资讯动态

JavaScript状态机实战:从零构建可维护的订单流程

发布时间:2026/9/9 22:10:15 来源:尧图企业网站定制
当一个流程模块开始疯狂叠加 if-else、到处添加布尔开关、每次改动都要担心“另一个状态没同步更新”时大多数开发者都会意识到该用状态机收敛复杂度了。这个标题虽然带了一点哲学意味但我想讨论的是一个非常“工程”的问题如何用 JavaScript 写出清晰、可测试、可维护的状态流转逻辑。无论是页面切页、订单流转、支付流程还是游戏角色控制背后都在回答同一个问题——系统当前处于什么状态遇到某个事件后应该去向哪里。本文将用一个完整的 JavaScript 状态机实战从零开始搭建一个通用状态机再以订单流程为例跑通完整演示最后补充工程化最佳实践。新手能看懂建模思路有经验的开发者可以直接复用代码。1. 状态机是什么它解决什么问题1.1 先给一个通俗的比喻可以把状态机理解成一张“地铁线路图”。地铁列车只能在固定线路上运行当列车到达某个站点它只能去往下一站而不是任意改变方向。每一站就是一个“状态”列车收到“到达某站”的信号就是一次“事件”列车根据当前站点和信号决定去向就是“状态迁移”。业务代码中的状态机也是同样的逻辑一组有限且固定的状态、一组能改变状态的事件以及一张从“当前状态 事件”推导出“下一状态”的规则表。1.2 专业一点的描述有限状态机Finite State MachineFSM是指系统在任意时刻只能处于有限个状态中的一个。它接收外部事件输入根据当前状态和事件确定下一个状态同时可以执行对应的动作。一个标准的 FSM 可以用五元组描述元素含义示例状态集合所有合法状态待支付、已支付、已发货、已完成事件集合触发迁移的外部信号支付成功、点击发货、确认收货迁移规则状态与事件到下一状态的映射待支付 支付成功 - 已支付初始状态系统启动时所在状态待支付终止状态流程结束、不再迁移的状态已完成、已取消JavaScript 里很多看似复杂的异步流程本质上都是状态机的特例。比如 Promise 只有pending / fulfilled / rejected三种状态且状态一旦改变就不能再变这本身就是一种状态机设计。1.3 它到底解决了什么问题状态机最大的价值是把“流程判断”从散乱的 if-else 中抽离出来变成一张可以提前设计、单独测试、可视化维护的迁移表。在业务代码中我们经常看到这类问题用户重复点击“支付”按钮订单被重复扣款已取消的订单还能走“发货”流程异步请求返回乱序界面显示的内容和服务器状态不一致修改一个流程分支时不小心影响了另一个分支。这些问题本质上都是“状态没有被显式建模”。if-else 描述的是一种即时判断但流程是一个有时间跨度的过程中间会产生多次状态变化。如果每次状态变化都散落在事件回调里代码的可控性就会越来越差。状态机要求你先想清楚“合法状态有哪些、合法迁移有哪些”然后把规则集中起来。非法事件在状态机层面就会被拦截而不是一直穿过层层 if-else 最后才在业务深处暴露。1.4 典型应用场景JavaScript 生态里状态机的使用范围很广前端业务流程控制订单、审批、登录注册、表单分步提交UI 组件状态弹窗开关、菜单展开、上传组件的生命周期游戏角色控制站立、跑步、跳跃、下落、攻击协议解析解析 TCP、串口、WebSocket 数据帧时根据当前解析阶段处理不同字节音视频播放器加载中、播放中、暂停、结束、出错编辑器协同同步状态、本地编辑状态、冲突状态。2. 状态机的四要素与建模方法2.1 四个核心名词先统一术语后面代码里会反复用到。状态State表示系统在某一时刻的稳定情况。状态应该是“稳定”的比如等待支付时订单处于“待支付”不会因为时间流逝而自然变化。事件Event表示外部发生了什么。事件是触发迁移的原因比如“用户点击支付”“服务器返回支付成功”。事件通常用动词或过去分词命名例如PAY、SHIP、TIMEOUT。迁移Transition表示从一个状态变成另一个状态的过程。迁移由“当前状态 事件”共同决定。同样的事件在不同状态下可能产生不同的结果甚至可能是非法操作。动作Action迁移发生时执行的副作用比如发送请求、写入日志、更新界面、弹出提示。2.2 初始状态与终止状态初始状态就是流程开始时的状态。大多数状态机还会包含一个或多个终止状态例如“已完成”“已取消”。进入终止状态后状态机不再响应业务事件这能有效避免“死循环”和重复处理。2.3 合法迁移与非法迁移状态机的关键在设计迁移规则。不是所有状态都可以互相跳转也不是任何事件都能在任意状态下被接受。比如一个简单订单流程合法迁移待支付 - 已支付待支付 - 已取消已支付 - 已发货非法迁移待支付 - 已完成已取消 - 已支付已完成 - 已发货。非法迁移有两种处理方式直接抛出异常或者静默忽略。业务场景推荐抛异常并记录日志因为非法迁移往往意味着程序 Bug 或有人恶意绕过流程。2.4 状态机与 if-else 的本质区别用 if-else 判断流程时代码往往长这样function handleEvent(order, event) { if (order.status PENDING event PAY) { order.status PAID; } else if (order.status PAID event SHIP) { order.status SHIPPED; } else if (order.status SHIPPED event COMPLETE) { order.status COMPLETED; } else { throw new Error(非法操作); } }这个写法初期还能忍受但状态一旦增加到十几个事件种类再翻倍条件分支会爆炸式增长阅读和测试都变得困难。状态机的思路是把规则“数据化”const transitions { PENDING: { PAY: PAID, CANCEL: CANCELLED }, PAID: { SHIP: SHIPPED, REFUND: REFUNDED }, SHIPPED: { COMPLETE: COMPLETED, REFUND: REFUNDED } };判断逻辑从“一堆 if 嵌套”变成“查表”。新增状态和事件只需要在表里加行不需要改动核心判断代码。2.5 实战前先用状态表建模写代码之前建议先画一张状态表。表格有三列当前状态、事件、下一状态。如果存在分支条件再加一列“条件说明”。以订单流程为例当前状态事件下一状态条件说明待支付PAY已支付支付成功回调待支付CANCEL已取消用户主动取消已支付SHIP已发货商家发货已支付REFUND已退款用户申请退款已发货COMPLETE已完成用户确认收货已发货REFUND已退款售后退款表格整理清楚后代码几乎是机械翻译。这就是“状态表先行代码随后”。3. 环境准备与项目结构3.1 运行环境本文的 JavaScript 状态机实现不依赖第三方库核心代码可以直接在浏览器控制台或任何现代 JavaScript 环境下运行。示例包含一个完整 HTML 页面用于交互演示。推荐环境Node.js 18 及以上或现代浏览器Chrome、Edge、Firefox不需要安装 npm 包代码使用 ES6 语法如果你的项目需要兼容旧浏览器可以手动改写为 ES5 或使用 Babel 转换。版本要求不高重点在于实现思路。如果你的项目使用 TypeScript也可以在本文代码基础上补充类型定义。3.2 示例项目结构state-machine-demo/ ├── index.html # 交互演示页面 ├── machine.js # 通用状态机工厂 └── order-demo.js # 订单状态机业务示例如果只想在 Node 环境跑逻辑可以省略index.html直接运行order-demo.js。4. 用原生 JavaScript 实现一个通用状态机4.1 最简模型红绿灯状态机先从一个最小例子入手。红绿灯有三个稳定状态绿灯、黄灯、红灯。每次定时器触发TIMER事件状态依次切换。// 文件路径state-machine-demo/traffic-light.js const createTrafficLight () { let currentState GREEN; const states { GREEN: { on: { TIMER: YELLOW } }, YELLOW: { on: { TIMER: RED } }, RED: { on: { TIMER: GREEN } }, }; return { getState() { return currentState; }, dispatch(event) { const currentConfig states[currentState]; const nextState currentConfig.on?.[event]; if (!nextState) { throw new Error( 非法事件当前状态 ${currentState} 不接受事件 ${event} ); } const prevState currentState; currentState nextState; console.log( [状态迁移] ${prevState} --${event}-- ${currentState} ); return currentState; }, }; }; // 演示 const light createTrafficLight(); light.dispatch(TIMER); // GREEN --TIMER-- YELLOW light.dispatch(TIMER); // YELLOW --TIMER-- RED light.dispatch(TIMER); // RED --TIMER-- GREEN运行后控制台输出[状态迁移] GREEN --TIMER-- YELLOW [状态迁移] YELLOW --TIMER-- RED [状态迁移] RED --TIMER-- GREEN这里最核心的逻辑就是currentConfig.on?.[event]根据当前状态找到状态配置再从配置中查找事件对应的下一状态。查不到就说明是非法事件。4.2 通用状态机工厂 createMachine红绿灯例子虽然简单但已经展示出状态机的核心结构。接下来把它封装成一个通用工厂方便业务复用。createMachine接收一个配置对象包含initialState、states和可选的onChange回调。states中的每个状态都支持on对象on中的每个事件可以映射到目标状态字符串也可以映射为带target和action的对象。// 文件路径state-machine-demo/machine.js function createMachine(config) { const { initialState, states, onChange } config; let currentState initialState; function getState() { return currentState; } function can(event) { const currentConfig states[currentState]; return Boolean(currentConfig?.on?.[event]); } function dispatch(event) { const currentConfig states[currentState]; if (!currentConfig) { throw new Error(状态 ${currentState} 未在状态表中定义); } const transition currentConfig.on?.[event]; if (!transition) { throw new Error( 非法迁移状态 ${currentState} 无法处理事件 ${event} ); } const nextState typeof transition string ? transition : transition.target; if (!nextState || !states[nextState]) { throw new Error( 非法目标状态${nextState} 未定义 ); } const prevState currentState; currentState nextState; if (typeof onChange function) { onChange({ from: prevState, to: nextState, event, }); } // 如果迁移配置了 action执行对应动作 if (typeof transition object typeof transition.action function) { transition.action({ from: prevState, to: nextState, event, }); } return currentState; } return { getState, can, dispatch, }; } // 导出供浏览器和 Node 环境使用 if (typeof module ! undefined module.exports) { module.exports { createMachine }; }4.3 关键设计说明为什么使用on作为事件表on的语义很直观“在当前状态下收到某个事件后怎么办”。states.PAID.on.SHIP读起来就是“已支付状态下收到 SHIP 事件应该迁移”。迁移配置为什么允许string和object两种写法纯字符串写法适合简单场景代码更短PAY: PAID但当某个迁移需要执行副作用时就需要对象写法PAY: { target: PAID, action: () { /* 调用支付回调 */ } }两种写法同时支持既能保持简单性又有扩展空间。can方法有什么用在某些场景下我们需要在触发事件前先判断是否允许操作。比如“已完成”订单不应显示“取消”按钮可以通过can(CANCEL)来控制按钮的可见性避免直接点击后抛错造成不好的用户体验。4.4 运行与验证在 Node 环境中验证通用状态机node -e const { createMachine } require(./machine.js); const login createMachine({ initialState: IDLE, states: { IDLE: { on: { SUBMIT: LOADING } }, LOADING: { on: { SUCCESS: SUCCESS, ERROR: ERROR } }, SUCCESS: {}, ERROR: {}, }, onChange: (ctx) console.log(ctx), }); login.dispatch(SUBMIT); login.dispatch(SUCCESS); console.log(最终状态:, login.getState()); 预期输出{ from: IDLE, to: LOADING, event: SUBMIT } { from: LOADING, to: SUCCESS, event: SUCCESS } 最终状态: SUCCESS如果尝试在SUCCESS状态下再触发SUBMIT会抛出异常Error: 非法迁移状态 SUCCESS 无法处理事件 SUBMIT这个异常在流程控制中非常有用它可以把“状态错误”尽早暴露在开发阶段而不是让脏数据继续流向下游。5. 实战案例订单流程状态机5.1 需求与状态表接下来用订单流程做一个完整案例。这是一套简化版的电商订单状态状态PENDING待支付、PAID已支付、SHIPPED已发货、COMPLETED已完成、CANCELLED已取消、REFUNDED已退款事件PAY支付成功、SHIP发货、COMPLETE确认收货、CANCEL取消、REFUND退款。状态表如下当前状态事件下一状态说明PENDINGPAYPAID支付成功PENDINGCANCELCANCELLED未支付取消PAIDSHIPSHIPPED商家发货PAIDREFUNDREFUNDED支付后退款SHIPPEDCOMPLETECOMPLETED确认收货SHIPPEDREFUNDREFUNDED发货后退款COMPLETED--终态不再迁移CANCELLED--终态不再迁移REFUNDED--终态不再迁移5.2 核心业务代码// 文件路径state-machine-demo/order-demo.js const { createMachine } require(./machine.js); const orderMachine createMachine({ initialState: PENDING, states: { PENDING: { on: { PAY: { target: PAID, action: () console.log([动作] 发送支付成功通知), }, CANCEL: { target: CANCELLED, action: () console.log([动作] 释放库存), }, }, }, PAID: { on: { SHIP: { target: SHIPPED, action: () console.log([动作] 创建物流单), }, REFUND: { target: REFUNDED, action: () console.log([动作] 发起退款流程), }, }, }, SHIPPED: { on: { COMPLETE: { target: COMPLETED, action: () console.log([动作] 更新用户积分), }, REFUND: { target: REFUNDED, action: () console.log([动作] 生成售后单), }, }, }, COMPLETED: {}, CANCELLED: {}, REFUNDED: {}, }, onChange: (ctx) { console.log( [订单状态变化] ${ctx.from} - ${ctx.to}触发事件${ctx.event} ); }, }); // 模拟一次完整下单流程 orderMachine.dispatch(PAY); // PENDING - PAID orderMachine.dispatch(SHIP); // PAID - SHIPPED orderMachine.dispatch(COMPLETE); // SHIPPED - COMPLETED console.log(最终订单状态, orderMachine.getState());运行结果[订单状态变化] PENDING - PAID触发事件PAY [动作] 发送支付成功通知 [订单状态变化] PAID - SHIPPED触发事件SHIP [动作] 创建物流单 [订单状态变化] SHIPPED - COMPLETED触发事件COMPLETE [动作] 更新用户积分 最终订单状态 COMPLETED如果业务逻辑尝试非法迁移比如在PENDING状态下直接COMPLETE状态机会直接抛出异常避免脏数据产生。5.3 页面按钮交互演示为了让演示更直观可以加一个 HTML 页面用按钮触发事件用文字展示当前状态。!-- 文件路径state-machine-demo/index.html -- !DOCTYPE html html langzh-CN head meta charsetUTF-8 / meta nameviewport contentwidthdevice-width, initial-scale1.0 / titleJavaScript 订单状态机演示/title style body { font-family: Arial, sans-serif; max-width: 600px; margin: 40px auto; padding: 0 20px; line-height: 1.8; } #log { background: #f6f8fa; border-radius: 8px; padding: 16px; margin-top: 20px; min-height: 120px; white-space: pre-wrap; font-size: 14px; } button { margin-right: 8px; padding: 6px 16px; cursor: pointer; } /style /head body h2订单状态机交互演示/h2 p当前状态strong idstateTextPENDING/strong/p button>PAY: { target: PAID, action: async () { await request(/api/pay/callback); updateUI(); }, }但要注意状态机的核心“状态切换”是同步发生的。也就是说dispatch(PAY)调用后状态会立刻变成PAID而异步动作在后台执行。这是符合业务直觉的支付回调一旦到达订单状态应该立刻更新后续的通知、对账等动作可以异步完成。如果需要等待异步动作完成后再迁移可以把异步逻辑放在dispatch外面由业务层控制时机await request(/api/pay/callback); orderMachine.dispatch(PAY);具体用哪种方式取决于你希望状态迁移是“事件触发后立刻完成”还是“动作成功后才完成”。前者适合大多数事件驱动场景后者适合强一致性的业务流。6.2 状态持久化与恢复状态机非常容易做持久化因为它的全部运行信息只有一个currentState字段。你可以把状态序列化到localStorage、数据库或日志文件// 保存状态 localStorage.setItem(orderState, orderMachine.getState()); // 恢复状态 const savedState localStorage.getItem(orderState); const restoredMachine createMachine({ initialState: savedState || PENDING, states: orderStates, });这也是状态机比散落的 if-else 更适合长流程的原因状态是显式的、单一的、可序列化的。服务器重启后从数据库恢复订单状态再继续接收事件流程就能接着跑。6.3 为什么状态机非常适合单元测试状态机本质是一张输入输出表非常适合用表格驱动的测试方式。通过枚举“当前状态 事件”的组合可以做到 100% 覆盖合法迁移和非法迁移。以订单状态机为例// order-machine.test.js const assert require(assert); const { createMachine } require(./machine.js); const orderStates { PENDING: { on: { PAY: PAID, CANCEL: CANCELLED } }, PAID: { on: { SHIP: SHIPPED, REFUND: REFUNDED } }, SHIPPED: { on: { COMPLETE: COMPLETED, REFUND: REFUNDED } }, COMPLETED: {}, CANCELLED: {}, REFUNDED: {}, }; function createOrderMachine(initialState) { return createMachine({ initialState, states: orderStates, }); } // 合法迁移测试 const validCases [ [PENDING, PAY, PAID], [PENDING, CANCEL, CANCELLED], [PAID, SHIP, SHIPPED], [PAID, REFUND, REFUNDED], [SHIPPED, COMPLETE, COMPLETED], ]; validCases.forEach(([from, event, to]) { const machine createOrderMachine(from); assert.strictEqual(machine.dispatch(event), to); assert.strictEqual(machine.getState(), to); console.log(通过: ${from} --${event}-- ${to}); }); // 非法迁移测试 const invalidCases [ [PENDING, COMPLETE], [COMPLETED, SHIP], [CANCELLED, PAY], [REFUNDED, SHIP], ]; invalidCases.forEach(([from, event]) { const machine createOrderMachine(from); assert.throws(() machine.dispatch(event)); console.log(通过: ${from} --${event}-- 抛出异常); });这种测试的优点是迁移规则写死在那张表里测试用例自然也可以表驱动新增一个状态只需要补充几行测试数据不需要复制粘贴大量测试函数。对流程复杂的项目来说状态机带来的测试收益非常明显。7. 常见问题与排查思路7.1 高频问题排查表问题现象常见原因解决思路状态没有按预期变化事件名拼写不一致检查事件常量是否统一建议用常量对象而非裸字符串非法事件没有报错使用了can做了静默拦截明确业务预期拦截展示类操作可以核心状态流转必须抛异常异步动作里报错导致状态混乱状态已切换但动作失败调整语义先执行动作成功后再dispatch或引入补偿机制页面刷新后状态丢失没有做状态持久化在状态变化时持久化currentState启动时恢复重复点击按钮引发重复迁移没有在 UI 层做操作前校验用can(event)控制按钮可用性或加幂等约束状态表越来越难维护缺少前置建模先整理状态表再写代码迁移表本身就是文档多个事件并发触发造成状态错乱没有考虑事件排队连续事件用队列或 Promise 链串行化新增状态时遗漏某个迁移状态表不完整补全状态矩阵所有状态 x 事件组合都覆盖7.2 排查建议遇到状态机相关 Bug建议按下面的顺序排查先打印当前状态确认起点是否符合预期确认触发的事件名是否准确是否传入了额外的空格或大小写差异查看状态配置表中的on映射是否存在对应事件检查是否有异步逻辑在dispatch后被覆盖或回写检查是否有多实例问题比如每次页面渲染都创建了新的状态机实例导致原状态丢失。绝大多数状态机问题都不是状态机机制本身的问题而是“事件来源不一致”或“状态保存位置不正确”。8. 最佳实践与工程建议8.1 状态表先行代码后写我见过很多同学直接上手写状态机写到一半发现某个状态漏了事件。更推荐的做法是先用表格或文档把状态矩阵列完整评审通过后再代码实现。状态表至少要包含全部状态及其含义全部事件及其触发场景每个状态接收每个事件后的下一状态每个迁移是否附带动作、动作是什么终止状态列表。一旦状态表定稿实现只是体力活。8.2 命名规范要统一状态建议全部大写使用名词或过去分词例如PENDING、PAID、SHIPPED事件建议全部大写使用动词或动词短语例如PAY、CANCEL业务代码里统一引用常量避免魔法字符串。这样做的好处是日志、接口、存储层都使用同一套命名排查问题时可以对得上。8.3 副作用与状态迁移分离状态机只负责“根据状态和事件决定下一状态”动作中的业务逻辑应该尽量薄。不要把大量业务处理塞进action里否则状态机会退化成又一层 if-else。推荐做法是让action只做“通知”和“调度”真正的数据请求、校验放在业务方法中SHIP: { target: SHIPPED, action: ({ event }) { // 只抛事件具体逻辑由外部监听器处理 emitter.emit(order.shipped, event); }, }这样状态机的行为更容易预测和测试。8.4 记录每一次迁移状态机是天然的日志埋点位置。每次迁移发生时记录{ from, to, event, time, traceId }出问题时一张时间线就能还原整个流程。onChange: (ctx) { console.info(状态迁移日志, { ...ctx, time: Date.now(), traceId: getTraceId(), }); }这份日志对排查分布式链路问题非常有价值。8.5 不要滥用状态机状态机适合“状态数量有限、迁移规则明确、事件驱动”的场景。如果是纯计算逻辑、一次性流程判断、或状态无限延续的场景强行套状态机反而增加复杂度。一个判断标准如果你能在一张 A4 纸上画出完整的状态迁移图并且图上的分支不超过三四十个状态机是合适的。如果画出来像蜘蛛网建议先组件化拆分再对每个子模块单独设计状态机。8.6 生产环境的风险意识涉及订单、支付、退款等真实业务时状态机只是“流程控制骨架”真正上线前还要考虑数据库中的订单状态与状态机状态保持一致迁移动作需要事务保护非法迁移被拦截后要告警而不是静默忽略状态机版本升级时要考虑旧数据的兼容迁移测试环境充分覆盖后再进入生产尤其是退款、取消这类不可逆操作。9. 总结与延伸方向这篇文章从一个很经典的工程痛点切入流程逻辑为什么容易越写越乱。答案是状态没有被显式建模。通过引入有限状态机我们把判断逻辑从散落的 if-else 变成一张可验证的迁移表然后用一个通用createMachine工厂轻松实现。写代码前先画状态表把状态、事件、迁移、动作四要素定义清楚实现时让状态机只负责流程控制业务副作用尽可能轻上线后依靠迁移日志和单元测试保障流程稳定性。如果你对状态机有兴趣未来的学习路线可以往这几个方向延伸XStateJavaScript 生态中最完整的有限状态机与状态图库支持嵌套状态、并行状态、副作用编排嵌入式状态机很多嵌入式软件架构都用状态机收敛复杂度经典的有 QP 状态机框架和本文思路一致只是语言换成了 C游戏状态机Godot 引擎内建状态机节点Unity 也有大量状态机插件做法同样是状态、事件、迁移状态图Statechart在普通 FSM 基础上加入嵌套、并发、历史状态等能力适合更复杂的业务流程。从一个简单的红绿灯例子到通用状态机再到订单流程实战其实核心思想一直没变复杂系统只是简单的状态迁移拼起来的关键是先找到那个“观察状态”的切口。如果你手头也有一个越改越乱的流程模块不妨先停下写代码试着把状态表整理出来你会发现复杂度比想象中好处理得多。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价