嘿,我是 Agnes。今天我们要聊一个听起来有点“学院派”,但实际上能让你代码质量起飞的话题——状态机(State Machine)在 TypeScript 项目里的实战应用。
你是不是也遇到过这种崩溃时刻:一个订单页面,上面堆满了 if (status === 'paid')、else if (status === 'shipped') 的逻辑?或者更糟,一个弹窗组件,状态切换乱成一锅粥,改了一个 bug 出来三个新的?
如果你点头了,那这篇文章就是写给你的。我们将抛开那些枯燥的教科书定义,直接上手,看看如何用 TypeScript 的强大类型系统,把复杂的业务流程变得像乐高大积木一样清晰、安全、且不可出错。
为什么“普通状态”会毁掉你的项目?
在深入状态机之前,先看看我们平时是怎么写状态的。
灾难现场:布尔值地狱
假设我们要开发一个“用户注册流程”,包含三个步骤:填写信息、验证邮箱、完成注册。
// 典型的反模式
let step = 1;
let isLoading = false;
let error = null;
let isEmailVerified = false;
function handleNext() {
isLoading = true;
if (step === 1) {
// 校验逻辑...
step = 2;
} else if (step === 2) {
// 发送验证码...
step = 3;
}
isLoading = false;
}
function handleVerify(email: string) {
if (step !== 2) return; // 只有第二步才能验证
isEmailVerified = true;
// 自动跳到下一步?还是显示成功?
// 这里逻辑开始混乱...
}
问题在哪?
- 无效状态是可能的:你可以处于
step = 3(已完成)但isEmailVerified = false的状态吗?从逻辑上讲不应该,但代码没阻止你。 - 状态转移不显式:谁改变了
step?什么时候变的?散布在各个函数里。 - TypeScript 帮不了你:
step只是一个number,编译器不会告诉你“你正试图从第3步跳到第1步”是非法的。
这就是过程识别要解决的问题:将“正在做什么”和“现在处于什么状态”分开。状态机让我们显式地描述所有可能的状态和合法的转换。
状态机的核心思想:三个基本构件
别怕,概念很简单。一个状态机由三样东西组成:
- State(状态):系统当前所处的静止点。比如
idle,loading,success,error。 - Event(事件):触发状态变化的动作。比如
CLICK,API_RESPONSE,ERROR. - Transition(转换):从哪个状态,因为哪个事件,变成了哪个状态。
用一张图来表示(我们称之为 状态转换图):
stateDiagram-v2
[*] --> Idle
Idle --> Loading : START_REQUEST
Loading --> Success : RECEIVE_DATA
Loading --> Error : ERROR_OCCURRED
Error --> Loading : RETRY
Success --> Idle : RESET
看,是不是清晰多了?没有 if/else 的迷宫,只有明确的路线。
TypeScript 实战:手撸一个小型状态机
光说不练假把式。我们来用 TypeScript 实现一个简单的 HTTP 请求状态机。
第一步:定义类型(Type Safety First)
TypeScript 最大的优势就是类型。我们要让非法状态在编译期就报错。
// 1. 定义所有可能的状态
type RequestState =
| 'idle' // 初始状态,未开始
| 'pending' // 请求中
| 'success' // 成功
| 'error'; // 失败
// 2. 定义所有可能的事件
type RequestEvent =
| { type: 'START' }
| { type: 'RESOLVE', payload: { data: any } }
| { type: 'REJECT', payload: { error: string } }
| { type: 'RESET' };
// 3. 定义转换表:当前状态 + 事件 -> 新状态
type TransitionMap = {
[K in RequestState]: {
[E in RequestEvent['type']]: RequestState;
};
};
// 这里我们手动“描述”合法的转换规则
// 注意:TypeScript 不会自动推断这个表,需要人工填写,但这正是关键!
const transitionTable: TransitionMap = {
idle: {
START: 'pending',
RESOLVE: 'idle', // 非法转换,但放在这里是为了类型完整
REJECT: 'idle', // 同上
RESET: 'idle',
},
pending: {
START: 'pending', // 重复提交,忽略
RESOLVE: 'success',
REJECT: 'error',
RESET: 'idle',
},
success: {
START: 'pending',
RESOLVE: 'success',
REJECT: 'error', // 成功后收到错误?可能是数据刷新了
RESET: 'idle',
},
error: {
START: 'pending', // 重试
RESOLVE: 'success',
REJECT: 'error', // 保持错误
RESET: 'idle',
},
};
等等,idle 状态下怎么能响应 RESOLVE 呢?
这就引出了状态机的一个高级技巧:Guard(守卫条件) 或 Context(上下文)。但在基础模型中,我们通常允许“无副作用”的转换,即映射到自身。或者,我们可以用更严格的类型系统来禁止未定义转换。
第二步:构建状态机类
class RequestStateMachine {
private currentState: RequestState = 'idle';
private context: { data?: any; error?: string } = {};
// 获取当前状态
getState(): RequestState {
return this.currentState;
}
// 获取上下文数据(比如响应数据)
getContext() {
return this.context;
}
// 核心:处理事件
send(event: RequestEvent) {
const nextStatus = transitionTable[this.currentState][event.type];
// 如果找不到转换,说明是非法操作
if (!nextStatus) {
throw new Error(`Illegal event '${event.type}' in state '${this.currentState}'`);
}
// 执行转换前的副作用(可选)
this.beforeTransition(this.currentState, event);
// 更新状态
this.currentState = nextStatus;
// 更新上下文(根据事件)
this.updateContext(event);
// 执行转换后的副作用(可选)
this.afterTransition(this.currentState, event);
console.log(`State changed: ${this.currentState} via ${event.type}`);
}
private updateContext(event: RequestEvent) {
switch (event.type) {
case 'RESOLVE':
this.context.data = event.payload.data;
this.context.error = undefined;
break;
case 'REJECT':
this.context.error = event.payload.error;
this.context.data = undefined;
break;
case 'RESET':
this.context = {};
break;
}
}
private beforeTransition(from: RequestState, event: RequestEvent) {
// 比如:开始请求时,清空旧数据
if (event.type === 'START') {
this.context = {};
}
}
private afterTransition(to: RequestState, event: RequestEvent) {
// 比如:进入 error 状态时,记录日志
if (to === 'error') {
console.warn('Request failed:', this.context.error);
}
}
}
第三步:使用状态机
const machine = new RequestStateMachine();
// 初始状态
console.log(machine.getState()); // 'idle'
// 合法流程
machine.send({ type: 'START' });
console.log(machine.getState()); // 'pending'
machine.send({ type: 'RESOLVE', payload: { data: 'Hello World' } });
console.log(machine.getState()); // 'success'
console.log(machine.getContext().data); // 'Hello World'
// 非法流程?编译器不会报错,但运行时会有防御
// machine.send({ type: 'RESET' }); // 从 success 重置,合法
// machine.send({ type: 'START' }); // 从 success 开始,合法(数据刷新)
// 如果我们想更严格,可以禁止某些转换
进阶:用 XState 这样的库来管理复杂业务
手写状态机很简单,但当你的业务复杂到 “订单状态 + 用户权限 + 支付渠道 + 物流跟踪” 多层嵌套时,手写会疯掉的。
这时候,业界标准库 XState 就派上用场了。它是 JavaScript/TypeScript 领域最流行的状态机库。
为什么推荐 XState?
- 可视化:它能生成你上面看到的 Mermaid 图,让你直观看到整个业务流程。
- 持久化:状态可以保存到 URL 或 LocalStorage,刷新页面不丢失。
- Actor 模型:复杂系统可以拆分成多个小状态机,互相通信。
- TypeScript 支持极好:类型推断非常强大。
XState 实战示例:一个简单的“待办事项”状态机
import { createMachine, interpret } from 'xstate';
// 定义类型
type TodoEvent =
| { type: 'ADD'; text: string }
| { type: 'COMPLETE'; id: string }
| { type: 'DELETE'; id: string };
type TodoContext = {
todos: Array<{ id: string; text: string; completed: boolean }>;
};
// 定义状态机
const todoMachine = createMachine({
id: 'todo',
initial: 'active',
context: { todos: [] },
states: {
active: {
// 可以在这个状态下监听事件
on: {
ADD: {
actions: assign({
todos: ({ context, event }) => [
...context.todos,
{ id: crypto.randomUUID(), text: event.text, completed: false }
]
})
},
COMPLETE: {
actions: assign({
todos: ({ context, event }) =>
context.todos.map(todo =>
todo.id === event.id ? { ...todo, completed: true } : todo
)
})
},
DELETE: {
actions: assign({
todos: ({ context, event }) =>
context.todos.filter(todo => todo.id !== event.id)
})
}
}
},
// 可以定义其他状态,比如 'filtering'
}
});
// 解释器:运行状态机
const service = interpret(todoMachine).start();
// 发送事件
service.send({ type: 'ADD', text: '学习 TypeScript 状态机' });
service.send({ type: 'ADD', text: '构建一个电商系统' });
// 查看状态
console.log(service.state.context.todos);
// Output: [
// { id: '...', text: '学习 TypeScript 状态机', completed: false },
// { id: '...', text: '构建一个电商系统', completed: false }
// ]
// 完成任务
service.send({ type: 'COMPLETE', id: service.state.context.todos[0].id });
关键点解释:
createMachine:定义状态机的蓝图。initial: 'active':初始状态。context:状态机的“记忆”,存储数据。on:定义在当前状态下能响应哪些事件。actions:状态切换时执行的副作用(如更新数据)。assign:XState 提供的用于更新 context 的工具函数,保证不可变性。
为什么状态机能提升可维护性?
1. 单一真相来源(Single Source of Truth)
以前,状态可能散落在变量、DOM 属性、Redux store 里。现在,所有状态都在一个地方定义。你想知道“当前订单能否取消?”——直接查状态机的状态。
2. 可预测性
给定相同的初始状态和事件序列,状态机永远产生相同的结果。这极大地简化了测试。
3. 可视化调试
使用 XState Visualizer,你可以实时看到状态机的当前状态和所有可能的转换。这在排查复杂 bug 时是神器。
4. 类型安全
XState 的类型推断非常出色。如果你试图从未定义的“错误状态”发送“完成”事件,TypeScript 会直接报错,而不是在运行时崩溃。
实战建议:什么时候该用状态机?
不是所有东西都需要状态机。滥用会导致过度设计。
适用场景:
- 有清晰状态边界的工作流:订单生命周期、审批流、游戏状态、表单向导。
- 状态转换复杂且容易出错:多步骤操作,依赖前序步骤成功。
- 需要可追溯性:需要记录“为什么到了当前状态”。
不适用场景:
- 简单的 UI toggle:比如一个“展开/折叠”按钮。用
useState就够了。 - 纯展示组件:没有业务逻辑的展示。
- 一次性脚本:不需要长期维护的代码。
判断标准:如果你的组件里有超过 3 个相互依赖的 boolean 状态,或者状态转换逻辑开始变得复杂,考虑引入状态机。
结语:从“过程式”到“声明式”的思维转变
学习状态机,不仅仅是在学习一个库,而是在学习一种思维方式。
以前,我们思考的是:“当用户点击按钮时,我要执行哪些步骤?”(过程式) 现在,我们思考的是:“系统当前处于什么状态?这个事件意味着什么?我应该转换到哪个状态?”(声明式)
这种转变会让你的代码更具韧性,更易于理解,也更少 Bug。
希望这篇指南能帮你迈出第一步。记住,复杂性的敌人不是更多的代码,而是更好的抽象。状态机,就是那个抽象利器。
如果你在实战中遇到问题,欢迎随时交流。毕竟,代码是写给人看的,顺便让机器执行。让我们把“人”这部分做好吧!
附录:快速检查清单
在决定使用状态机前,问自己:
- [ ] 我能列出系统的所有可能状态吗?
- [ ] 我能定义清楚每个状态之间如何转换吗?
- [ ] 这些转换是否依赖于外部事件(用户操作、API 响应)?
- [ ] 当前的状态逻辑是否让我感到困惑或容易出错?
如果 3 个以上答案是“是”,那么状态机可能就是你的答案。
