前几天看到一个很有意思的标题:“帽子、死亡、宇宙、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 生态中比较成熟的状态机库,社区文档完整,支持有限状态机、层次状态机、并行状态,并且提供了可视化工具。如果你不想引入依赖,也可以直接跳过本节,看后面的手写版本。
创建项目目录并安装 XState:
mkdir 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:超时。
第四步,建立状态转移表。这是整个建模过程最关键的一步。
| 当前状态 | 事件 | 下一状态 |
|---|---|---|
| pendingPayment | START_PAY | paying |
| pendingPayment | CANCEL | cancelled |
| pendingPayment | TIMEOUT | expired |
| paying | PAY_SUCCESS | paid |
| paying | PAY_FAIL | pendingPayment |
| paying | CANCEL | cancelled |
| paid | 无 | 终止状态 |
| cancelled | 无 | 终止状态 |
| expired | 无 | 终止状态 |
注意,这张表里没有列出pendingPayment状态下收到PAY_SUCCESS的情况,也没有列出paying状态下收到TIMEOUT的情况。这些就是非法事件,状态机要做的就是忽略或拒绝它们。
有了这张表,代码只是把表翻译一次。如果没有这张表,直接写代码,大概率会把状态机写成一堆守卫条件,最后还是回到 if else。
7. 使用 XState 实现订单支付状态机
现在把上一节的转移表翻译成 XState 代码。新建orderMachine.js:
import { 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_PAY,XState 默认会忽略,并且onTransition不会触发。这个特性刚好可以用来拦截异步竞态。
8. 手写一个轻量状态机
如果不想为一个小功能引入 XState,可以自己写一个几十行的状态机。手写状态机的核心只有三部分:当前状态、状态配置表、事件分发函数。
新建handwrittenMachine.js:
export 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.js:
import { 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.js:
import { 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 项目,它确实是最值得先试的工具。