状态机入门:从帽子死亡宇宙隐喻到JavaScript实现与实战
发布时间:2026/9/9 3:08:57来源:尧图网络
前端项目里状态机的概念经常被提起但真正把它用起来的人并不多。很多人写业务代码时习惯用一堆布尔变量表达状态比如isLoading、isSubmitting、isError等变量多到一定数量就会出现“改了一个开关另一个地方的表现完全不符合预期”的问题。这正是状态机要解决的把所有可能的状态、事件以及状态之间的转移关系显式定义出来避免系统进入没有定义过的、模棱两可的中间状态。标题里的“帽子、死亡、宇宙”三个词恰好能对应状态机的三个核心概念帽子对应状态死亡对应终止态宇宙对应状态空间。这篇文章会从这三个隐喻讲起用 JavaScript 实现一个可运行的状态机再讨论它到底适合解决什么问题。1. 先理解状态机的三个核心概念帽子、死亡与宇宙1.1 帽子状态决定了对象如何响应事件在现实生活里“戴帽子”是一种很容易理解的行为。一个人的行为会因为“是否戴着帽子”而不同。在室内摘下帽子和室外戴着帽子面对同一阵风反应完全不一样。如果把“人”看作一个系统那么“戴帽子”或“没戴帽子”就是两种状态。状态机里的状态state就是这个意思对象在某个状态中时遇到同一个事件event产生的反应是确定的。状态机规定了一个对象在任意时刻只能处于有限个状态中的一个这个约束是它管理复杂度的基础。在 JavaScript 代码里最常见的状态就是请求的loading、success、error。状态不同同一段数据更新代码执行后的 UI 表现完全不同。关键点是状态必须有限、可枚举而且要能区分开。如果一个状态既可以表示“正在加载”又可以表示“加载完成但数据为空”这种状态定义就是含糊的后面所有判断都会跟着含糊。1.2 死亡终止态一旦进入就不应再离开“死亡”在状态机里的对应概念是终止态terminal state。现实生活中死亡意味着生命不再经历任何新的状态转移在状态机里终止态指的是没有特别需求就不应再发生转移的状态。例如一个订单进入“已关闭”状态后理论上就不应再收到“支付成功”事件。如果代码里还允许这种转移发生就相当于让一个已经结束的流程重新活过来逻辑上会产生混乱。终止态的作用是约束系统的行为边界让开发者在设计阶段就想清楚哪些状态是“终点”哪些状态还可以继续演化。这种设计约束看起来是限制实际上是保护。1.3 宇宙状态空间组合爆炸是复杂度的真正来源“宇宙”对应的是状态空间state space。假设一个对象有两个布尔标志位状态组合数是 2 的 2 次方也就是 4 种如果有四个布尔标志位组合数变成 16 种如果标志位本身还有多值组合数会更快膨胀。这些组合里大部分是业务上不可能出现或者没有意义的但代码里没有显式约束时程序完全可能因为某次异常输入进入其中一种。状态机做的事情就是把“所有可能状态”收敛到一个明确集合里把“哪些状态可以转移、由什么事件触发”变成一张显式表。这样状态空间不再是隐性的排列组合而是一个可阅读、可测试、可审查的模型。这个模型就是状态机的宇宙。2. JavaScript 里实现状态机的四种常见写法在引入状态机库之前先用原生 JavaScript 把状态机的几种写法过一遍。实际开发时你会根据场景选择不同的实现复杂度。2.1 布尔开关最简单的两态机最原始的状态表达就是布尔值。let isLogin false; function handleLoginSuccess() { isLogin true; } function handleLogout() { isLogin false; }这种方式适合状态很少、只有“开和关”的场景。但它的问题是一旦状态超过两个布尔值就会变成多个组合就会爆炸而且布尔值之间没有任何约束代码里可能会出现isLogin isError同时为 true 的非法状态。所以布尔开关只适合表达单个开关不适合表达业务的完整状态。2.2 switch 分支经典但容易失控很多开发者第一次写状态机会用 switch按“当前状态 事件”做双层分支。let state idle; function transition(event) { switch (state) { case idle: if (event START) state running; break; case running: if (event STOP) state stopped; else if (event PAUSE) state paused; break; case paused: if (event RESUME) state running; else if (event STOP) state stopped; break; default: break; } }这种写法直观但状态一多switch 会变得很长而且状态和事件的判断逻辑散落在每个 case 里很难一眼看出整张转移表。它适合小型脚本不适合复杂业务。2.3 状态转移表数据驱动规则密集场景更清晰把状态转移关系抽成纯数据是更接近状态机本质的写法。const transitions { idle: { START: running, }, running: { PAUSE: paused, STOP: stopped, }, paused: { RESUME: running, STOP: stopped, }, }; function transition(state, event) { const next transitions[state]?.[event]; if (!next) { throw new Error(非法转移: ${state} - ${event}); } return next; }这里transitions就是一张状态转移表外层键是当前状态内层键是事件值是目标状态。如果某个事件在当前状态下没有定义就会进入错误分支这比静默忽略要安全得多。转移表的优点是把“规则”和“执行逻辑”分离缺点是如果要执行副作用需要额外设计。2.4 类封装把状态、事件和副作用绑定在一起把状态机和副作用放一起用类封装更合适。class Machine { constructor(initial, transitions, actions) { this.state initial; this.transitions transitions; this.actions actions || {}; } send(event) { const next this.transitions[this.state]?.[event]; if (!next) { throw new Error(事件 ${event} 在状态 ${this.state} 下不被允许); } const prev this.state; this.state next; this.actions.onTransition?.(prev, next, event); } }这个基础类封装了“状态存储、转移查询、转移动作钩子”实际项目可以在这个类上继续扩展增加进入/离开状态的钩子、增加状态历史记录、增加防重复转移判断。3. 环境准备与项目初始化动手前先准备好一个最简单的 JavaScript 练习环境。推荐用 Vite 初始化一个纯前端项目不引入框架减少干扰。3.1 初始化项目npm create vitelatest state-machine-demo -- --template vanilla cd state-machine-demo npm install npm run dev这个命令会创建一个基于原生 JavaScript 的 Vite 项目src目录下默认有一个main.js。后面的示例代码可以全部写在main.js或单独的模块文件里在浏览器控制台观察输出。3.2 目录结构建议把状态机的核心逻辑和业务示例分开便于后续测试。state-machine-demo/ ├── index.html ├── package.json ├── src/ │ ├── main.js │ ├── machine.js │ └── order-machine.jsmachine.js放通用状态机类order-machine.js放订单业务的状态定义和转移表main.js负责调用并打印结果。3.3 学习环境与生产环境的差别上面这个环境只为跑通逻辑。进入生产项目时状态机的使用方式需要额外考虑维度学习环境生产环境状态定义写死在代码里考虑从配置或后端下发状态转移同步逻辑可能涉及异步请求、重试、超时日志控制台打印上报到日志平台带 traceId持久化不持久化需要恢复状态考虑刷新生效场景测试手动点按钮单元测试覆盖每一条转移路径这一点很重要状态机的核心逻辑非常容易写单元测试因为输入是“当前状态 事件”输出是“目标状态 副作用”。生产环境应该把状态转移表当作被测对象而不是只测页面 UI。4. 用一个订单状态机跑通完整流程订单是状态机的经典业务场景。订单有明确的状态、明确的事件、明确不允许的转移非常适合做演示。4.1 需求与状态定义订单的状态定义如下。状态含义pending待支付paid已支付shipped已发货completed已完成cancelled已取消refunded已退款事件定义如下。事件含义PAY支付SHIP发货COMPLETE确认完成CANCEL取消REFUND退款正常的业务预期pending 可以 PAY 变成 paid也可以 CANCEL 变成 cancelled。paid 可以 SHIP 变成 shipped也可以 REFUND 变成 refunded。shipped 可以 COMPLETE 变成 completed也可以 REFUND 变成 refunded。completed 和 cancelled 是终止态不再接收任何转移。refunded 同样看成终止态。4.2 转移表设计把上述规则写成转移表export const orderTransitions { pending: { PAY: paid, CANCEL: cancelled, }, paid: { SHIP: shipped, REFUND: refunded, }, shipped: { COMPLETE: completed, REFUND: refunded, }, completed: {}, cancelled: {}, refunded: {}, };这里每个状态的空对象表示“该状态不接受任何事件”。这样写虽然看起来冗余但好处是状态全集一目了然代码审查时不需要猜某个状态是否漏了。4.3 通用状态机类与订单实例在machine.js中实现通用状态机类export class FiniteStateMachine { constructor({ initial, transitions, listeners {} }) { this.state initial; this.transitions transitions; this.listeners listeners; } send(event, payload) { const nextState this.transitions[this.state]?.[event]; if (!nextState) { throw new Error( 非法转移: 状态 ${this.state} 不允许事件 ${event} ); } const prevState this.state; this.state nextState; if (this.listeners.onTransition) { this.listeners.onTransition({ from: prevState, to: nextState, event, payload, }); } if (this.listeners.onEnter?.[nextState]) { this.listeners.onEnter[nextState].call(this, { from: prevState, event, payload, }); } return this.state; } can(event) { return Boolean(this.transitions[this.state]?.[event]); } }在main.js中创建订单状态机并运行import { FiniteStateMachine } from ./machine.js; import { orderTransitions } from ./order-machine.js; const orderMachine new FiniteStateMachine({ initial: pending, transitions: orderTransitions, listeners: { onTransition: ({ from, to, event }) { console.log([订单] ${event}: ${from} - ${to}); }, onEnter: { completed: () console.log(订单已完成进入终止态), cancelled: () console.log(订单已取消进入终止态), }, }, }); orderMachine.send(PAY); orderMachine.send(SHIP); orderMachine.send(COMPLETE); console.log(当前状态:, orderMachine.state);4.4 验证状态机行为浏览器控制台应输出[订单] PAY: pending - paid [订单] SHIP: paid - shipped [订单] COMPLETE: shipped - completed 订单已完成进入终止态 当前状态: completed接着验证非法转移try { orderMachine.send(CANCEL); // completed 状态下不允许取消 } catch (error) { console.error(error.message); // 非法转移: 状态 completed 不允许事件 CANCEL }这里的关键点是非法转移被显式抛出而不是被静默忽略。实际项目中如果你在 UI 上不打算暴露某个按钮业务代码仍然要做状态判断否则后端接口可能返回不一致的数据。5. 用 fetch 请求场景讲透状态机对复杂度的收敛订单状态机只是把所有状态列出来真正让状态机发挥威力的是异步流程。这里用最常见的fetch请求做例子。5.1 请求状态机的状态定义一个常规请求从发起到结束状态可以分为状态含义idle初始状态loading请求进行中success请求成功error请求失败事件包括FETCH、RESOLVE、REJECT、RETRY、RESET。用状态机约束请求流程idle 收到 FETCH进入 loading。loading 收到 RESOLVE进入 success。loading 收到 REJECT进入 error。error 收到 RETRY进入 loading。success 收到 RESET回到 idle。error 收到 RESET回到 idle。注意loading 状态下不能再次发起 FETCH。在没有状态机时用户连续点击提交按钮就可能出现重复请求有了状态机约束FETCH事件在 loading 状态下被拒绝重复点击就不会进入新的 loading。5.2 实现请求状态机import { FiniteStateMachine } from ./machine.js; const requestTransitions { idle: { FETCH: loading }, loading: { RESOLVE: success, REJECT: error, }, success: { RESET: idle }, error: { RETRY: loading, RESET: idle, }, }; export const requestMachine new FiniteStateMachine({ initial: idle, transitions: requestTransitions, listeners: { onEnter: { loading: () console.log(开始请求...), success: () console.log(请求成功), error: () console.log(请求失败), }, }, });在异步函数里使用import { requestMachine } from ./request-machine.js; async function fetchData(url) { requestMachine.send(FETCH); try { const response await fetch(url); if (!response.ok) { throw new Error(HTTP ${response.status}); } const data await response.json(); requestMachine.send(RESOLVE, data); return data; } catch (error) { requestMachine.send(REJECT, error); throw error; } }5.3 状态机如何防重复请求假设按钮的点击事件直接调用fetchData连续快速点击两次第一次点击时状态从 idle 变成 loading。第二次点击时状态仍处于 loadingFETCH在转移表里没有定义会抛出“非法转移”。这正是状态机收敛复杂度的核心表现你不需要在点击事件里写if (isLoading) return状态机自己就把不可达路径挡掉了。当然生产环境里需要把“非法转移”当成业务约束来处理而不是让用户看到一屏堆栈。可以在调用层判断if (requestMachine.can(FETCH)) { fetchData(/api/user); }或者直接捕获非法转移并忽略。推荐用can方法做显式判断代码语义更清晰。6. 状态机的常见坑与排查链路状态机的思想并不复杂但实际使用中会有一些隐蔽问题。6.1 常见坑一状态枚举和业务字符串混用错误写法是有些地方用loading有些地方用LOADING大小写不一致导致转移表永远匹配不上。建议方式把状态和事件定义成常量对象。export const OrderState { PENDING: pending, PAID: paid, SHIPPED: shipped, COMPLETED: completed, CANCELLED: cancelled, REFUNDED: refunded, }; export const OrderEvent { PAY: PAY, SHIP: SHIP, COMPLETE: COMPLETE, CANCEL: CANCEL, REFUND: REFUND, };这样在转移表里引用时写错的概率会小很多配合 TypeScript 还能获得编译期提示。6.2 常见坑二副作用散落在调用方而不是状态机内状态机的职责不只是“改一个变量”还应该包含“进入某个状态时该做什么”。如果所有副作用都写在派发事件之后的手动逻辑里状态机就退化成 switch 壳子。推荐做法把副作用注册到onEnter钩子里。例如订单进入 shipped 状态时通知物流系统进入 completed 状态时更新库存这些动作都应由状态机统一触发而不是在调用方手动拼。6.3 常见坑三异步事件导致状态漂移在异步流程中事件可能乱序到达。例如用户先发起了请求 A又发起了请求 BA 的响应最后才返回。如果没有状态机约束A 的响应可能在 B 已经完成后把状态覆盖回 success导致数据错乱。排查链路先确认当前状态打印machine.state。再确认到达的事件打印事件名。检查转移表里“当前状态 事件”是否有定义。如果没有定义说明出现了时序问题需要在异步调用中做版本序号或 AbortController 处理。6.4 状态机排查问题清单现象可能原因检查方式状态一直不变事件名拼写错误打印事件名和转移表非法转移频繁抛错异步时序乱序检查是否有多个并发请求UI 状态与实际状态不一致状态机状态没有同步到组件确认是否订阅了状态变化刷新后状态丢失状态没有持久化用 localStorage 或后端保存状态7. 状态机库怎么选手写还是 XState手写状态机适合规则少、团队对状态机不够熟悉的场景。当状态数量变多、需要可视化调试、需要支持并行状态和嵌套状态时手写实现会越来越吃力此时可以考虑成熟的状态机库。XState 是目前 JavaScript 生态中使用较多的状态机库。它支持有限状态机和状态图。并行状态。嵌套状态。守卫条件。延迟事件。可视化调试。与 React、Vue 的集成。安装方式npm install xstate用 XState 定义订单状态机import { createMachine } from xstate; const orderMachine createMachine({ id: order, initial: pending, states: { pending: { on: { PAY: paid, CANCEL: cancelled, }, }, paid: { on: { SHIP: shipped, REFUND: refunded, }, }, shipped: { on: { COMPLETE: completed, REFUND: refunded, }, }, completed: { type: final }, cancelled: { type: final }, refunded: { type: final }, }, });选择建议场景推荐方式规则少于 10 条团队新手多手写类封装有异步、嵌套、并行状态XState需要可视化调试XState只需要防重复请求手写三四个状态的轻量对象即可注意状态机库不是银弹。它解决的是“状态转移的确定性”不解决“业务逻辑是否合理”。如果业务需求本身混乱状态机只是把混乱显式呈现出来。8. 最佳实践与扩展方向状态机在 JavaScript 项目中真正落地的关键是把它当成设计工具而不是代码模式。首先设计阶段就要画状态转移表哪怕只是在纸上画。状态机最值钱的部分不是代码而是你提前想清楚了所有状态和事件。代码只是把这张表翻译成可执行逻辑。其次把状态机与 UI 解耦。不要在组件内部到处修改状态机的状态应该把状态机实例作为独立的业务模块组件只负责渲染和派发事件。在 React 中可以用useReducer实现类似效果也可以直接用 XState 的useMachine钩子。第三状态机一定要写单元测试。最基础的测试是“每个状态 每个事件”是否得到预期目标状态其次是测试非法转移是否被拒绝。因为状态机的输入输出完全可预测它是整个前端项目里最好测试的业务逻辑。最后扩展方向上可以关注持久化状态机。比如页面刷新时把状态机当前状态和上下文存储到 localStorage 或服务端刷新后恢复状态机用户就不会在刷新后看到一个已经完成的流程重新回到起点。回到开头那三个隐喻帽子提醒你状态决定了行为死亡提醒你终止态必须被尊重宇宙提醒你状态空间需要显式管理。状态机的意义不在于让代码变得炫酷而在于让复杂流程变得确定、可测试、可解释。这也是它在 JavaScript 项目里值得被认真使用的原因。
网站建设高端定制企业官网