Skip to content

这不是一篇指南,本文旨在记录一次完整参与全栈项目的复盘。

起点 ​

老师临时让我们做一个抢答系统,要求在比赛中使用。于是我们就开始了这个项目。目标是实现选手实时抢答、自动计分,以及选手端、大屏端和管理端之间的实时联动。

食材选择 ​

最初选择了自己比较熟悉的技术栈:用 TS 作为主语言,Vue 页面搭建,并用主流的 node 后端框架 express 实现后端,数据库方面使用了同学之前使用过的 lowDB,挺小众的。

  • 部署:两个 Git 仓库 + 一台阿里云服务器
  • 前端:Node(TS) + Vue + ElementPlus + axios + Pinia + VueRouter
  • 后端:Node(TS) + express + lowdb

由于前后端都使用 TypeScript,理论上可以通过 Monorepo 共享类型。不过当时考虑到 pnpm workspace 的配置成本以及团队成员的上手成本,最终没有采用。(可惜想试试来着

项目需求分析 ​

项目最初并没有非常明确的需求结构。后来参考之前比赛中使用 Excalidraw 来分析功能的方式,把系统拆为选手端、大屏端和管理端,对各端的页面和功能进行划分。

img1.webp

不得不说,用 excalidraw 绘制手绘风格的图是真好看。

主要逻辑设计 ​

完成页面功能划分后,就开始设计后端的核心业务逻辑,让同学协助实现部分基础 API。

这里出现了第一个比较明显的问题:虽然前端 UI 已经画出来了,但没有把前端具体应该如何实现、数据应该如何流动描述清楚,最终导致前端后续进行了一次重写。

为了更可视化的展示后端的基本流程,后续又补充了一张流程图。

img2.webp

设计 ​

状态驱动 UI ​

整体采用了 状态驱动页面(State-Driven UI) 的方式。

简单来说页面本身不负责维护核心业务逻辑,只是 State 的一种表现形式。当前题目、抢答状态、倒计时等数据都有统一的数据结构。

前端使用 Pinia 保存这些状态。无论数据来自 WebSocket 还是 HTTP 请求,都只负责更新 State,再由 Vue 的响应式系统驱动 UI 更新。

核心的状态结构大致如下:

typescript
export interface GameState {
	/** 当前环节 */
	currentPhase: "waiting" | "required" | "snatch" | "risk" | "ranking";
	/** 当前正在进行的题号 */
	currentQuestionId: string | null;
	/** 抢答题倒计时时间戳 */
	snatchTimer: number | 0;
	/** 其他类型题目倒计时时间戳 */
	otherQuestionTimer: number | 0;
	/** 服务器时间戳 */
	serverTimestamp: number;
}

有了这层状态定义之后,交互就可以抽象成对 GameState 的修改, 来更新 UI。

抢答时效性 ​

由于接收请求响应有时间延迟,要解决的问题就是: 倒计时怎么做到多端一致, 以及怎么判断"谁先按下的"

最直觉的做法是广播“还剩 5 秒”,不过网络传输速度本身不确定,每台设备也不一样。

所以下发的是截止的绝对时间戳,每个设备用各自的时钟去计算就能得到剩余时间

但还有个问题,每台设备的本地时钟也不准,所以还需要一个来自服务器的时间戳,本地计算好时间差

ts
/**
 * 基于接收时刻和往返时延,估算服务器时间偏移量。
 */
export const estimateServerOffset = (
	serverTimestamp: number, 
	localReceivedAt: number, 
	roundTripMs:number = 0 
) =>  serverTimestamp - localReceivedAt + roundTripMs / 2
ts
// 接收到 GAME_STATE 广播消息后进行处理
if (data.serverTimestamp) {
	clientTime.value = receivedTime;
	serverTimeOffset.value = estimateServerOffset(
		data.serverTimestamp,
		receivedTime,
		networkRoundTripMs.value,
	);
}

基于这个代码就能实现多客户端的同步

后来才知道正统做法是 NTP 那种四时间戳中点法

对于谁先按下这个问题,有了上面的代码就容易多了。

各端上报自己相对于统一截止时刻的延迟,比较延迟相对延迟大小。

ts
const clickedAt = Date.now()
const pressDelayMs = clickedAt - snatchAnchor // snatchAnchor 是已换算到本地时钟的截止时刻

允许为负负数意味着在截止时刻之前就按了,于是同一个字段顺带解决了"提前抢答要扣分"的判定。

这个地方的漏洞也很明显,pressDelayMs 由客户端自己算,而客户端是可以撒谎的。不过对于这个小项目来说,作弊的可能性不大,所以就没有做额外的防护。

一些后续整理出来的问题 ​

后台功能凌乱 ​

项目分工有问题 ​

技术选型(包括 Monorepo) ​

项目管理(CI/CD) ​

功能(完善 LOG 记录) ​

关于AI ​

显然,第一次从头开始一个项目就能直接分析出项目需求是不太可能的。最开始是设想使用文档规范好项目模块,直接让 AI 进行组件代码的编写,但因为没有项目文档编写经验,也不了解怎么用文档规范 AI 编写的代码,导致后续 95% 的代码都是纯手写的 古法编程这一块。

Released under the MIT License.