尧图精选

JavaScript状态机实战:从建模到代码收敛业务复杂度

🕒 发布时间:2026/9/9 22:14:00 📁 来源:尧图网络
前几天看到一个很有意思的标题“帽子、死亡、宇宙、JavaScript与状态机”。乍一看像是哲学混搭视频看下去才发现这三个词其实都在说状态机帽子是状态切换死亡是终止状态宇宙是状态空间而 JavaScript 是实现环境。这不是故弄玄虚。它回答了一个前端和 Node 工程师每天都会遇到的问题业务逻辑越来越乱if else 越堆越多有没有一种方法能把复杂度收敛住状态机就是答案之一。先说结论。状态机不是一个新框架也不属于某个库。它是一套建模方法把某个对象可能处于的全部状态列清楚把外部触发定义为事件再规定每个状态下遇到事件后转到哪里去。只要规则足够完整业务代码里的分支会被压缩成一张状态转移表。热搜词里反复出现的“嵌入式软件架构第一课用状态机收敛复杂度”“qp状态机”“godot状态机”“三段式状态机”都指向同一件事这套思路在嵌入式、游戏、前端、服务端流程控制里都是通用工具。这篇文章会带着你从“状态建模”开始不急着写代码。先给出一套可以直接照做的建模步骤然后用 XState 实现一个订单支付状态机再手写一个轻量级状态机最后补充功能测试、性能观察、常见问题排查和工程化最佳实践。看完之后你可以立刻拿手头最复杂的一处流程去试。1. 核心能力速览能力项说明项目类型JavaScript 状态机建模与实现方法核心功能用有限状态机收敛复杂度、约束非法迁移、追踪状态变更运行环境Node.js 12任意支持 JavaScript 的浏览器环境启动方式npm 初始化项目Node.js 脚本运行测试框架验证依赖要求XState 可选手写状态机不需要任何第三方依赖主要路径状态建模 → 状态与事件定义 → 转移表 → 代码实现 → 测试适合场景前端页面状态、表单流程、异步任务、订单状态、游戏状态、嵌入式逻辑模拟是否支持 API状态机本身不是 API 服务但可以驱动 API 请求流程是否支持批量任务状态机支持批量业务状态流转需要配合队列和幂等设计资源占用纯 CPU 计算无 GPU 需求内存占用与状态数和事件数相关2. “帽子、死亡、宇宙”三个隐喻对状态机意味着什么这个标题看起来抽象实际上非常适合解释状态机。“帽子”是状态切换。任何对象都可以看成“戴着一顶帽子”一个按钮有“可点击”“点击中”“不可点击”三种帽子一个订单有“待支付”“支付中”“已支付”三种帽子。状态机要解决的不是帽子的数量而是什么时候允许换帽子、换到哪顶帽子。“死亡”是终止状态。任何一个流程最终都会落到一个终点成功、失败、取消、超时、关闭。很多项目混乱就是因为终止状态没有显式建模失败路径靠 throw 到处传播取消路径靠 return 硬塞最后谁也说不清楚这单业务到底有几种结局。状态机要求把终止状态写出来这是收敛复杂度最有效的一步。“宇宙”是状态空间。一个复杂的业务系统理论上可以有很多种状态组合。状态机不追求枚举所有可能的宇宙只约束当前对象必须停留在有限状态集中的某一个状态。换句话说它把可能的空间校准到了一个可控的范围内。把这三个隐喻放到 JavaScript 里本质就是一件事在任何时刻只能处于一个明确状态从当前状态触发合法事件才能进入下一个状态。非法事件要么被忽略要么进入兜底处理而不是靠一堆布尔变量去猜测。3. 为什么 JavaScript 项目需要状态机JavaScript 项目里的复杂度很大一部分来自异步和多分支。一个组件加载数据需要处理“加载中”“成功”“失败”“超时”“重试”这几种状态。如果每个状态都用布尔变量表示你会得到isLoading、isSuccess、isError、isTimeout这样一组变量。组合起来是什么有可能出现isLoading和isSuccess同时为 true 的情况代码一旦进入这种状态页面表现就会错乱。这种问题不是代码写得不够细心而是建模方式先天容易出错。布尔变量之间没有约束关系它们天然允许非法组合。状态机把状态约束成一个单值比如loading | success | error | timeout任何一个时刻只能取其中一个值非法组合在建模阶段就被消灭了。另一个复杂来源是异步竞态。用户先点击“发起支付”支付请求还没回来用户又点了“取消”随后支付结果才返回。没有状态机时你只能在回调里判断“当前是否已经取消”代码越写越绕。有状态机之后取消事件会把状态切到cancelled支付成功事件到达时发现当前状态不接受PAY_SUCCESS选择忽略问题自然被拦住了。状态机收敛复杂度的原理并不神秘就三条状态是显式的不会再被隐藏成零散变量的组合。事件驱动迁移逻辑变成一张可查的转移表。非法迁移被显式禁止不再靠 if 防御。4. 环境准备与前置条件这一节内容并不复杂但还是要说清楚。状态机实现需要以下基础环境操作系统Windows、macOS、Linux 均可。JavaScript 运行时Node.js 12 以上建议使用 Node.js 18 或更高版本内置测试框架node:test用起来更方便。包管理器npm、yarn 或 pnpm任选其一。编辑器VS Code 或任意支持 JavaScript 的编辑器。浏览器开发场景Chrome、Edge、Firefox 中可直接编写并运行状态机脚本。确认本机环境node -v npm -v输出示例v20.11.0 10.2.4版本号以实际环境为准。状态机代码不涉及 GPU、CUDA、显存等硬件加速所以不用关心显卡型号二手笔记本也能流畅运行。5. 安装 XState 并初始化项目这里以 XState 为例。XState 是 JavaScript 生态中比较成熟的状态机库社区文档完整支持有限状态机、层次状态机、并行状态并且提供了可视化工具。如果你不想引入依赖也可以直接跳过本节看后面的手写版本。创建项目目录并安装 XStatemkdir state-machine-demo cd state-machine-demo npm init -y npm install xstate为了让 Node.js 支持import语法需要修改package.json添加type: module{ name: state-machine-demo, version: 1.0.0, type: module, main: index.js, scripts: { start: node index.js, test: node --test } }之后新建一个orderMachine.js文件开始写状态机。6. 状态建模第一课从状态建模开始很多人写状态机失败不是因为不会写代码而是跳过了建模直接写配置。热搜词里那句“嵌入式软件架构第一课用状态机收敛复杂度从状态建模开始”说得非常准确。建模顺序应该是先画状态转移表再写代码。以“订单支付”场景为例。第一步明确实体。这里的实体是订单。第二步列出该实体可能处于的全部状态。订单在支付流程中至少有以下状态pendingPayment待支付。paying支付中。paid已支付。cancelled已取消。expired已超时失效。第三步列出触发状态变化的事件。支付场景中的事件包括START_PAY发起支付。PAY_SUCCESS支付成功。PAY_FAIL支付失败。CANCEL取消订单。TIMEOUT超时。第四步建立状态转移表。这是整个建模过程最关键的一步。当前状态事件下一状态pendingPaymentSTART_PAYpayingpendingPaymentCANCELcancelledpendingPaymentTIMEOUTexpiredpayingPAY_SUCCESSpaidpayingPAY_FAILpendingPaymentpayingCANCELcancelledpaid无终止状态cancelled无终止状态expired无终止状态注意这张表里没有列出pendingPayment状态下收到PAY_SUCCESS的情况也没有列出paying状态下收到TIMEOUT的情况。这些就是非法事件状态机要做的就是忽略或拒绝它们。有了这张表代码只是把表翻译一次。如果没有这张表直接写代码大概率会把状态机写成一堆守卫条件最后还是回到 if else。7. 使用 XState 实现订单支付状态机现在把上一节的转移表翻译成 XState 代码。新建orderMachine.jsimport { createMachine } from xstate; export const orderMachine createMachine({ id: order, initial: pendingPayment, states: { pendingPayment: { on: { START_PAY: paying, CANCEL: cancelled, TIMEOUT: expired } }, paying: { on: { PAY_SUCCESS: paid, PAY_FAIL: pendingPayment, CANCEL: cancelled } }, paid: { type: final }, cancelled: { type: final }, expired: { type: final } } });再新建index.js用interpret启动状态机服务并发送事件import { interpret } from xstate; import { orderMachine } from ./orderMachine.js; const service interpret(orderMachine) .onTransition((state) { console.log(当前状态:, state.value); }) .start(); service.send({ type: START_PAY }); service.send({ type: PAY_FAIL }); service.send({ type: START_PAY }); service.send({ type: PAY_SUCCESS });运行node index.js预期输出当前状态: paying 当前状态: pendingPayment 当前状态: paying 当前状态: paidonTransition会在每次状态实际变化时触发。如果需要监听事件的每次发送即使状态没有变化也要感知可以用onEvent或其他事件回调具体取决于 XState 版本以官方文档为准。测试过程中的几个判断标准第一次START_PAY后状态从pendingPayment变到paying说明合法迁移生效。PAY_FAIL后状态回到pendingPayment说明支付失败重试路径可用。再次START_PAY后进入paying说明状态机支持循环路径。最终PAY_SUCCESS后进入paid终止状态生效。如果发送一个当前状态不支持的非法事件比如在paid状态发送START_PAYXState 默认会忽略并且onTransition不会触发。这个特性刚好可以用来拦截异步竞态。8. 手写一个轻量状态机如果不想为一个小功能引入 XState可以自己写一个几十行的状态机。手写状态机的核心只有三部分当前状态、状态配置表、事件分发函数。新建handwrittenMachine.jsexport function createStateMachine({ initial, states }) { let current initial; function getState() { return current; } function transition(event, payload) { const currentStateNode states[current]; if (!currentStateNode) { throw new Error(未知状态: ${current}); } const nextState currentStateNode.on?.[event]; if (!nextState) { console.warn([状态机] 当前状态 ${current} 不接受事件 ${event}已忽略); return current; } const prev current; if (currentStateNode.onExit) { currentStateNode.onExit(prev, nextState, event, payload); } current nextState; if (states[current]?.onEntry) { states[current].onEntry(prev, current, event, payload); } return current; } function reset() { current initial; } return { getState, transition, reset }; }使用方法如下import { createStateMachine } from ./handwrittenMachine.js; const orderMachine createStateMachine({ initial: pendingPayment, states: { pendingPayment: { on: { START_PAY: paying, CANCEL: cancelled } }, paying: { on: { PAY_SUCCESS: paid, PAY_FAIL: pendingPayment, CANCEL: cancelled }, onEntry(prev, next, event) { console.log(订单从 ${prev} 进入 ${next}原因是 ${event}); } }, paid: {}, cancelled: {} } }); orderMachine.transition(START_PAY); orderMachine.transition(PAY_SUCCESS); console.log(最终状态:, orderMachine.getState());这个手写版本已经具备状态机的核心能力状态集中管理外部只能看到getState()。非法事件会被忽略并输出警告日志。通过onEntry和onExit支持进入和离开状态的副作用。它缺少的部分是没有自动状态持久化没有并行状态没有层次状态。如果你的业务需要这些能力直接用 XState 更合适。9. 功能测试与效果验证状态机的优点之一就是容易测试。因为输入是“当前状态 事件”输出是“下一个状态”这是纯函数式的逻辑结构不需要模拟复杂环境。Node.js 18 及以上版本自带node:test测试模块。新建test/orderMachine.test.jsimport { test } from node:test; import assert from node:assert/strict; import { interpret } from xstate; import { orderMachine } from ../orderMachine.js; test(订单状态机默认状态为 pendingPayment, () { const service interpret(orderMachine).start(); const current service.getSnapshot?.() ?? service.state; assert.equal(current.value, pendingPayment); service.stop(); }); test(支付成功后进入 paid 状态, () { const service interpret(orderMachine).start(); service.send({ type: START_PAY }); service.send({ type: PAY_SUCCESS }); const current service.getSnapshot?.() ?? service.state; assert.equal(current.value, paid); service.stop(); }); test(支付失败后回到 pendingPayment, () { const service interpret(orderMachine).start(); service.send({ type: START_PAY }); service.send({ type: PAY_FAIL }); const current service.getSnapshot?.() ?? service.state; assert.equal(current.value, pendingPayment); service.stop(); }); test(已支付状态后发送 CANCEL 不会改变状态, () { const service interpret(orderMachine).start(); service.send({ type: START_PAY }); service.send({ type: PAY_SUCCESS }); service.send({ type: CANCEL }); const current service.getSnapshot?.() ?? service.state; assert.equal(current.value, paid); service.stop(); });运行测试npm test预期输出会显示 4 个测试全部通过。测试建议覆盖以下维度默认状态是否正确。每个合法事件迁移后的状态是否正确。非法事件是否被忽略。终止状态是否不可继续迁移。所有状态是否可达是否存在永远无法进入的死状态。每次迁移的副作用函数是否只执行一次。如果测试中发现某个非法事件意外改变了状态说明状态机配置里多写了不该有的迁移回到转移表检查即可。10. 性能与资源占用观察状态机不需要 GPU不消耗显存也基本不产生大量内存占用。它在运行时的开销主要集中在三块事件分发、状态查找、副作用回调。观察性能的方法很简单直接记录大量状态迁移的总耗时。新建benchmark.jsimport { performance } from node:perf_hooks; import { interpret } from xstate; import { orderMachine } from ./orderMachine.js; const service interpret(orderMachine).start(); const start performance.now(); for (let i 0; i 100000; i) { service.send({ type: START_PAY }); service.send({ type: PAY_FAIL }); } const end performance.now(); console.log(10 万次状态迁移耗时: ${(end - start).toFixed(2)}ms); service.stop();运行node benchmark.js实际耗时会受到本机 CPU、Node.js 版本、XState 版本和日志输出的影响重点不是追求一个绝对数字而是观察量级。如果 10 万次迁移在几百毫秒到几秒之间说明状态机的开销可以忽略。在手写状态机版本中性能通常会更好一些因为逻辑更简单没有额外的事件队列和解释器开销。实际工程里的性能风险主要来自副作用不是状态机本身。比如在onEntry里发起 HTTP 请求、操作 DOM、写入数据库这些操作每次进入状态都会执行。高频事件触发时要考虑以下措施副作用加节流或防抖。事件队列增加批处理机制。日志输出在生产环境关闭或抽样。不要把耗时操作直接放在状态迁移的同步回调里。如果状态机运行在浏览器主线程最需要关注的是执行长任务导致的页面卡顿。可以通过 Chrome DevTools 的 Performance 面板录制一次状态迁移过程观察主线程上是否存在超过 50ms 的长任务。11. 常见问题与排查方法问题现象可能原因排查方式解决方案事件发送后状态没有变化当前状态没有定义该事件检查状态转移表和 XState 配置在对应状态增加事件映射或确认事件是否合法状态机乱跳出现预期之外的状态事件被重复触发或异步回调竞态打印所有事件发送日志给事件加唯一 ID使用事件队列幂等处理提示“未知状态”状态名称拼写错误检查states配置和初始值统一状态命名常量避免魔法字符串进入终止状态后还能收到事件业务层没有做状态判断检查终止状态定义在 service 外层判断状态或让终止状态不配置on同一事件导致多个副作用执行状态机上配置了重复监听检查onTransition和onEvent注册次数统一事件监听入口避免重复订阅调试困难不知道当前状态没有状态日志在onTransition中输出state.value开发环境开启日志生产环境用埋点状态数量持续膨胀建模粒度太细或状态没有收敛重新梳理状态转移表合并语义相近的状态或使用层次状态机页面卡顿副作用太耗时高频触发用 Performance 面板记录长任务副作用异步化、节流必要时使用 Web Worker最典型的坑是“没有梳理转移表就直接写 XState 配置”。一旦业务复杂很容易出现状态漏写、事件漏配、非法迁移悄悄发生的问题。任何一次状态机变更都应该先从修改转移表开始。12. 最佳实践与工程化建议状态机用得好代码非常清爽。用得不好会成为另一种形式的 if else。下面这些建议来自实际工程中的常见做法可以直接参考。第一先建模后编码。先列出实体、状态、事件、转移表确认没有遗漏后再写代码。复杂流程至少要把转移表给同事过目一遍。第二状态和事件命名统一维护。不要到处写PAY_SUCCESS字符串建议定义常量export const OrderStatus { PENDING_PAYMENT: pendingPayment, PAYING: paying, PAID: paid, CANCELLED: cancelled, EXPIRED: expired }; export const OrderEvent { START_PAY: START_PAY, PAY_SUCCESS: PAY_SUCCESS, PAY_FAIL: PAY_FAIL, CANCEL: CANCEL, TIMEOUT: TIMEOUT };第三状态机只负责流转不负责业务实现。发起支付请求、写入数据库、弹窗提示这些操作应该放在 action 或onEntry中而不是让状态机本身去实现请求逻辑。第四不要在状态机之外维护另一套状态标志。如果状态机已经接管了状态就不要再用isSuccess这类布尔变量否则两套状态会不一致。第五条件分支用守卫处理。支付失败之后是否需要重试可能依赖失败次数。这种情况下可以给事件配置守卫条件只有满足条件时才允许迁移。第六控制状态机规模。一个状态机内状态超过 15 个可读性会明显下降。此时应该考虑拆分子状态机或者使用 XState 的层次状态机能力。第七日志和审计。生产环境建议记录状态迁移的关键路径至少保留订单号、旧状态、新状态、事件、时间戳。这不仅能排查问题也是合规审计的基础。第八涉及用户数据、订单信息、支付记录的状态机必须注意数据合规。日志中不要输出完整手机号、身份证号、银行卡号等信息必要字段要做脱敏处理。所有状态变更操作都应在授权范围内进行不要用状态机去绕过权限校验。13. 总结与下一步这篇文章从“帽子、死亡、宇宙”这三个隐喻出发把状态机的核心概念拆开讲了一遍。最值得尝试的动作不是安装 XState而是先拿一个真实业务场景画状态转移表。你会发现很多复杂的代码问题在画完表的瞬间就消失了因为状态一旦显式化分支就没有地方可以藏。建议先做一个小验证找一个最近写的、if else 最多的流程列出它的状态和事件然后分别用 XState 和手写状态机实现一遍。对比一下两种实现的代码量、可读性和扩展性。最容易踩的坑是跳过建模直接写代码这个问题在实际项目中几乎一定会出现。后续可以继续往这几个方向扩展把状态机接入 React 的 reducer 流程里管理组件状态用 XState Inspector 可视化调试做状态机持久化和恢复或者把状态机用到 Node 服务端的任务编排中去。状态机不是银弹但对于状态复杂、分支繁多的 JavaScript 项目它确实是最值得先试的工具。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →