把前端状态机做成可回放系统:命令日志、确定性重放与回归测试

📅 2026/7/24 5:57:02
把前端状态机做成可回放系统:命令日志、确定性重放与回归测试
引言复杂前端最难修的 Bug常常不是报错而是这句话我刚才点了几下就出问题了但现在按同样顺序又复现不了。页面仍然能打开控制台也没有异常。真正丢失的是状态怎样一步步走到这里的证据。录屏能保留视觉过程却不能告诉我们每次操作对应的结构化输入保存一个最终状态快照又无法说明中间经过了哪些分支。若页面还依赖随机数、当前时间、网络返回或隐藏的全局变量即使拿到相同点击顺序也可能得到不同结果。我这次没有引入 Redux、后端日志服务或完整事件溯源框架而是在三个零依赖单 HTML 页面中加入一套最小回放结构所有可改变规则状态的输入先变成命令所有命令只经过一个dispatch入口回放从同一场景初态重新执行原命令序列用规范化状态摘要比较实时结果与回放结果。这套方案不只适用于游戏。配置向导、可视化编辑器、规则表单、排班工具和离线工作台只要状态变化主要由离散操作驱动都可以用相同思路提高可复现性。快照、DOM 录制和命令日志不是一回事先把三种常见方案分清楚。方案保存什么适合解决什么主要缺点状态快照某一时刻的完整或部分 state恢复现场、断点续用看不到状态形成过程DOM 操作录制点击坐标、选择器、键盘事件UI 自动化、真实交互回放与布局和文案强耦合命令日志具有业务语义的离散输入复现状态转移、比较规则结果要求状态机具有确定性例如点击页面坐标(214, 633)只是设备输入寻找淡水才是业务命令{ type: ACTION, kind: water }选择器改名、按钮换位置或移动端改成底部工具栏后这条命令仍然有效。反过来命令日志也不能替代端到端测试。它能证明同一规则入口产生同一状态不能证明按钮可见、触控区域足够大或事件绑定没有失效。因此本文最后仍会让 Playwright 真正点击页面。先建立四条确定性约束图 1命令日志驱动的确定性回放结构。实时执行和回放执行共享同一个dispatch给定相同初始上下文与命令序列规范化digest必须完全相等。一套状态机能够回放至少要满足四条约束。1. 初始上下文必须明确命令序列本身通常不够。下面两次操作显然会得到不同结果场景 A [STEP, STEP, BUY air] 场景 B [STEP, STEP, BUY air]因此回放输入至少应包含场景或关卡 ID规则版本玩家在开始前选择的角色、协议或难度若确实存在随机过程则包含固定种子而不是保存某次Math.random()的偶然结果。本次三个页面分别把拍卖行索引、生存路线索引、传播情景与初始协议作为回放上下文。makeState()只读取这些明确数据构造固定初态。2. 状态变化只能通过统一入口以生存页面为例四类规则输入被压缩为一个小型命令集合function dispatch(command, recordCommand true) { if (recordCommand) state.history.push({ ...command }); if (command.type ACTION) action(command.kind); else if (command.type CRAFT) craft(command.kind); else if (command.type END_DAY) endDay(); else if (command.type EVENT) chooseEvent(command.choice); }页面按钮不再直接修改木材、口渴或信号值而是发出命令button.onclick () dispatch({ type: ACTION, kind: button.dataset.action });自动流程同样不能绕过入口。传播页面的自动演算只是定时发出{ type: STEP }而不是另写一套快速模拟逻辑。3. 规则函数不能偷偷读取不稳定输入最常见的破坏源包括Math.random()Date.now()动画帧间隔网络请求返回顺序DOM 当前文本没有进入初态或命令的模块级变量。这次使用固定拍品、固定天气、固定事件序列和固定地区网络。变化仍然很多但变化来自数据与命令而不是无法追踪的即时随机。如果产品确实需要随机性可以把随机数生成器也放进状态const roll nextRandom(state.seed); state.seed roll.seed; state.value roll.value;只要初始种子和算法版本相同回放仍然成立。4. 副作用必须与规则结果分开一次实时通关可能会更新生涯星级写入localStorage播放音效弹出通知发送遥测。回放不能再次执行这些副作用否则查看复盘可能把统计累加两遍。当前单文件实现使用state.replaying抑制生涯写入if (!state.replaying) { profile.stars[index] Math.max(profile.stars[index], stars); profile.clears 1; save(); }更大型的工程适合让状态转移函数返回{ nextState, effects }实时运行器执行effects回放运行器直接丢弃它们。这样规则和副作用边界会比布尔标记更清楚。回放不是把 history 再循环一次这么简单最小实现看起来确实只是重新执行命令function replay(commands, index routeIndex) { const oldIndex routeIndex; const liveState state; routeIndex index; state makeState(); state.replaying true; for (const command of commands) { dispatch(command, false); } const result digest(state); state liveState; routeIndex oldIndex; renderAll(); return result; }其中dispatch(command, false)的第二个参数非常重要。若回放时继续记录命令遍历中的history会不断增长轻则重复重则形成无法结束的循环。这个实现适合小型单文件项目但它还暴露了一个工程风险回放临时替换了模块级state。如果中途抛出异常现场恢复可能无法执行。至少应使用try/finallyconst liveState state; try { state makeState(); state.replaying true; for (const command of commands) dispatch(command, false); return digest(state); } finally { state liveState; }更稳妥的长期方向是把核心改成纯 reducerfunction replay(commands, context) { return commands.reduce( (current, command) reduce(current, command).nextState, createInitialState(context) ); }这时回放不会碰真实页面状态也不需要先保存再恢复全局变量。不要比较整个 state要比较规范摘要图 2三个真实页面的实时状态与回放状态比较。截图用于证明页面经过了真实交互测试分别取得实时digest与从命令日志重建的digest然后执行严格相等比较。直接执行JSON.stringify(liveState) JSON.stringify(replayState)通常过于脆弱。完整 state 里可能包含history本身只供 UI 展示的日志序号定时器句柄是否正在回放这样的运行标记可由核心状态重新计算的缓存浮点计算产生的无意义尾差。因此每个页面定义一个规范摘要。传播模型只保留决定规则结果的字段并把浮点数统一到四位小数function digest(state) { return JSON.stringify({ mode: state.mode, cycle: state.cycle, dna: state.dna.toFixed(4), cure: state.cure.toFixed(4), deaths: state.deaths.toFixed(4), upgrades: state.up, levels: state.levels, regions: state.regions.map((item) item.inf.toFixed(4)), modifiers: state.mods }); }这里的digest不是密码学摘要也不负责安全校验。它只是一个稳定的状态投影用于回答影响后续规则和结算的字段是否一致字段选择过少会漏掉分叉选择过多又会把无关 UI 状态变成失败原因。一个实用判断是如果某个字段不同会改变下一条合法命令或最终结算它通常应该进入摘要。回放日志和跨设备档案要分开命令日志可能很长而且通常只对当前局有效跨设备档案则应该小、稳定并能跨版本迁移。本次三种档案只保存局外结果档案保存内容不保存内容AUCTION2解锁、星级、最佳资产、生涯统计当前竞价 historyISLAND2路线星级、最佳生命、完成纪录当前十日命令序列PATH2情景星级、最佳周期、研究纪录当前演算中间状态这种拆分避免把调试数据永久塞进用户存档也避免规则版本变化后旧命令被新状态机错误解释。如果产品确实要分享复盘应单独设计回放协议例如REPLAY.2.scenario.ruleset-hash.commands.checksum其中至少要包含规则版本或内容哈希。只保存命令而不锁定规则版本几个月后同一日志可能因为平衡调整产生完全不同的结果。怎样在真实浏览器里验证回放专项测试没有直接向history塞假数据而是先操作真实页面await page.locator([data-actionwood]).click(); await page.locator([data-actionwater]).click(); await page.locator(#sleepBtn).click(); await page.locator([data-choice0]).click();然后在页面上下文中取实时摘要和回放摘要const result await page.evaluate(() { const api window.__islandSurvival; return { live: api.digest(api.state()), replay: api.replay(api.state().history) }; }); assert.equal(result.live, result.replay);这样一次断言同时覆盖了DOM 事件是否生成了正确命令命令是否进入日志实时与回放是否调用同一状态入口规范摘要是否一致。传播模型还额外保存四条参考策略。测试从四个情景的固定初态运行对应命令计划最终分别在 12、12、18、22 个周期完成目标。这不能证明策略最优但能证明内容发布时至少存在一条满足当前阈值的可行路径。执行命令node promo-video/scripts/check-event-replay-scenarios.mjs图 3回放专项测试与全仓浏览器审计结果。三组真实命令序列的实时摘要与回放摘要完全一致三种档案完成往返四条参考策略全部通关全仓 100 个页面在桌面和手机共 200 个组合中未发现加载失败、脚本错误、控制台错误或横向溢出。本次真实验证结果如下检查结果命令序列回放3 / 3 摘要一致跨设备档案往返3 / 3 通过传播参考策略4 / 4 完成目标桌面与手机专项页面6 / 6 无横向溢出全仓页面组合200加载失败0JavaScript 错误0控制台错误0横向溢出0全仓审计命令为node promo-video/scripts/audit-games.mjs这里仍要强调证据边界三条样例命令一致不代表所有可能序列都已穷举四条参考策略可行也不代表数值平衡已经最优Chromium 的桌面与移动上下文不能替代 Safari 和真实低性能设备。五个容易踩中的坑1. 只记录成功操作如果一次购买因为资源不足被拒绝是否应该进入日志若拒绝结果完全由当前状态决定记录命令通常更有诊断价值因为它保留了用户真实意图。若只记录成功动作复盘会看起来像用户从未点击过。但要保持一致要么记录所有已提交命令要么在命令结果中明确标记accepted不要让不同按钮采用不同规则。2. 自动流程绕过 dispatch定时器、AI、批处理和自动完成按钮最容易另写一套快捷逻辑。只要它们能改变核心状态就应该发出同一类命令或产生可记录的系统命令。3. 把音效、动画时间写进核心状态视觉插值和音频播放位置通常不应该进入规则摘要。否则不同帧率、后台标签页节流或音频策略会让同一命令序列产生不同digest。4. 忘记命令协议也需要版本{ type: BUY, id: air }的含义可能随版本改变。长期保存或联网传输回放时应给命令协议和内容数据加版本并为旧版本定义迁移或明确拒绝策略。5. 日志无限增长编辑器和长期工作台不能把所有命令永远留在内存。常见做法是定期生成快照只保留快照之后的增量命令调试上传还要设置大小上限并清理敏感字段。什么时候值得做命令回放出现下面任意两项时我会优先考虑这套结构用户报告经常依赖一串操作才能复现同一规则被鼠标、触屏、快捷键和自动流程共同调用页面包含长流程、分支、撤销或重做规则测试需要绕过大量 DOM 准备状态产品希望提供复盘、分享或问题诊断包随机、时间和副作用已经让回归结果不稳定。最小落地清单可以压缩为十项定义少量、带业务语义的命令所有核心输入统一经过dispatch初始上下文包含场景、配置和规则版本随机过程使用可保存种子核心状态不读取 DOM、时间和网络隐式值回放不重复记录 history持久化、音效和遥测在回放中被抑制digest只包含会影响规则与结算的规范字段浮点字段按业务精度归一化至少有一条真实 UI 操作到命令回放的端到端测试。结语可回放系统最有价值的地方不是多了一个回看按钮而是它迫使前端回答三个架构问题什么才算一次业务操作哪些数据真正决定状态转移哪些行为只是可以被隔离的外部副作用当这些边界明确以后实时执行、自动演算、问题复现和回归测试才有机会共享同一套规则而不是各自维护一份看起来相似的流程。本文对应源码、专项测试与图片生成脚本位于开源仓库GitHub - wangzifan396-wzf/mini-browser-games: 100 zero-dependency, single-file HTML5 browser games | 100 款零依赖浏览器小游戏支持桌面/触屏、离线运行与质量分级 · GitHub