阅读视图

发现新文章,点击刷新页面。

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

源码太枯燥?一个人啃不动?关注公众号-「前端小卒」,让我做你的源码领读人。这里没有晦涩的说教,只有清晰的 Vue 3 & React 19 源码拆解。积跬步以至千里,小卒也能进阶为大将,我们一起过河!♟️

故事的开始总是惊人的相似。

上个月,需求评审会上,收到了产品经理的一个需求:“做一个扫码支付弹窗。逻辑很简单:打开弹窗获取二维码,然后每隔 2 秒轮询一次接口,扫码成功就跳转,30 秒超时就让用户刷新”

一拍脑袋,凭经验判断,需求不难。作为一名熟练的“React工程师”,我极其自信地敲下代码,开始写需求代码。

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)

绘制状态流转图

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

svgviewer-png-output (52).png

流程图清晰的展示出来:

  • 只有在 FAILUREIDLE 状态下,才允许发起 FETCH
  • LOADING 状态下,无论用户怎么点击,都不会触发多余的请求。

引入useReducer

React 的 useReducer 是实现状态机的绝佳工具,通过它可以将状态管理收敛到一个纯函数中。

定义状态与事件

// 状态枚举,只有这五种状态
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 结构:先判断当前处于什么状态,再判断发生了什么事件

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 处理轮询副作用。

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,且彼此之间存在逻辑关联,就需要警惕,当你需要维护多个布尔值变量的同步关系时,状态机是更优解

例如:

// 典型的高风险代码
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 = 前端逻辑的绝对防御。

从被追问到被点赞:我靠“哨兵+快慢指针”展示了面试官真正想看的代码思维

面试高频链表题精讲:从哨兵节点到快慢指针

在算法面试中,链表是一类非常经典的数据结构题型。它不像数组那样支持随机访问,但正因如此,很多操作都需要我们对指针(引用)有精准的控制。本文将通过四道典型题目,带你系统掌握链表处理中的两大核心技巧:哨兵节点(Dummy Node)快慢指针(Fast & Slow Pointers)


一、删除链表中的指定节点:为什么需要哨兵节点?

题目描述

给定一个单链表的头节点 head 和一个值 val,删除链表中所有值等于 val 的节点,并返回新的头节点。
力扣:LCR 136. 删除链表的节点 - 力扣(LeetCode)

初步解法(不使用哨兵)

function remove(head, val) {
  // 特殊处理头节点
  if (head && head.val === val) {
    return head.next;
  }
  let cur = head;
  while (cur.next) {
    if (cur.next.val === val) {
      cur.next = cur.next.next;
      break; // 假设只删第一个匹配项
    }
    cur = cur.next;
  }
  return head;
}

面试官反问:

“你为什么要单独判断头节点?能不能统一处理?”

这是一个非常关键的问题!因为头节点没有前驱节点,所以当我们想“让前一个节点跳过当前节点”时,头节点就成了特例。

引入哨兵节点(Dummy Node)

哨兵节点是一个人为添加的假节点,通常放在链表头部,其作用是消除边界条件的特殊处理

function remove(head, val) {
  const dummy = new ListNode(0);
  dummy.next = head;
  let cur = dummy;
  while (cur.next) {
    if (cur.next.val === val) {
      cur.next = cur.next.next;
      break;
    }
    cur = cur.next;
  }
  return dummy.next; // 返回真正的头节点
}

优势

  • 不再需要单独判断头节点是否为待删节点;
  • 所有节点都变成了“中间节点”,逻辑统一;
  • 即使链表为空或全被删完,也能安全返回。

二、反转链表:哨兵 + 头插法

题目描述

反转一个单链表。
力扣:206. 反转链表 - 力扣(LeetCode)

解法思路

我们可以利用哨兵节点作为新链表的头,然后遍历原链表,每次把当前节点插入到哨兵之后——这就是头插法

function reverseList(head) {
  const dummy = new ListNode(0); // 哨兵,dummy.next 指向已反转部分的头
  let cur = head;
  while (cur) {
    const next = cur.next;       // 1. 保存下一个节点
    cur.next = dummy.next;       // 2. 当前节点指向已反转部分的头
    dummy.next = cur;            // 3. 更新反转部分的新头
    cur = next;                  // 移动到原链表下一节点
  }
  return dummy.next;
}

🔍 关键理解

  • dummy 始终不动,它的 next 指向当前已反转链表的头
  • 每轮操作把 cur 插到最前面,实现“头插”;
  • 三步操作顺序不能乱,否则会断链。

💡 虽然这道题也可以用递归或双指针(prev/cur)实现,但哨兵+头插法提供了一种清晰、不易出错的迭代思路。


三、判断链表是否有环:从哈希表到快慢指针

题目描述

给定一个链表,判断其中是否存在环。
力扣:141. 环形链表 - 力扣(LeetCode)

解法一:哈希表(空间换时间)

let hash = new Map();
let temp =head;
while(temp)
 {
   if(hash.has(temp))
    return true;
   else
    hash.set(temp,1);
  temp=temp.next;
 }
  • 时间复杂度:O(n)
  • 空间复杂度:O(n)

面试官追问:

“能不能不用额外空间?”

解法二:快慢指针(Floyd 判圈算法)

核心思想:如果链表有环,快指针(每次走两步)一定会在环内追上慢指针(每次走一步)。

function hasCycle(head) {
  let slow = head;
  let fast = head;
  while (fast && fast.next) {
    slow = slow.next;         // 慢指针走1步
    fast = fast.next.next;    // 快指针走2步
    if (slow === fast) {      // 比较引用地址(同一内存)
      return true;
    }
  }
  return false;
}

优势

  • 空间复杂度 O(1);
  • 时间复杂度 O(n);
  • 是链表环检测的标准解法。

🌟 快慢指针不仅用于判环,还可用于找环入口、求中点、删除倒数第 N 个节点等。


四、删除链表的倒数第 N 个节点:哨兵 + 快慢指针的完美结合

题目描述

给你一个链表,删除倒数第 n 个节点,并返回链表头。
力扣:19. 删除链表的倒数第 N 个结点 - 力扣(LeetCode)

解法思路

  1. 使用哨兵节点避免头节点被删的边界问题;
  2. 快指针先走 n 步
  3. 然后快慢指针同步移动,当快指针到达末尾时,慢指针正好在倒数第 n 个节点的前一个
  4. 执行删除操作。
const removeNthFromEnd = function(head, n) {
  const dummy = new ListNode(0);
  dummy.next = head;
  let fast = dummy;
  let slow = dummy;

  // 快指针先走 n 步
  for (let i = 0; i < n; i++) {
    fast = fast.next;
  }

  // 快慢指针一起走,直到 fast 到达最后一个节点
  while (fast.next) {
    fast = fast.next;
    slow = slow.next;
  }

  // 此时 slow 指向倒数第 n 个节点的前一个
  slow.next = slow.next.next;

  return dummy.next;
};

🎯 为什么需要哨兵?

  • 如果要删除的是头节点(比如链表长度为 5,n=5),那么 slow 需要停在“头节点之前”;
  • 没有哨兵的话,slow 无法指向 null 的前驱;
  • 哨兵确保 slow 始终有效,且 slow.next 可安全删除。

总结:链表面试两大法宝

技巧 适用场景 核心价值
哨兵节点(Dummy) 删除、插入、反转等涉及头节点的操作 消除边界条件,统一逻辑
快慢指针 判环、找中点、倒数第 N 个节点 O(1) 空间解决距离/位置问题

在实际面试中,先写出朴素解法(如哈希表) ,再根据面试官提示优化到快慢指针,能体现你的思维层次;而哨兵节点则是写出健壮、简洁代码的关键技巧。


希望这篇文章能帮你打通链表题的任督二脉!下次遇到链表题,先问自己两个问题:

  1. 要不要加哨兵? → 避免头节点特殊处理
  2. 能不能用快慢指针? → 解决“倒数”、“中点”、“环”等问题

祝你面试顺利,Offer 收割!🎉

❌