一个看似“送分”的需求为何翻车?——前端状态机实战指南

源码太枯燥?一个人啃不动?关注公众号-「前端小卒」,让我做你的源码领读人。这里没有晦涩的说教,只有清晰的 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 界面上,"正在加载"的转圈动画和"网络错误"的红字提示竟然同时存在,甚至还能点击"重试"按钮!!!

按照我的逻辑,isLoadingtrue 时,isError 肯定得是 false 啊。我明明写了 setLoading(true); setError(false),为啥会出现这种问题!

为什么会失控?

问题的根源就在于试图用线性的逻辑,去对抗非线性的现实。

isLoadingisPollingisError 三个布尔值混在一起时,理论上它们能组合出 2^3=8 种状态。而在真实的业务逻辑中,合法的状态可能只有 3 种(加载中、轮询中、失败)。那么剩下的那 5 种"不可能存在的状态",一不小心没处理就会存在大量的缺陷。

我们花费 80% 的时间,不是在写业务,而是在写防御代码,去堵那些因为逻辑漏洞而产生的混沌状态。

面对那坨膨胀且脆弱的代码,再进行修修补补已经无济于事,必须要大刀阔斧式的推倒重来,和PM和运营沟通,需求进行延期,代码打回去重写。

解决此类问题的最佳方案,不是更高超的 if-else 技巧,而是一个经典的数学模型------有限状态机 。它的核心理念是:系统在任何时刻只能处于一种状态,且状态的流转是确定的。

引入有限状态机 (FSM)

绘制状态流转图

在写代码之前,我先画了一张图。这才是逻辑的核心:

流程图清晰的展示出来:

  • 只有在 FAILUREIDLE 状态下,才允许发起 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。以下场景如果不以状态机思维去写,迟早会崩:

  • 多媒体播放器 : 你以为只有 PlayPause?错。 还有 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 = 前端逻辑的绝对防御。

相关推荐
syt_10132 小时前
Object.defineProperty和Proxy实现拦截的区别
开发语言·前端·javascript
遝靑2 小时前
Flutter 跨端开发进阶:可复用自定义组件封装与多端适配实战(移动端 + Web + 桌面端)
前端·flutter
cypking2 小时前
Web前端移动端开发常见问题及解决方案(完整版)
前端
长安牧笛2 小时前
儿童屏幕时间管控学习引导系统,核心功能,绑定设备,设时长与时段,识别娱乐,APP超时锁屏,推荐益智内容,生成使用报告,学习达标解锁娱乐
javascript
老前端的功夫3 小时前
Vue 3 vs Vue 2 深度解析:从架构革新到开发体验全面升级
前端·vue.js·架构
xlp666hub3 小时前
C进阶之内存对齐,硬件总线和高并发伪共享的底层原理
面试·代码规范
栀秋6663 小时前
深入浅出链表操作:从Dummy节点到快慢指针的实战精要
前端·javascript·算法
狗哥哥3 小时前
Vue 3 动态菜单渲染优化实战:从白屏到“零延迟”体验
前端·vue.js
青青很轻_3 小时前
Vue自定义拖拽指令架构解析:从零到一实现元素自由拖拽
前端·javascript·vue.js