状态机(State Machine)这个词听起来很学术,但它描述的问题非常接地气:一个对象在任意时刻只能处于有限个状态中的一个,状态的改变只能通过明确的事件触发,并且每次改变都遵循预先定义好的规则。比如一个文件上传组件,它可以是“待选择”“上传中”“暂停”“成功”“失败”中的某一个状态,绝不可能既是“上传中”又是“成功”。很多前端代码出问题的根源,就在于用一堆isLoading、isSuccess、isPaused布尔值去表达本该互斥的状态,组合爆炸之后逻辑根本理不清。这篇文章带你用现代JavaScript从零实现一个实用的状态机,并看看它和主流库之间的关系。

一、为什么布尔值管理状态注定会失控
先看一段典型的“布尔值地狱”代码。假设我们要做一个登录表单,有未提交、提交中、成功、失败四种状态,常见写法是这样的:
const state = {
isIdle: true,
isSubmitting: false,
isSuccess: false,
isError: false
};
function handleSubmit() {
if (state.isIdle && !state.isSubmitting) {
state.isSubmitting = true;
state.isIdle = false;
// 发请求...
} else if (state.isSuccess) {
// 已经成功了,还能再提交吗?
}
}这段代码的问题非常明显:四个布尔变量理论上有16种组合,但其中12种组合是非法的(比如isSuccess和isError同时为true)。没有任何机制阻止你写出非法组合,只能靠开发者在每个赋值点小心翼翼地维护所有标志位。一旦状态增加到七八个,组合数量会瞬间失控。
状态机的思路恰恰相反:把状态本身压缩成一个单一字符串,把“允许的变化路径”显式声明出来。判断状态时只需要state.current === 'submitting',切换状态时框架自动校验“当前状态加上这个事件,是否有一条合法的过渡路径”。非法操作在定义阶段就被拦截,而不是等到运行时出现诡异bug再回头排查。
二、用原生JavaScript手写一个轻量级状态机
实现一个够用的状态机只需要几十行代码。核心思路是三个部分:状态定义表、当前状态指针、事件分发方法。我们用ES6的类语法和Proxy来实现,顺便支持过渡前后的钩子函数。
class StateMachine {
constructor({ initial, states }) {
this.current = initial;
this.states = states; // 状态定义表
this.listeners = new Set(); // 订阅者,用于响应状态变化
}
// 派发事件,尝试从当前状态过渡到下一个状态
dispatch(event, payload) {
const stateDef = this.states[this.current];
if (!stateDef) {
throw new Error(`未定义的状态: ${this.current}`);
}
const transition = stateDef.on?.[event];
if (!transition) {
throw new Error(
`状态 "${this.current}" 不允许触发事件 "${event}"`
);
}
// 过渡前钩子,返回 false 可以取消这次过渡
if (stateDef.before && stateDef.before(event, payload) === false) {
return false;
}
const prev = this.current;
this.current = typeof transition === 'string'
? transition
: transition.target;
this.listeners.forEach(fn => fn(this.current, prev, event, payload));
return true;
}
// 判断当前是否可以处理某个事件
can(event) {
return Boolean(this.states[this.current]?.on?.[event]);
}
// 订阅状态变化
subscribe(fn) {
this.listeners.add(fn);
return () => this.listeners.delete(fn);
}
}用这个类来描述一个下载按钮的生命周期,代码会变得非常直观。每个状态声明它能响应哪些事件、响应后跳到哪里,非法流程直接在dispatch时抛错:
const machine = new StateMachine({
initial: 'idle',
states: {
idle: {
on: { CLICK: 'loading' }
},
loading: {
on: {
SUCCESS: 'success',
ERROR: 'error',
CANCEL: 'idle'
}
},
success: { on: { RESET: 'idle' } },
error: { on: { RETRY: 'loading', RESET: 'idle' } }
}
});
machine.subscribe((curr, prev) => {
console.log(`${prev} -> ${curr}`);
});
machine.dispatch('CLICK'); // idle -> loading
machine.dispatch('SUCCESS'); // loading -> success
machine.dispatch('CLICK'); // 抛错:success 状态不允许 CLICK这个实现的优点是零依赖、代码量小、行为完全可控,适合嵌入到任何框架里。它也天然契合React的useReducer模式——把dispatch包进reducer,组件就能根据machine.current渲染对应UI,状态迁移逻辑和视图渲染完全解耦。
三、复杂场景:交给XState还是继续手写?
当状态机开始出现嵌套状态、并行状态、延时自动过渡这些需求时,手写实现的复杂度会陡增。比如订单系统里“已支付”状态内部还有“待发货、已发货、已签收”子状态,或者需要“支付后30分钟未确认则自动取消”这种定时逻辑。这时候XState这样的成熟库就值得引入了。
import { setup } from 'xstate';
const orderMachine = setup({
actions: {
notifyUser: () => { /* 发送通知 */ }
}
}).createMachine({
id: 'order',
initial: 'created',
states: {
created: {
after: {
1800000: { target: 'cancelled' } // 30分钟未支付自动取消
},
on: { PAY: 'paid' }
},
paid: {
initial: 'pending',
states: {
pending: { on: { SHIP: 'shipped' } },
shipped: { on: { SIGN: 'signed' } },
signed: { type: 'final' }
}
},
cancelled: {
entry: 'notifyUser',
type: 'final'
}
}
});XState提供了可视化工具,可以把机器定义直接渲染成状态图,对排查复杂流程特别有用。它的after语法天然支持延时过渡,嵌套状态的写法也比自己维护父子关系清爽得多。
不过引入XState也有代价:包体积、学习曲线、团队熟悉度都需要评估。一个实用的判断标准是——如果状态数量不超过五个、没有嵌套和并行需求、流程基本是线性的,手写的几十行类完全够用;一旦出现层级结构或需要时间驱动的过渡,再考虑上库也不迟。千万不要为了炫技在两三态的简单场景里堆重型依赖,那是对状态机思想的误解。
四、落地时的几个常见坑
第一个坑是事件命名。状态机里事件应该描述“发生了什么”而不是“要去哪”,比如用SUCCESS而不是GO_TO_SUCCESS。因为同一个事件在不同状态下可能导向不同的目标状态,事件名绑定了语义,过渡表才灵活。
第二个坑是副作用的位置。不要在状态切换的dispatch调用里顺手发请求、弹提示,副作用应该放在过渡钩子(entry、exit)或订阅回调中。这样状态机本身保持纯粹的描述逻辑,测试时不需要mock任何网络请求,只验证过渡表是否正确即可。
第三个坑是忘记处理“不允许的事件”。手写实现里抛异常是最直接的方式,但在UI层面更好的做法是先调用can(event)判断,据此禁用按钮或忽略点击。让非法操作根本无法被用户触发,比触发后报错体验好得多。
总结一下,状态机的价值不在于那个类怎么写,而在于它强制你提前把所有状态和合法路径想清楚。这份“想清楚”的产物就是过渡表,它既是代码,也是文档,也是测试用例的来源。下次再看到组件里五六个布尔标志纠缠不清时,不妨停下来画一张状态图,往往问题在画图阶段就解决了一半。
JavaScript状态机状态机实现状态管理修改时间:2026-09-13 16:12:59