状态机思想:用显式状态解决 JavaScript 异步时序失控问题
第一次看到这个标题的时候我愣了一下帽子、死亡、宇宙、JavaScript、状态机。这几个词平常不会出现在同一个界面里但真正写过一段时间 JavaScript 的人多半会觉得这个组合并不荒诞。JavaScript 项目里最让人头疼的往往不是某个算法写不出来而是你很难回答一个问题当前系统到底处于什么状态状态机恰好就是用来回答这个问题的。我印象很深的一次排查场景并不复杂。用户连续快速点击保存按钮第二次点击后没有任何反馈页面不报错控制台没有日志网络请求也没有发出去。业务代码看起来一切正常表单校验通过了按钮没有禁用API 函数也确实被调用了。最后把问题落到状态层面才看清楚第一次点击已经把整个流程推进到了“提交中”而“提交中”这个状态根本没有定义“再次保存”应该怎么处理。它不是被用户取消了也不是被组件销毁了只是这个状态下不存在那条转移路径。这种问题靠加一个isSubmitting变量也能挡掉一部分但挡不干净。状态机想做的不是用另一种方式继续写条件分支而是把“当前状态”这件事显式建出来让状态、事件、转移路径成为代码结构和团队沟通的一部分。1. 真正的麻烦不是逻辑写不出来而是“此刻到底是什么状态”1.1 状态机不是高深理论是时间问题的显式化很多人对状态机的第一印象停留在编译原理里的“有限自动机”觉得那是理论课才会用到的东西。但状态机在业务代码里的角色更像是一个“时序管理器”。程序本质上是在时间中做决策的。if/else 能表达条件但表达不了历史。你写一个普通的赋值语句比如let isSubmitting true它确实记录了某个时刻的状态但这个状态是隐式的散落在组件、闭包、全局变量和业务函数里。等到操作变多事件变多状态之间的跳转关系就会逐渐失控。状态机换了一种思考方式它把“当前状态”当作一个显式变量把“事件”当作触发条件把“下一个状态”当作转移结果。你不再需要从十几个布尔值里推断系统现在处于什么阶段因为状态机的state字段就是唯一答案。这看起来像是一个简单的抽象但它改变了问题的性质。过去遇到“页面表现异常”时你要在代码里到处找那个可能没有复位成功的变量现在遇到同样的问题先看状态机当前在哪一个状态再对照状态表看这个事件是否合法。1.2 JavaScript 的事件驱动特性让隐式状态更容易失控浏览器和 Node.js 都是事件驱动的。用户点击、网络响应、定时器触发、WebSocket 消息、组件生命周期这些事件会在你无法精确预料的顺序下发生。异步代码进一步放大了这个问题一个请求发出去之后用户又点了别的地方回调却可能在完全不同的时间回来。在传统同步流程里状态几乎等于执行位置程序运行到哪一行大概就知道进行到哪一步。但在 JavaScript 的异步事件流里执行位置不能代表业务阶段。发起请求那一行代码早就执行完了请求结果回来时用户可能已经进入了另一个页面或者又发起了另一个请求。这种场景下真正需要控制的不是某一行代码而是“事件到达时系统当前允许执行什么”。状态机正是干这个的。它先把所有可能的状态枚举出来再把每个状态下允许的事件写清楚最后才让业务代码去响应事件。1.3 状态机不会消灭 if/else而是把散落的状态判断集中起来有一个常见误解用了状态机代码里就不应该有 if/else 了。其实不是。状态机里照样有条件判断典型的是各种 guard也就是“守卫条件”。它真正消灭的是那种“凭感觉判断系统当前处于哪个状态”的散装代码。比如“保存按钮能不能点击”过去可能在按钮组件里判断一次在提交函数里判断一次在监听器里又判断一次。每次判断都在重复估算系统状态而且这三处判断很可能不完全一致。状态机的做法是状态的唯一来源是机器本身事件是否合法由转移表决定按钮和业务函数都去问同一个对象。所以状态机的价值不在于少写几个 if而在于让非法路径可以被看见。一条从“提交中”到“提交中”的转移路径如果不存在用户快速点击第二次保存时系统就不会茫然地再发一个请求。2. 帽子、死亡和宇宙三个理解状态机的隐喻2.1 帽子状态必须是有限、可辨认、有边界的帽子是个很合适的比喻。你在某个时刻只能处于一种头部状态没戴帽子、戴了棒球帽、戴了头盔或者戴了草帽。你不能同时处于两种帽子状态而且这个集合是有限的每一种状态都可以被明确辨认。程序里的状态也应该这样。loading就是loadingsuccess就是success不能出现一个既算loading又算success的中间地带。常见做法是用字符串常量或 TypeScript 的联合类型把这组有限状态直接写死type LoadState | idle | loading | success | error;这样做的一个直接好处是非法值在编译期就很难出现。如果某行代码把状态写成了succees编辑器立刻会提醒你。但“可辨认”还不只是值要写对。真正可辨认的意思是状态只能通过转移产生而且能从外部观测。也就是说一个状态机不应该是黑盒它运行到任何一个时刻你都可以通过machine.state这种接口知道它现在在哪并且把它记录到日志里。做不到这一点状态机就失去了意义。2.2 宇宙状态空间一旦爆炸状态机就是星图“宇宙”对应的是状态空间。这是个非常实际的问题。假如你的页面里有三个独立布尔值是否加载中、请求是否失败、弹窗是否可见。三个布尔值组合起来就是 8 种情况。如果再加一个“是否正在保存”就是 16 种。你会发现很多组合在业务上根本不存在但因为代码没有阻止它就可能出现。有些人会认为状态机反而会把状态变多因为要枚举所有可达状态。但状态机真正做的事是只保留那些有业务意义的合法状态。它不是在描述“世界可以怎样”而是在描述“这个世界允许怎样”。当然宇宙是很大的。有些系统的状态数量确实会爆炸尤其是存在多个并行维度时比如 UI 显示状态、网络连接状态、用户操作状态同时存在。这时候不要试图把它们压进一个扁平状态机。更合理的做法是拆成几个正交的“区域”或者子状态机各自管理各自的转移再在业务层把它们组合起来。状态机的价值不是穷尽宇宙里所有可能而是建立一张能用的星图我们不需要知道每一颗星星的位置但必须知道从当前位置出发有哪些航道是安全的。2.3 死亡终止状态和失败状态要像正常流程一样被设计“死亡”听起来很像标题党但放在状态机里一点都不夸张。任何异步流程都有终点成功、失败、取消、超时、被用户主动中断。这些终点如果不在状态表里出现系统就会卡在一个不是终点的状态里看起来还活着实际已经无法继续推进。更常见的是失败状态被当成临时状态处理。请求失败了弹一个 toast然后把状态改回idle。这没问题但问题在于“失败之后能做什么”没有被显式建模。失败之后可以重试可以取消可以填写新数据重新提交也可以在连续失败 N 次后进入锁定态。这些路径如果不画出来写代码时很容易漏掉某个分支。状态机的另一个设计原则是“快速失败”。当当前状态收到一个不被允许的事件时最糟糕的处理方式不是直接忽略而是静默地吞掉。更好的做法是抛出一个错误让开发者立刻意识到状态表少画了一条边。生产环境里可以选择忽略或降级但在开发阶段非法转移应该像一条越界访问一样被暴露出来。“死亡”还提醒我另一件事一个系统要允许被干净地停止。用户关闭页面、任务被取消、请求被 abort这些“终止事件”也要成为状态机的一部分。否则组件销毁了请求还在飞回调还要去更新一个已经不存在的东西。3. 从零搭一个状态机最小实现与真实事件流3.1 先定义状态表和事件表动手写代码前最该做的不是搜索用什么库而是把状态表和事件表列出来。以一次异步请求为例当前状态事件下一个状态idleFETCHloadingloadingRESOLVEsuccessloadingREJECTerrorerrorRETRYloadingerrorRESETidlesuccessFETCHloading注意loading状态下没有定义FETCH事件。这就是前面说的“非法转移”的位置。如果你希望用户在请求期间不能再点保存这个空位就是规则本身如果你希望用户再点一次可以取消当前请求那就应该定义一条从loading到idle的CANCEL事件。状态机不帮你做业务决策但会把决策逼到桌面面上来。3.2 最小状态机代码结构一个最简单的 JavaScript 状态机不依赖任何第三方库。核心思路是维护一个current变量以及一张配置好的转移表function createMachine(config) { let current config.initial; return { get state() { return current; }, can(event) { return Boolean( config.states[current] config.states[current][event] ); }, send(event) { const next config.states[current]?.[event]; if (!next) { throw new Error( 非法转移: 状态 ${current} 不接受事件 ${event} ); } current next; return current; }, }; }然后定义一个异步请求状态机const requestMachine createMachine({ initial: idle, states: { idle: { FETCH: loading, }, loading: { RESOLVE: success, REJECT: error, }, success: { FETCH: loading, }, error: { RETRY: loading, RESET: idle, }, }, });这个版本虽然小但已经具备状态机最核心的两个能力能问“我现在能不能接收这个事件”以及能在非法事件到来时报错。3.3 接入异步请求时的常用套路真实场景里状态机不会自己发起请求。它只负责回答“当前允不允许切换到下一个状态”以及切换之后当前状态是什么。请求逻辑仍然写在业务代码里async function loadData() { if (!requestMachine.can(FETCH)) { return; } requestMachine.send(FETCH); try { const res await fetch(/api/data); const data await res.json(); store.data data; requestMachine.send(RESOLVE); } catch (err) { store.error err; requestMachine.send(REJECT); } }这个写法有一个容易被忽略的好处请求还没发出去之前先通过can(FETCH)做了一次合法性检查。如果当前状态是loading这次点击会被直接忽略。换作以前你可能要在多个地方复制 “如果正在提交就 return” 这样的判断而且很容易漏掉某一处。注意一个细节状态机的send应该只负责改变状态真正的数据写入、UI 提示、网络请求副作用应该放在send调用前后的业务代码里。这个习惯能避免状态机变成一个看不懂的副作用垃圾场。4. 状态 vs 数据事件 vs 动作最容易想混的两组边界4.1 状态是“处境”数据是“内容”新手最容易搞混的就是把数据和状态当成一回事。比如一个列表页数组是空是满这个“空”是状态吗不一定。[]是数据而“空状态”是用户界面里的一种展示处境。状态机关心的是“当前处于加载中、成功、失败、空数据”中的哪一种而不是数组的长度具体是多少。实际操作上状态机里不要塞太多业务数据。requestMachine的状态只需要告诉你当前处于哪个阶段而不需要把每次请求的完整响应体都保存下来。数据放进 store、ref 或 context 里状态机只负责编排阶段。这样做的好处是状态机的状态空间可以被控制住。如果你为“重试次数为 2”单独定义一个状态那“重试次数为 3”也要单独定义状态数量会失控。正确的处理是状态仍然只有error或loading动态重试次数放在守卫条件里判断。4.2 事件是已经发生的事实动作是转移时的副作用事件名要表达“发生了什么”而不是“现在去做什么”。FETCH这个词有一点点歧义它看起来像是在命令系统去请求。更精确的事件名可能是REQUEST_STARTED、REQUEST_SUCCEEDED、REQUEST_FAILED。用“过去时”或“被动式”命名能帮助你区分事件和动作。状态机收到事件后决定是否发生状态转移。转移本身不是动作真正打开 loading、关闭弹窗、发请求、埋点上报这些动作应该在转移两侧显式执行。如果把这些动作全部塞进转移逻辑里状态机会变成一个很难测试的地方——因为你每测一次“状态能不能转移”都会连带触发一次网络请求或 UI 更新。推荐的拆分方式是事件触发send。send内部查转移表。如果合法更新current。由调用者根据新的状态执行对应副作用。4.3 用“三段式状态建模”收敛复杂度很多架构课会把“从状态建模开始”当作第一课这句话放到 JavaScript 项目里同样成立。我在实际操作中常用一组“三段式状态建模”的流程第一段列状态。把业务切面里所有能说清名字的阶段列出来。如果一个业务切面的状态超过 7 个不要急着继续加先考虑拆成多个子状态机。第二段列事件。把所有可能到达这个切面的事件全部列出来包括用户操作、组件生命周期、网络回调、定时器、外部消息。第三段填转移矩阵。一个表格里列出当前状态、事件、下一个状态。合法路径肯定要填但更重要的是注意那些“业务上不允许发生”的组合它们是不需要处理的但需要用测试或开发期报错来守住。完成这三步之后代码怎么写反而变得不太重要。你可以手写一个简单的状态机也可以用成熟的状态机库但建模结果已经决定了代码质量的上限。5. 落地状态机的正确顺序先画图再编码最后再考虑工具5.1 从最容易出问题的交互切片开始不建议一上来就把整个项目重构成状态机。那会让改动面非常大而且很难评估状态建模是否正确。更稳妥的做法是先从“事件乱序最容易造成 bug”的交互切片开始。哪些交互适合比如表单提交与重复点击。文件上传尤其是暂停、继续、取消、失败重试。WebSocket 连接涉及连接中、已连接、断线重连、手动关闭。异步任务队列涉及排队、执行中、超时、完成、失败。视频播放器涉及加载、播放、暂停、缓冲、播放结束。选一个当前 bug 最多的切片单独抽出状态机。跑一段时间后再决定要不要推广到其他模块。这样风险最小也最容易看到效果。5.2 先手写状态表再考虑引入状态机库很多人一提到状态机就直接想到引库其实先手写一个 20 行版本比直接引库更能帮助你建立直觉。手写版本的好处是简单、透明、没有依赖。状态转移表写在哪日志打在哪报错怎么处理全部由你控制。对于一个小模块、一个小组件来说这个方案已经够用。但如果项目复杂度继续上升比如你需要状态转移的历史记录和撤销在同一份配置里维护多个平行区域可视化地画出状态图并生成代码与 DevTools 联动实时查看当前状态在状态转移之间支持多种守卫条件和执行副作用这时候成熟的状态机库会省掉很多事情。社区里有很多 JavaScript 状态机库选择标准可以看三件事状态转移是否支持运行时校验是否提供可视化工具以及异步副作用如何定义。不过我更建议的做法是先用最原始的转移表把业务理清楚再决定要不要上库。业务不清晰时任何库都救不了你。5.3 不是所有场景都需要状态机状态机也有很明显的适用边界。如果一个流程是纯线性的比如“读取配置 - 转换数据 - 写出文件”中间没有分支、没有事件乱序、没有重复触发那么普通函数组合就够了。如果一个系统是连续变化的比如实时动画里的速度、位置这些值理论上存在无限多种连续状态强行用有限状态机建模反而会失真。遇到这种情况你可能需要的是插值、动画系统或行为树而不是状态机。状态机擅长的是“离散状态 显式事件”的问题不是所有问题。判断标准很简单当你无法回答“当前处于什么阶段”时状态机合适如果阶段本来就不是问题那就不要硬造出一个状态机。6. 状态机调试链路从“点了没反应”顺藤摸瓜6.1 最常踩的三个坑第一个坑是状态机实例不对。组件内部每次 mount 都创建一个新实例两个实例之间互不共享状态。看起来状态机在跑但实际生效的是另一个实例。排查时先确认你打印的state和业务代码读取的是不是同一个对象。第二个坑是异步竞态。请求 A 先发出请求 B 后发出结果 B 先回来。如果回调直接无脑send(RESOLVE)状态机会被先到达的响应推进到success。等 A 回来再RESOLVE时要么报非法转移要么覆盖掉 B 的数据。这时候要在请求发出时记录版本号或者用 AbortController 取消过期请求。第三个坑是在守卫条件里直接改状态。守卫是用来做判断的不是用来执行副作用的。如果canRetry()内部顺手把retryCount 1下一次判断时结果就会变化而且你很难定位这个副作用。6.2 排查顺序先问状态再问事件最后问守卫遇到状态机相关的问题我会按这个顺序排查打印当前状态machine.state。打印触发事件确认用户操作或回调送来的是哪个事件名。对照状态表当前状态和该事件之间是否存在合法转移。检查守卫条件如果配置了守卫确认守卫读取的数据源是不是最新值。检查状态机实例是不是同一个实例是不是在正确的组件或模块里持有。检查异步上下文回调是否捕获了旧状态是否用了过期的闭包变量。这个顺序背后的逻辑是前面任何一步错了都不需要走到后面。绝大多数“点了没反应”的问题最后都会落在“当前状态不接受这个事件”或“守卫条件不满足”这两类原因上。状态机的一个优势是它天然比普通代码更容易留下日志。你只需要在send方法里打两行日志就能记录每次事件到达前和状态改变后的完整时间线。6.3 状态机不是银弹适用边界要写清楚状态机能解决一部分“时序混乱”的问题但不会替代数据校验、权限控制、业务规则或者架构设计。它甚至本身就带有一定的学习成本状态表要更新文档要维护团队成员要理解状态和事件的区别。如果只是一两个文件里用状态机其他几百个文件仍然是隐式状态满天飞那它的价值也体现不出来。更准确地说状态机更适合出现在那些“用户可感知、事件会重复、状态会持续一段时间”的流程里。一次简单的页面上拉加载更多没有必要上状态机一个支持离线重传的同步引擎则非常适合状态机。我见过一些项目把所有模块都改造成状态机结果状态图画出来比业务代码还复杂最后维护成本直接翻倍。状态机是收敛复杂度的工具但它自己也可能成为复杂度。使用它之前先回答一个问题不用状态机的情况下这个模块的“当前状态”是不是真的难以回答如果是它就值得如果不是保持简单可能是更好的选择。最后说回标题里的那顶帽子。帽子看起来只是一个小物件但戴上之后很多动作都会受到约束。状态机也是这种约束。它不会替你写请求函数不会替代业务判断但它会在你准备第二次快速点击保存的时候明确告诉你此刻没有一条合法的路。这种约束恰恰是长期维护一个复杂项目最需要的东西。