状态模式 📖
定义
状态模式(State Pattern) 是我们在处理“复杂业务流转”和“组件生命周期”时的一把利器。从本质上讲,状态模式就是面向对象版本的“有限状态机(FSM)”。
允许一个对象在其内部状态改变时改变它的行为。对象看起来似乎修改了它的类。
当你按下键盘的“空格键”时,播放器会发生什么?如果当前是 “播放状态” ,按下空格会暂停;如果当前是 “暂停状态” ,按下空格会继续播放。同一个动作(按空格),在不同的状态下,产生了完全不同的行为。
痛点:当一个对象有多种状态,且不同状态下的行为极其复杂时,代码中会充斥着形如 if (state === 'PLAYING') { ... } else if (state === 'PAUSED') { ... } 的巨型判断逻辑。且状态之间的切换极易产生 Bug。
解决:将每一种状态封装成一个独立的类/对象。把各种情况下的行为分散到这些状态对象中。上下文对象(Context)只需要把动作委托给当前的状态对象去执行。
对比:状态模式 vs 策略模式
由于两者的类图几乎一模一样(都是把逻辑委托给另一个对象),这是面试中的高频考点。核心区别在于 “谁来掌控变化” 和 “变化的驱动力” :
| 维度 | 策略模式 (Strategy) | 状态模式 (State) |
|---|---|---|
| 独立性 | 各个策略之间是平行且毫无关系的。 | 各个状态之间是有流转关系的(A状态能切到B状态)。 |
| 谁来切换 | 客户端(外界)主动切换。比如你主动在地图里选择“公交”还是“打车”。 | 上下文内部或状态对象自身来切换。比如播放完自动切到停止状态,外界并不干涉。 |
| 业务认知 | 客户端需要了解有哪些策略可用。 | 客户端通常只需要告诉环境“去执行动作”,不需要知道当前内部是什么状态。 |
实现
1. 传统 OOP 经典实现
上下文(播放器)把动作委托给当前状态。状态执行完动作后,主动去修改上下文的状态。
// 1. 状态基类 (规定有哪些动作)
class State {
constructor(player) {
this.player = player; // 互相持有引用,为了改变状态
}
pressPlay() { throw new Error('必须实现 pressPlay'); }
}
// 2. 具体状态类:播放中状态
class PlayingState extends State {
pressPlay() {
console.log('当前正在播放 -> 执行暂停');
this.player.setState(this.player.pausedState); // 状态内部驱动状态流转!
}
}
// 2. 具体状态类:暂停状态
class PausedState extends State {
pressPlay() {
console.log('当前已暂停 -> 恢复播放');
this.player.setState(this.player.playingState);
}
}
// 3. 上下文:音乐播放器
class AudioPlayer {
constructor() {
// 初始化所有可用状态
this.playingState = new PlayingState(this);
this.pausedState = new PausedState(this);
// 设置初始状态
this.currentState = this.pausedState;
}
setState(state) {
this.currentState = state;
}
// 暴露给外界的方法:把请求委托给当前状态!
pressPlayButton() {
this.currentState.pressPlay();
}
}
// ================= 使用 =================
const player = new AudioPlayer();
player.pressPlayButton(); // 输出: 当前已暂停 -> 恢复播放
player.pressPlayButton(); // 输出: 当前正在播放 -> 执行暂停
player.pressPlayButton(); // 输出: 当前已暂停 -> 恢复播放
// 客户端全程只需按按钮,根本没有 if-else!
2. 现代 JS 的状态机实现 (FSM)
在前端(JS环境),由于可以非常方便地使用字面量对象,我们很少写上面那么多类,而是通常将其实现为一个 有限状态机 (Finite State Machine, FSM) 的配置表。
// 更符合前端口味的“状态机配置表”
const fsmPlayer = {
state: 'paused', // 初始状态
// 状态转移字典表
transitions: {
paused: {
pressPlay: function() {
console.log('当前已暂停 -> 恢复播放');
this.state = 'playing'; // 切换状态
}
},
playing: {
pressPlay: function() {
console.log('当前正在播放 -> 执行暂停');
this.state = 'paused';
}
}
},
// 对外触发接口
dispatch(actionName) {
const action = this.transitions[this.state][actionName];
if (action) {
action.call(this); // 执行并绑定 this
} else {
console.log(`警告:在 ${this.state} 状态下不支持动作 ${actionName}`);
}
}
};
fsmPlayer.dispatch('pressPlay'); // 暂停 -> 播放
fsmPlayer.dispatch('pressPlay'); // 播放 -> 暂停
前端应用
1. 前端最重要的基石 —— Promise
Promise 就是一个最经典、最严格的状态模式应用!
Promise 内部有三种状态:Pending(进行中)、Fulfilled(已成功)、Rejected(已失败)。
- 它的状态机有着极其严格的单向流转规则:只能从 Pending → Fulfilled 或 Pending → Rejected。
- 一旦到达终态,调用
resolve()或reject()将不会产生任何行为。这就是典型的“不同状态下行为不同”。
2. XState 与复杂 UI 状态管理
在复杂的 React / Vue 开发中(比如电商结账流程、多步骤表单、拖拽交互),单纯依靠定义一堆 isLoading, isError, isSuccess 极其容易引发状态组合爆炸(比如同时出现 isLoading=true 且 isError=true 的幽灵 Bug)。
前端目前非常流行使用 XState 这样的专业状态机库。开发者通过画“状态图”,严格定义组件在“Idle”、“Loading”、“Success”、“Failure”等互斥状态之间的流转路径,彻底消灭了不合法的 UI 状态。
3. 游戏开发中的角色控制器
在用 Canvas 或 WebGL 做 HTML5 游戏时,角色往往有“站立(Idle)”、“走(Walk)”、“跳跃(Jump)”、“攻击(Attack)”等状态。
如果不用状态模式:
// 满屏的 flag 噩梦
if (isJumping && !isAttacking) {
// 如果在跳跃,就不能再次跳跃
} else if (isWalking && key === 'space') {
// 走的时候可以跳
}
引入状态模式后,将每个行为封装成 JumpState 等。在 JumpState 内,无论你怎么按“跳跃键”,状态机都会将其忽略,逻辑异常清晰。
4. 复杂的组件交互(例如下拉刷新组件)
拉到底部的“上拉加载更多”组件,其实是一个非常标准的状态机。它内部可能包含四个状态:
- normal(正常):监听滚动事件。
- pulling(拉拽中):文案显示“松开加载”。
- loading(加载中):展示 Loading 图标,在此状态下忽略一切外部拉拽和点击动作。
- no-more(到底了):展示“没有更多了”,彻底禁用加载行为。
总结
当你发现某个组件内部出现了两三个甚至更多的 Boolean 标志位(如 isA, isB),并且大量函数内部都需要通过 if (isA && !isB) 来决定是否执行逻辑时,这通常是系统在向你求救。
此时,请果断祭出状态模式(或有限状态机 FSM),用确定的状态枚举替换掉散乱的布尔值,将行为封装到各个状态配置中,你会发现原本错综复杂的代码瞬间变得井然有序。