源码太枯燥?一个人啃不动?关注公众号-「前端小卒」,让我做你的源码领读人。这里没有晦涩的说教,只有清晰的 Vue 3 & React 19 源码拆解。积跬步以至千里,小卒也能进阶为大将,我们一起过河!♟️
故事的开始总是惊人的相似。
上个月,需求评审会上,收到了产品经理的一个需求:"做一个扫码支付弹窗。逻辑很简单:打开弹窗获取二维码,然后每隔 2 秒轮询一次接口,扫码成功就跳转,30 秒超时就让用户刷新"
一拍脑袋,凭经验判断,需求不难。作为一名熟练的"React工程师",我极其自信地敲下代码,开始写需求代码。
javascript
const [isLoading, setIsLoading] = useState(false); // 加载二维码中
const [isPolling, setIsPolling] = useState(false); // 轮询接口中
const [isError, setIsError] = useState(false); // 出错了
const [retryCount, setRetryCount] = useState(0); // 重试次数
// ...后面还有一堆 useEffect 和 useRef 用来存定时器ID
当时的我还未意识到,这几个state给我带来了好几个bug单。
隐形的"状态爆炸"
正式提测之后,我就陆陆续续收到了好几个bug单。
"大佬,我就在网络卡顿的时候多点了几下'刷新',页面就乱了"
浏览器的Network 面板上,几十个轮询请求在并发执行。UI 界面上,"正在加载"的转圈动画和"网络错误"的红字提示竟然同时存在,甚至还能点击"重试"按钮!!!
按照我的逻辑,isLoading 为 true 时,isError 肯定得是 false 啊。我明明写了 setLoading(true); setError(false),为啥会出现这种问题!
为什么会失控?
问题的根源就在于试图用线性的逻辑,去对抗非线性的现实。
当 isLoading、isPolling、isError 三个布尔值混在一起时,理论上它们能组合出 2^3=8 种状态。而在真实的业务逻辑中,合法的状态可能只有 3 种(加载中、轮询中、失败)。那么剩下的那 5 种"不可能存在的状态",一不小心没处理就会存在大量的缺陷。
我们花费 80% 的时间,不是在写业务,而是在写防御代码,去堵那些因为逻辑漏洞而产生的混沌状态。
面对那坨膨胀且脆弱的代码,再进行修修补补已经无济于事,必须要大刀阔斧式的推倒重来,和PM和运营沟通,需求进行延期,代码打回去重写。
解决此类问题的最佳方案,不是更高超的 if-else 技巧,而是一个经典的数学模型------有限状态机 。它的核心理念是:系统在任何时刻只能处于一种状态,且状态的流转是确定的。
引入有限状态机 (FSM)
绘制状态流转图
在写代码之前,我先画了一张图。这才是逻辑的核心:

流程图清晰的展示出来:
- 只有在
FAILURE或IDLE状态下,才允许发起FETCH。 - 在
LOADING状态下,无论用户怎么点击,都不会触发多余的请求。
引入useReducer
React 的 useReducer 是实现状态机的绝佳工具,通过它可以将状态管理收敛到一个纯函数中。
定义状态与事件
ts
// 状态枚举,只有这五种状态
const STATES = {
IDLE: 'idle',
LOADING: 'loading',
POLLING: 'polling',
SUCCESS: 'success',
FAILURE: 'failure',
};
// 事件枚举:用户或系统能做的动作
const EVENTS = {
FETCH: 'FETCH', // 触发获取
RESOLVE: 'RESOLVE', // 成功拿到二维码
REJECT: 'REJECT', // 失败
DONE: 'DONE', // 支付完成
TIMEOUT: 'TIMEOUT', // 超时
};
编写 Reducer
这里使用双重 Switch 结构:先判断当前处于什么状态 ,再判断发生了什么事件。
go
const machineReducer = (state, event) => {
switch (state.status) {
case STATES.IDLE:
case STATES.FAILURE:
// 只有在空闲或失败时,才允许发起 FETCH
if (event.type === EVENTS.FETCH) {
return { ...state, status: STATES.LOADING, error: null };
}
return state; // 其他事件直接忽略!
case STATES.LOADING:
if (event.type === EVENTS.RESOLVE) {
return { ...state, status: STATES.POLLING, qrCode: event.data };
}
if (event.type === EVENTS.REJECT) {
return { ...state, status: STATES.FAILURE, error: event.error };
}
// 🔥 重点:这里没有处理 'FETCH' 事件。
// 这意味着:在 Loading 状态下,无论用户怎么狂点按钮,代码都不会有任何反应!
return state;
case STATES.POLLING:
if (event.type === EVENTS.DONE) return { ...state, status: STATES.SUCCESS };
if (event.type === EVENTS.TIMEOUT) return { ...state, status: STATES.FAILURE, error: '超时' };
return state;
default:
return state;
}
};
组件层调用(分离副作用)
现在,组件层的代码变得异常清爽。我们只需要根据状态去渲染 UI,并利用 useEffect 处理轮询副作用。
ini
const PaymentModal = () => {
const [state, dispatch] = useReducer(machineReducer, { status: STATES.IDLE });
// 业务动作:只负责派发意图,不负责判断逻辑
const handleFetch = () => {
dispatch({ type: EVENTS.FETCH });
api.getQrCode()
.then(data => dispatch({ type: EVENTS.RESOLVE, data }))
.catch(err => dispatch({ type: EVENTS.REJECT, error: err }));
};
// 副作用管理:由状态驱动轮询
useEffect(() => {
let timer = null;
if (state.status === STATES.POLLING) {
timer = setInterval(async () => {
const res = await api.checkStatus();
if (res.success) dispatch({ type: EVENTS.DONE });
}, 2000);
}
return () => clearInterval(timer);
}, [state.status]); // 状态变了,副作用自动清理或重启
return (
<div>
{state.status === STATES.LOADING && <Spinner />}
{state.status === STATES.POLLING && <QRCode img={state.qrCode} />}
{state.status === STATES.FAILURE && <ErrorView onRetry={handleFetch} />}
{state.status === STATES.SUCCESS && <SuccessView />}
</div>
);
};
看懂了吗?我们不再修补漏洞,通过清晰的定义状态和触发事件,收敛业务代码复杂度,消除可能产生的bug。
深度解析状态机
写到这里,可能很多同学会问: "这不就是 Switch-Case 吗?为什么要叫它状态机?" 我们需要把视角拉高,从理论层面彻底理解它,以便应对更复杂的场景。
什么是有限状态机
有限状态机不是一种代码写法,而是一个数学模型。它由五个要素组成:
- 有限的状态 :比如红绿灯只有红、黄、绿,不可能出现"红绿"。
- 有限的事件 :比如"倒计时结束"、"按下按钮"。
- 转换规则 :即
状态 A + 事件 B -> 状态 C。 - 初始状态 。
- 最终状态 。
它的核心理念就是:系统在任何时刻只能处于一种状态,且状态的流转是确定的。
为什么前端需要它
前端早已不是"展示页面"那么简单了,我们是在浏览器里跑 APP。以下场景如果不以状态机思维去写,迟早会崩:
- 多媒体播放器 : 你以为只有
Play和Pause?错。 还有Buffering(缓冲中)、Seeking(拖拽进度条中)、Ended(播放结束)、Error(加载失败)这些场景。 - 复杂的表单向导 : 第一步 -> 第二步 -> 第三步。 用户在第二步点了"上一步",数据怎么存?在第三步提交失败了,退回哪一步?
- TCP/WebSocket 连接管理 :
Connecting->Connected->Reconnecting->Disconnected。 如果在Reconnecting期间用户手动点了"断开",应该去哪? - Canvas 游戏/交互可视化: 拖拽模式、绘图模式、选中模式。
读到这里,你可能已经热血沸腾,准备把项目里的所有组件都重写一遍。
且慢。
软件工程的核心在于权衡,如果需求仅仅是控制一个模态框的显示与隐藏,引入状态机只会增加无谓的代码复杂度。
那么,在实际业务开发中,我们应该依据哪些标准来判定是否需要引入状态机?当你的组件出现以下三种特征之一时,就可以结合业务适当的考虑重构了。
存在多个需要"手动同步"的boolean变量
这是最显著的信号。检查一下你的组件 State 定义,如果出现了两个及以上的boolean,且彼此之间存在逻辑关联,就需要警惕,当你需要维护多个布尔值变量的同步关系时,状态机是更优解。
例如:
scss
// 典型的高风险代码
const [isLoading, setLoading] = useState(false);
const [isError, setError] = useState(false);
const [hasData, setHasData] = useState(false);
在这种结构下,开发者需要时刻警惕变量间的同步问题。比如在 setLoading(true) 时,必须记得同时重置 setError(false)。这种依赖"人为细心"来维护的代码,随着迭代周期的拉长,几乎必然会产生 Bug。
业务逻辑要求状态必须"互斥"
在很多场景下,业务状态在逻辑上是完全排他的。
比如开头的"扫码轮询"场景:一个请求不可能既在"进行中"又"已失败"。然而,如果使用分散的布尔值控制,代码层面是允许 { isLoading: true, isError: true } 这种非法组合存在的。
这种逻辑上的"排他性"如果不能在代码结构上得到强制保证,就会导致 UI 渲染出错(例如 Loading 动画和错误提示重叠显示)。所以如果UI展示依赖于严格互斥的状态(如 Promise 的 Pending/Resolved/Rejected),请尽可能的使用状态机强制约束。
状态流转路径有严格限制
简单的业务状态流转是自由的,而复杂的业务往往是有向图。
以"退款流程"为例:
- ✅ 合法路径:
申请中->审核通过->打款中 - ❌ 非法路径:
申请中->打款中(跳过审核)
如果使用传统的 if-else 来防御非法跳转,代码中会充斥着大量 if (status === 'AUDIT_PASS') 这样的防御性判断。而状态机可以通过配置 transitions,天然地禁止了所有未定义的跳转路径。
所以当业务逻辑不仅仅关乎"当前是什么状态",更关乎"当前状态允许变更为哪些状态"时,可以考虑引入状态机。
技术选型对照表
在开始写代码前,可以参考下表进行决策:
| 业务场景特征 | 推荐方案 |
|---|---|
| 简单的开/关状态 (Toggle) | useState(boolean) |
| 简单的互斥状态 (Loading/Success/Error) | 字符串枚举 (String Enum) |
| 状态复杂,且存在特定的流转规则 | 状态机 (Reducer / XState) |
| 涉及定时器、重试、取消等异步竞态问题 | 状态机 (推荐) |
记住,工具是为了解决复杂度而生的。简单留给 useState,复杂留给状态机。
限制即自由
写烂代码的本质,往往是我们试图在混乱中保留过多的"自由度"。而优秀架构的本质,通常在于限制 。正如那句话说的:「喜欢就会放肆,但爱就是克制」
状态机严格规定了什么状态下能做什么事,这种克制提高了我们的前端稳定性。
最后送给所有的前端的一句话:
所有的 Bug,本质上都是 "非法的状态"或"错误的转换"。
TypeScript 帮我们在编译时静态地规避了非法类型,而状态机则帮我们在运行时动态地管理状态流转。
TypeScript + State Machine = 前端逻辑的绝对防御。