JavaScript有限状态机:从手写实现到工程化落地
发布时间:2026/9/9 3:12:04 作者:尧图编辑部 阅读量:1,286

帽子可以戴上也可以摘下人死亡是一个不能再转移的终态宇宙很大但任意时刻只会处于一个确定的状态。这个画面感很强的比喻对应的正是计算机里最经典的概念之一有限状态机。JavaScript 恰好是上手状态机时非常舒服的语言既能写浏览器里的交互逻辑也能写 Node.js 服务端的任务编排。这篇文章就把“帽子、死亡、宇宙”拉回键盘从概念到手写实现再到 XState、前端框架、批量任务和单元测试完整过一遍状态机的落地玩法。不讲虚的直接说你会得到什么第一理解状态机为什么能让 if-else 少一大半第二有一份零依赖的 JavaScript 状态机实现可以直接跑第三知道在 React/Vue、Node.js 批量任务里怎么用状态机第四拿到一套状态转移表和测试用例的写法方便你迁移到自己的项目。状态机不是银弹但凡是带“状态流转”的业务逻辑它都能把复杂度控制住。1. 状态机核心能力速览维度说明核心内容有限状态机FSM的概念、状态建模、JavaScript 实现与工程化落地运行环境浏览器 / Node.js无 GPU 和显存要求依赖情况自研实现零依赖使用 XState 时通过 npm 安装入门难度中等偏易重点在建模思路不在 API可验证成果可运行的状态机脚本、转移表、单元测试用例常用库XState、Robot或自研对象映射状态机扩展场景Web 前端交互、Node.js 任务编排、游戏 AI、嵌入式状态建模使用边界适合显式状态流转不适合高频数值计算或复杂并发系统生产环境需加审计和授权控制从这张表能看出来状态机不是一个“新框架”而是一套建模方法。它不挑显卡、不挑平台JavaScript 里随便一个对象加一个函数就能跑起来。真正的门槛在于你能不能把业务里的“状态”和“事件”拆清楚。2. 从“帽子、死亡、宇宙”理解状态机的思维模型先别急着写代码把标题里的三个词拆掉。帽子是最直观的有限状态集合一顶帽子在现实里可能只有“戴上”和“摘下”两种状态对应状态机里就是两个节点。你说“半戴半不戴”在现实里可能成立但在离散状态模型里不行——为了可计算我们总是把连续世界切成有限个明确状态。这个“切”的动作就是状态建模。死亡可以看作是终态。终态不是 bug而是状态机对“流程结束”的显式表达。比如订单状态机里订单一旦完成或取消就不应该再接收支付、发货这类事件。如果没有终态系统就会允许“已取消的订单还能发货”这种荒唐事发生。终态的存在是状态机和普通布尔变量最大的区别它不允许你来路不明地乱跳。宇宙对应状态空间。一个系统所有可能的状态合在一起就是它的宇宙。状态机的价值在于它帮你把整个宇宙画在一张表里而不是散落在几十个 if-else 里。你只要看这张表就能回答“现在在哪里、什么事件能触发什么转移、哪些状态永远到不了”。这个模型为什么适合 JavaScript因为前端交互本质上就是事件驱动用户点击、键盘输入、接口返回每个事件都可能改变界面状态。服务端任务也一样一次批量导入有等待、执行中、成功、失败、重试。把这些过程写成状态机代码的可读性和可维护性会明显提升。后面所有内容都围绕这件事展开。3. 环境准备与前置条件状态机的运行环境非常轻量不需要 GPU不需要单独安装模型也不需要配置 CUDA。你只需要安装了 Node.js 的运行环境建议使用当前 LTS 版本。一个代码编辑器VS Code 或其他编辑器都可以。如果想用 XState需要 npm 或 pnpm 来安装依赖。先确认 Node.js 环境node -v npm -v如果能看到版本号环境就满足要求。后面所有示例既可以在 Node.js 脚本里跑也可以直接放到浏览器的开发者工具里调试。唯一需要注意的是不要为了炫技引入一堆依赖。状态机的核心只是一个转移函数。4. 零依赖手写一个 JavaScript 状态机这次我们直接用 Node.js 写一个最小可用的订单状态机。先定义状态和事件状态pending、paid、shipped、completed、cancelled事件PAY、SHIP、COMPLETE、CANCEL合法转移pending --PAY-- paidpending --CANCEL-- cancelledpaid --SHIP-- shippedshipped --COMPLETE-- completed创建一个order-machine.jsconst orderMachine { initial: pending, states: { pending: { on: { PAY: paid, CANCEL: cancelled } }, paid: { on: { SHIP: shipped } }, shipped: { on: { COMPLETE: completed } }, completed: { type: final }, cancelled: { type: final } } }; function createTransitionTable(machine) { const table {}; for (const [stateName, state] of Object.entries(machine.states)) { if (state.on) { for (const [event, next] of Object.entries(state.on)) { table[${stateName}.${event}] next; } } } return table; } function createMachine(machine) { const table createTransitionTable(machine); let currentState machine.initial; return { get state() { return currentState; }, send(event) { const key ${currentState}.${event}; const next table[key]; if (!next) { throw new Error(非法转移: ${currentState} --${event}-- ?); } currentState next; return currentState; } }; } const order createMachine(orderMachine); console.log(order.state); // pending order.send(PAY); console.log(order.state); // paid order.send(SHIP); console.log(order.state); // shipped order.send(COMPLETE); console.log(order.state); // completed运行node order-machine.js输出应该是pending paid shipped completed这个实现很短但已经包含状态机的三要素有限状态集合、事件驱动、显式转移。它还自带拦截能力——如果你在completed状态再发送PAY因为转移表里没有completed.PAY代码会直接抛错。这种“快速失败”在业务里很有用总比等到调用支付接口后才发现订单状态不对要强得多。如果想把“动作”也加进去比如进入某个状态时发送通知可以在状态对象上增加enter回调const orderMachineWithAction { initial: pending, states: { pending: { on: { PAY: paid } }, paid: { on: { SHIP: shipped }, enter: () console.log(通知用户已支付) } } };这里的enter会在状态切换时执行。你把这种机制再升级一下就接近 XState 的entry动作了。这也是为什么很多人建议不要一上来就用重型状态机库先手写一次你会对事件、状态、动作有更体感的认识。5. 用 XState 处理复杂状态流和并发逻辑手写状态机适合学习和简单场景。但当状态多了比如十几个状态、几十个事件还要处理并发任务、延迟、守卫条件时自己维护转移表会变得很吃力。这时候可以使用 XState。先安装npm init -y npm install xstate4下面的例子里我们用 XState 写一个交通信号灯状态机状态循环为 green - yellow - red - greenconst { createMachine, interpret } require(xstate); const trafficLightMachine createMachine({ id: trafficLight, initial: green, states: { green: { on: { TIMER: { target: yellow } } }, yellow: { on: { TIMER: { target: red } } }, red: { on: { TIMER: { target: green } } } } }); const service interpret(trafficLightMachine) .onTransition((state) { console.log(当前状态: ${state.value}); }) .start(); service.send({ type: TIMER }); service.send({ type: TIMER }); service.send({ type: TIMER });注意XState 的版本迭代比较快v4 和 v5 在部分 API 上不同。如果你安装的是新版本以官方文档为准重点不是 API 细节而是“状态机可以被解释执行”这件事。XState 还提供了几个对工程化很有用的能力守卫条件通过cond判断是否允许转移。动作进入状态、离开状态、转移时执行副作用。子状态机把复杂状态拆成嵌套状态。Actor 模型用spawn创建并发执行的任务状态机之间可以通信。可视化XState 官方提供的可视化工具可以把状态图直接渲染出来方便和产品、测试对齐。用状态机库不是为了让代码看起来高级而是为了把状态流转变成可审计、可回放、可测试的数据结构。这对需求经常变动的业务很有价值新加一个状态你只需在状态定义里加节点在事件里加连线而不是去翻一堆组件里的isXxx布尔变量。6. 在前端框架中使用状态机React 与 Vue前端框架里的状态管理最容易出现的问题就是“布尔变量太多”。isLoading、isError、isSuccess一旦组合起来页面状态几乎不可预测。解决方案是把这些布尔变量收敛成一个状态字段。先看 React 中用useReducer实现异步加载状态机const loadReducer (state, action) { switch (action.type) { case FETCH: return state idle ? loading : state; case SUCCESS: return state loading ? success : state; case FAILURE: return state loading ? error : state; case RETRY: return state error ? loading : state; default: return state; } }; function useLoadState() { const [state, dispatch] useReducer(loadReducer, idle); return { state, dispatch }; }组件里使用function Page() { const { state, dispatch } useLoadState(); useEffect(() { fetchData() .then(() dispatch({ type: SUCCESS })) .catch(() dispatch({ type: FAILURE })); }, []); if (state loading) return div加载中/div; if (state error) return div加载失败/div; if (state success) return div加载成功/div; return null; }这个模式的好处是不可能出现“error 和 success 同时为 true”的情况因为渲染完全由state一个字段决定。Vue 3 里的写法更接近状态机本身import { ref } from vue; const state ref(idle); const transitionTable { idle.FETCH: loading, loading.SUCCESS: success, loading.FAILURE: error, error.RETRY: loading }; function transition(event) { const key ${state.value}.${event}; const next transitionTable[key]; if (next) { state.value next; } } // 使用 transition(FETCH); // state - loading transition(SUCCESS); // state - success把转移表单独抽出来最大的收益是你不再需要到处搜索isLoading是在哪里被改成false的。所有状态变化的可能路径都写在一张表里一眼看全。7. 在 Node.js 服务端用状态机管理批量任务状态机不只属于前端。Node.js 服务端最常见的场景是批量任务比如批量导入文件、批量发送通知、批量处理视频。一次任务的完整生命周期通常包含 pending、running、success、retry、failed。如果只用 if-else 写重试逻辑会到处复制用状态机写任务每次运行前先检查自己当前状态能跑才跑。下面是一个极简的批量任务状态机实现支持重试const STATE { PENDING: pending, RUNNING: running, SUCCESS: success, RETRY: retry, FAILED: failed }; class Task { constructor(id, fn) { this.id id; this.fn fn; this.state STATE.PENDING; this.retries 0; } canRun() { return this.state STATE.PENDING || this.state STATE.RETRY; } async run() { if (!this.canRun()) { return false; } this.state STATE.RUNNING; try { await this.fn(); this.state STATE.SUCCESS; } catch (error) { if (this.retries 3) { this.retries 1; this.state STATE.RETRY; } else { this.state STATE.FAILED; } } return true; } toJSON() { return { id: this.id, state: this.state, retries: this.retries }; } } class BatchRunner { constructor(tasks, concurrency 1) { this.queue [...tasks]; this.done []; this.concurrency concurrency; } async start() { const workers Array.from({ length: this.concurrency }, async () { while (this.queue.length 0) { const task this.queue.shift(); await task.run(); if (task.state STATE.RETRY) { this.queue.push(task); } else { this.done.push(task); } } }); await Promise.all(workers); return this.done; } } // 使用示例 const tasks [ new Task(task-1, async () { // 模拟一部分任务失败 if (Math.random() 0.5) { throw new Error(random error); } }), new Task(task-2, async () { console.log(task-2 done); }) ]; const runner new BatchRunner(tasks, 2); runner.start().then(() { for (const task of tasks) { console.log(task.toJSON()); } });这个实现里Task的状态变化是显式的。每次run之前检查canRun如果任务已经成功或失败就不会再被启动。重试次数达到上限后failed作为终态避免无限重试把系统拖垮。如果把状态机暴露成接口服务只需要在 HTTP 接口里返回任务状态即可。下面是一个通用的 Node.js HTTP 服务示例实际字段需要按你的项目调整const http require(http); const tasks []; function findTask(id) { return tasks.find((task) task.id id); } http .createServer((req, res) { res.setHeader(Content-Type, application/json); if (req.url /tasks/1) { const task findTask(task-1); if (task) { res.end(JSON.stringify(task.toJSON())); } else { res.statusCode 404; res.end(JSON.stringify({ error: task not found })); } return; } res.statusCode 404; res.end(JSON.stringify({ error: not found })); }) .listen(3000, () { console.log(server listening on 3000); });查询curl http://127.0.0.1:3000/tasks/1返回示例{ id: task-1, state: running, retries: 1 }这种结构的优势是前端轮询、API 网关鉴权、日志审计都可以直接基于这个统一的状态字段做。状态不是散落在变量里的而是可以通过接口暴露给调用方的第一等概念。8. 状态转移表、可视化与单元测试状态机要落地不能只在脑子里“觉得能跑”。实际工程里我建议你做两件事维护一张状态转移表给关键转移写单元测试。8.1 状态转移表把上面的订单状态机整理成表格const orderTransitionTable [ { from: pending, event: PAY, to: paid }, { from: pending, event: CANCEL, to: cancelled }, { from: paid, event: SHIP, to: shipped }, { from: shipped, event: COMPLETE, to: completed } ];这张表可以直接用于驱动状态机也可以用于生成文档。更重要的是你可以写一个脚本检查“每个非终态至少有一条出路”或“不存在两个事件从同一状态转移到同一个目标状态”。这种静态检查能帮你提前发现建模漏洞。8.2 单元测试使用 Node.js 内置的node:test写测试。假设你已经封装了createMachine可以这样测const { test } require(node:test); const assert require(node:assert/strict); const { createMachine } require(./order-machine); test(订单状态机正常流转, () { const order createMachine(orderMachine); order.send(PAY); order.send(SHIP); order.send(COMPLETE); assert.equal(order.state, completed); }); test(订单状态机取消流程, () { const order createMachine(orderMachine); order.send(CANCEL); assert.equal(order.state, cancelled); }); test(订单状态机非法转移抛错, () { const order createMachine(orderMachine); order.send(PAY); assert.throws(() order.send(PAY)); });运行测试node --test状态机是少数很适合“全转移覆盖测试”的代码结构。状态数量有限事件数量有限组合起来通常也就几十条路径。把每一条合法路径和几条非法路径都写成测试用例比在 UI 上点点点要可靠得多。这也是很多嵌入式项目强调状态机测试的原因。不止是 Web嵌入式软件架构第一课通常会讲如何用状态机收敛复杂度QP状态机、Godot状态机、三段式状态机都是这类思路的具体表现。状态建模做得越细后续测试和联调就越省事。9. 资源占用与性能观察状态机本身不消耗多少资源。它不涉及深度学习推理也不需要显存。在浏览器里一个状态机实例只是几个对象和字符串在 Node.js 服务端大量任务实例也主要是内存和 CPU 的常规开销。但有几个点会影响性能状态转移表使用对象查找平均复杂度接近 O(1)如果使用长数组线性查找状态多时会慢但业务状态通常不会超过几十个。批量任务并发数直接影响内存和 CPU。并发太高会导致任务排队和系统负载上升需要配合信号量或连接池。状态机里如果挂了异步动作比如每次进入某个状态就发送 HTTP 请求这时的瓶颈在 IO不在状态机本身。观察 Node.js 进程的资源占用node --trace-gc task-runner.js更简单的做法是在应用里打印内存占用const used process.memoryUsage(); console.log(RSS: ${(used.rss / 1024 / 1024).toFixed(1)} MB);一般状态机的性能不会是系统瓶颈真正要关心的是状态机之外的异步任务和 IO。所以建议先把状态建模建正确再考虑优化。10. 常见问题与排查方法问题现象可能原因排查方式解决方案发送事件后状态没变转移表里没有对应的合法转移打印当前状态和事件名检查状态定义和转移表补充合法转移非法事件没有报错状态机实现默认忽略不合法事件查看send方法是否抛异常改成“不合法转移直接抛错”状态机出现循环跳转事件定义成自循环或缺少终止条件画出状态图检查终态为不应继续流转的状态加终态定义UI 状态和接口状态不一致前端和后端各维护了一份状态抓包查看请求和返回让前端状态机作为后端状态的镜像不自行拼接批量任务一直处于 running异步任务没有正确 await检查任务函数是否返回 Promise在run内统一 try/catch 并设置超时重试任务无限重复重试次数没有字段记录打印retries字段达到最大重试次数后进入 failed 终态状态机代码里 if-else 仍然很多转移和动作混在回调里把动作移到entry或exit中将副作用从状态转移逻辑中剥离常见问题的根源基本都是“状态没有显式建模”。如果你发现代码里到处是status doing和isFinished的布尔组合状态机就是最好的替代方案。但记得状态机只负责状态流转不负责把所有业务逻辑都塞进状态节点里。11. 最佳实践与使用建议根据实际项目经验我建议你按下面的顺序用状态机第一步先用小状态练手。不要一开始就在核心订单模块上重构。找一个页面级的加载状态或者一个登录表单的校验流程把 idle、loading、success、error 建好运行一周体会一下“状态可预测”的感觉。第二步把状态转移表沉淀成文档。哪怕表格只有十行也要和同事约定后续改状态先改表再改代码。这样能避免“这个事件加不上”的情况。第三步给状态机写测试。至少覆盖所有合法路径和关键非法路径。状态机的测试成本比其他业务逻辑低因为它的输入和输出都是明确的。第四步注意异步边界。状态机的同步部分容易理解难的是在 async 环境中转移。标准的做法是先修改状态再触发异步副作用异步返回后发送新事件。不要让异步回调直接修改状态机内部字段否则状态会失控。第五步守住安全与合规边界。如果状态机涉及支付流程、用户隐私数据、人脸/语音类功能或版权素材处理必须遵守最小权限原则保留完整的操作日志用于审计。任何状态变更都应该能回答是谁、在什么时候、因为什么事件把状态从 A 变成了 B。12. 总结帽子、死亡、宇宙这三个词把状态机的核心说透了有限的状态集合、明确的终态、巨大的状态空间管理。JavaScript 是实现状态机最方便的语言之一零依赖可以写XState 可以让它更健壮React/Vue 里可以收敛 UI 状态Node.js 里可以管理批量任务和重试嵌入式里可以用同样的思路收敛硬件逻辑。值得立刻做的一件事找一个已经写满status判断的小业务把它改成一个不超过 10 个状态的状态机再补上测试。跑通之后你再看状态机相关的话题会比翻十篇教程都有用。最容易踩的坑不是状态机写不出来而是连“状态”和“条件”都没分清楚。先把当前业务里所有布尔变量列出来考虑它们哪些其实是某个状态字段的组合然后试着用一张转移表去替代它们。状态机不会让所有代码变简单但它能让“会变的逻辑”变得可维护、可测试、可审计。这也是它从帽子与宇宙的哲学比喻变成前端、后端、游戏、嵌入式共同语言的根本原因。