news 2026/9/9 3:08:54

状态机入门:从帽子死亡宇宙隐喻到JavaScript实现与实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
状态机入门:从帽子死亡宇宙隐喻到JavaScript实现与实战

前端项目里,状态机的概念经常被提起,但真正把它用起来的人并不多。很多人写业务代码时习惯用一堆布尔变量表达状态,比如isLoadingisSubmittingisError,等变量多到一定数量,就会出现“改了一个开关,另一个地方的表现完全不符合预期”的问题。这正是状态机要解决的:把所有可能的状态、事件以及状态之间的转移关系显式定义出来,避免系统进入没有定义过的、模棱两可的中间状态。标题里的“帽子、死亡、宇宙”三个词,恰好能对应状态机的三个核心概念:帽子对应状态,死亡对应终止态,宇宙对应状态空间。这篇文章会从这三个隐喻讲起,用 JavaScript 实现一个可运行的状态机,再讨论它到底适合解决什么问题。

1. 先理解状态机的三个核心概念:帽子、死亡与宇宙

1.1 帽子:状态决定了对象如何响应事件

在现实生活里,“戴帽子”是一种很容易理解的行为。

一个人的行为会因为“是否戴着帽子”而不同。在室内摘下帽子,和室外戴着帽子,面对同一阵风,反应完全不一样。如果把“人”看作一个系统,那么“戴帽子”或“没戴帽子”就是两种状态。状态机里的状态(state)就是这个意思:对象在某个状态中时,遇到同一个事件(event),产生的反应是确定的。状态机规定了一个对象在任意时刻只能处于有限个状态中的一个,这个约束是它管理复杂度的基础。

在 JavaScript 代码里,最常见的状态就是请求的loadingsuccesserror。状态不同,同一段数据更新代码执行后的 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 vite@latest 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.js

machine.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请求失败

事件包括FETCHRESOLVEREJECTRETRYRESET

用状态机约束请求流程:

  • 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。
  • 第二次点击时,状态仍处于 loading,FETCH在转移表里没有定义,会抛出“非法转移”。

这正是状态机收敛复杂度的核心表现:你不需要在点击事件里写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,又发起了请求 B,A 的响应最后才返回。如果没有状态机约束,A 的响应可能在 B 已经完成后把状态覆盖回 success,导致数据错乱。

排查链路:

  1. 先确认当前状态:打印machine.state
  2. 再确认到达的事件:打印事件名。
  3. 检查转移表里“当前状态 + 事件”是否有定义。
  4. 如果没有定义,说明出现了时序问题,需要在异步调用中做版本序号或 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 项目里值得被认真使用的原因。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/9 3:08:46

离线波形比较器:把肉眼波形验证变成可重复回归测试

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/9 3:08:12

JavaScript 10天入门学习路线:从基础语法到DOM操作实战

各位读者朋友,大家好。很多准备转行前端或者刚入门的同学,都会遇到一个很现实的问题:JavaScript 资料太多,今天看到一个教程讲变量,明天看到一个视频讲函数,学了大半个月,连一个完整的页面交互都…

作者头像 李华
网站建设 2026/9/9 3:08:10

零基础学JavaScript:从语法入门到前端实战的完整路线

很多零基础准备入行Web前端的读者,最初都会被一大堆学习资料劝退:今天搜到一套视频,明天刷到一篇“JS 必背 50 题”,后天又有人告诉你“别学了,直接用框架”。最终结果往往是收藏夹越来越厚,代码一行没写&a…

作者头像 李华
网站建设 2026/9/9 3:05:47

Webpack异步加载机制全解析:从动态import到JS Bundle的完整链路

做前端工程化这些年,Webpack 几乎是绕不开的一座大山。平时我们用 import() 写异步加载,浏览器 Network 面板里就会冒出一堆零散的 JS 文件——有人管它们叫异步 chunk,有人直接叫 JS Bundle。但真被问到“Webpack 到底是怎么在异步请求 JS…

作者头像 李华
网站建设 2026/9/9 3:03:24

格雷厄姆特价股票理论在可再生数字资产上的实践指南

去年底我在整理链上协议数据的时候,突然意识到一个现象:有些协议的收入连续两个季度都在增长,代币价格却跌到历史低位附近,成交额低得可怜。这种画面让我第一时间想起了格雷厄姆在《聪明的投资者》里反复讲的东西——特价股票&…

作者头像 李华
网站建设 2026/9/9 3:01:18

用Python Flask搭建轻量级网络诊断面板,定位校园网卡顿

学校网太卡了,这是很多住过宿舍、泡过实验室的人都踩过的坑。白天访问还正常,晚高峰一到延迟飙高、丢包上升、网页转圈,问题到底出在校园网出口带宽、DNS 解析、无线干扰还是本机网卡,不测一下根本说不清楚。这次我们来看一个可以…

作者头像 李华