你到底打了多久——把练习时长做成确定、防篡改、可复现
键道 KeyDo 复盘:练习时长原来用 Date.now() 的 end - start,挂机、切 tab、墙钟回拨全算进去了。改成累加「相邻击键之间被接受的间隔」,超 60 秒的间隔整段丢弃;再叠上输入法守卫、单调时钟、幂等结算,让这个数字既准又不被刷。
键道 KeyDo 的成绩——WPM、准确率、练习时长——全在浏览器里算。之前写过打字引擎怎么用 ref 存真相。这篇讲一个更朴素、也更容易被忽视的数字:练习时长到底怎么算才准? 原来用 Date.now() 的 end - start,结果玩家挂机、切走、甚至系统墙钟被 NTP 回拨,全都算进了「练习时长」。这次把它连根改成确定、可复现、不被刷的。
有效打字时长:累加间隔,不是墙钟差
核心思路的转变是:练习时长不再是「结束时间 - 开始时间」,而是累加每两次有效击键之间被接受的间隔。
export function acceptActiveAction(state, now) {
let activeMs = current.activeMs
if (current.lastAcceptedAt !== null) {
const gapMs = now - current.lastAcceptedAt
if (gapMs >= 0 && gapMs <= MAX_ACCEPTED_GAP_MS) { // 只累加合理间隔
activeMs += gapMs
}
// gapMs < 0(墙钟回拨)或 > 60s(挂机)→ 整段不累加,以当前击键为新基准
}
return activeTimeState(activeMs, now)
}
MAX_ACCEPTED_GAP_MS = 60_000:只有相邻两次击键的间隔落在 [0, 60000]ms 才累加,超过 60 秒的间隔整段丢弃、并以当前这次击键作为新基准。这就是「挂机不计时」的确定化实现——你打一会儿去泡杯咖啡,回来接着打,中间那几分钟的间隔超了 60 秒,不进账。边界精确到毫秒:59999ms、60000ms 计入,60001ms 整段忽略(闭区间)。
配套还有个 suspend:页面切到后台(visibilitychange 到 hidden)、失焦(blur)、pagehide 时,把「上次击键时间」置空,让恢复后的第一键重新建基准。注意 suspend 不是「暂停计时」——测速、挑战的墙钟在后台照样走、局照样会到点结束,只是练习时长因为基准被清空而不累计。两套时间语义并存:倒计时用墙钟,练习时长用有效间隔。
时钟要用单调的
上面那个 gapMs >= 0 的守卫,防的是系统墙钟被回拨(NTP 校时、用户改时间)导致间隔为负。但更彻底的做法是换时钟源:计时和截止判定都用 performance.now()(单调时钟,只增不减、不受系统时间调整影响),只有真正要落库的时间戳才用 Date.now()。
这样一来,墙钟被回拨既不能刷时长(间隔算出来不会是负数)、也不能提前结束一局(截止判定走单调时钟)。该用单调时钟的地方用单调时钟,是这类计时防篡改的地基。
输入法:中文组合输入不能污染计数
一个容易漏的坑是输入法。中文、日文在「组合输入」期间,浏览器会派发一批 keydown,其 key === 'Process' 或 keyCode === 229,还带 isComposing = true。如果不挡,这些会被当成普通字符污染击键数和字符计数,还会错误推进 activeMs 的基准。
守卫统一放在所有业务键盘处理器的最前面:
export function shouldIgnoreBusinessKey(event, compositionActive) {
return Boolean(
compositionActive
|| event?.isComposing
|| event?.key === 'Process'
|| event?.keyCode === 229, // 部分浏览器组合首键既非 isComposing 也非 Process
)
}
CompositionProvider 用 compositionstart / compositionend 维护一个共享标志,还有个关键兜底:window 失焦、document 隐藏时强制复位这个标志——因为切走时可能丢掉 compositionend 事件,不复位的话输入会被永久锁死在「组合中」状态,之后所有键都被忽略。
结算要幂等,别重复上报
成绩重复上报的根因是:完成回调、pagehide、组件卸载清理,可能连着触发好几次。解法是一个「只增水位 + 一次性完成标记」的纯状态:
export function claimPracticeDelta(state, activeMs) {
const persistedActiveMs = Math.max(current.persistedActiveMs, activeMs) // 只入账未入账的增量
return { state: ..., deltaMs: persistedActiveMs - current.persistedActiveMs }
}
export function claimCompletion(state) {
const accepted = !current.completionClaimed // 完赛只被接受一次
return { state: settlementState(..., true), accepted }
}
pagehide、blur、Esc、Tab 重开、卸载,全走同一条结算路径,只提交 deltaMs > 0 的增量、且只有 claimCompletion.accepted 为真才写完整成绩。哪怕 React StrictMode 把回调跑两遍,水位和完成标记也挡住了重复。
帧转换也要原子
单词雨这类游戏还有个竞态:旧代码把「移动词、判落地、扣命、结束、生成新词」拆在多个 setState 里,多次触发就可能重复扣命、重复提交。改成把这些压进单个 advanceRainFrame 的返回值,lives = Math.max(0, lives - missedCount)、endedNow = lives === 0 每帧只算一次。帧间隔还钳到 MAX_FRAME_MS = 100——否则切 tab 回来,一个超大的时间差会让单词「瞬移穿过」地板,一瞬间掉光所有命。
附:内容确定化 + 按身份隔离
两个补充。题目内容原来用 Math.random(),同一局在不同设备不可复现、也没法测;改成种子化 RNG,种子由 ${内容}:${runId}:${块序号} 拼出——同参数必得同文本,可复现可测。以及匿名多身份下,练习数据原来全塞在全局 localStorage 键里会串数据;现在每个身份独立命名空间(键加 keydo:user:${identity}: 前缀),只有设备偏好留全局,还用白名单硬挡越权写全局。
复盘
- 时长要「累加有效间隔」,不是「墙钟差」。end - start 会把挂机、切走全算进去;累加相邻击键间隔、超阈值整段丢弃,才是「你确实在打字」的时长。阈值边界精确到毫秒,才可测;
- 该用单调时钟就别用墙钟。
performance.now()不受系统时间调整影响,计时和截止都走它,墙钟回拨就既刷不了时长也提前不了结束; - 输入法守卫要放在最前、且要能自愈。组合输入的
Process/229/isComposing统一在处理器入口挡掉;失焦/隐藏时强制复位标志,防丢compositionend把输入永久锁死; - 结算幂等,才扛得住多次触发。完成回调 + pagehide + 卸载会重复开火,用「只增水位 + 一次性完成标记」把重复上报挡在纯状态里,所有退出路径共用一条结算;
- 把每帧的多次 setState 压成一次原子转换。竞态导致的重复扣命、重复提交,根因常是「一件事分了几次改状态」——合成单个 reducer 返回值,每帧只算一次。
留言