React useState 状态更新:源码解析 dispatchAction 如何将更新对象存入 pending 队列

React 内部机制深潜:当 dispatchAction 遇上 pending 队列

各位同学,大家好!

今天我们不聊业务逻辑,不聊组件设计,咱们来聊点“硬菜”。咱们要扒开 React 的外衣,看看那个最熟悉的 useState 到底是怎么工作的。

你们每天都在用 const [count, setCount] = useState(0);。简单吧?简单得让人想睡觉。但如果你以为它就是一行代码把数字存进去,那你就太小看 React 团队了。这背后,有一套精密的调度系统,有一套优雅的数据结构,甚至还有一套“拖延症”治疗机制。

今天,咱们的主角是 dispatchAction。它是 useState 的幕后推手,是状态更新的发起者。而我们要探究的核心奥秘在于:dispatchAction 被召唤时,它是如何把更新对象塞进那个神秘的 pending 队列里的?

别眨眼,咱们开始这趟源码之旅。


第一幕:主角登场——dispatchAction 是谁?

想象一下,你的组件就像一个巨大的仓库(Fiber 节点)。仓库里有一个货架,专门放状态。这个货架就是 memoizedState

当你调用 setCount(1) 的时候,React 并没有直接去改货架上的数字。为什么?因为改货架是同步的,是阻塞的。如果用户疯狂点击按钮,瞬间触发 100 次状态更新,那你的页面不就卡成 PPT 了吗?

所以,React 需要一个“搬运工”。这个搬运工就是 dispatchAction

它长什么样?别去翻那个几万行的 React 源码,咱们把它抽象一下。dispatchAction 本质上是一个闭包函数,它捕获了当前的 Fiber 节点(组件实例)和对应的更新队列。

它的工作流程大概是这样的:

  1. 接收命令: 收到 setState 传进来的参数(比如 1)。
  2. 打包货物: 把这个 1 封装成一个对象,我们叫它 update 对象。
  3. 排队入栈: 把这个 update 对象塞进组件的 pending 队列里。
  4. 呼叫调度员: 告诉调度中心“我有活干了,有空来取”。

听着很简单,对吧?但第 3 步——“排队入栈”,才是今天的重头戏。


第二幕:神秘的 pending 队列——环形链表的艺术

在 React 源码中,每个 Fiber 节点都有一个 updateQueue 属性。这个 updateQueue 里面藏着什么?它是一个队列,用来存储待处理的更新。

很多初学者会想:“既然是队列,那用数组 push 一下不就完了吗?”

错!大错特错!

如果用数组,每次渲染时,你都得把数组清空,然后从头遍历。这叫“重置状态”。但这不符合 React 的设计哲学。React 想要的是状态合并

比如你在同一个渲染周期内调用了两次 setCount(1)setCount(2)。最终结果应该是 count = 3。如果你用数组 push,渲染时 1 进来,2 进来,渲染时 1 先被处理,然后 21 覆盖了。这很乱。

为了解决这个问题,React 使用了环形链表

1. 数据结构揭秘

在源码中,updateQueue 的结构大致如下:

class UpdateQueue {
  // pending 是一个链表
  // 它指向链表中的第一个元素
  pending: Update | null = null;

  // last 指向链表中的最后一个元素
  last: Update | null = null;

  // dispatchAction 会往这个队列里扔东西
  dispatch(action) {
    // ...省略调度逻辑
  }
}

2. dispatchAction 的核心代码逻辑

dispatchAction 被调用时,它做的事情可以简化为下面这段代码(为了理解,我做了大量简化):

function dispatchAction(fiber, queue, action) {
  // 1. 创建一个 Update 对象
  // 这个对象里包含了我们要更新的值
  const update = {
    action: action, // 比如 1 或 2
    next: null,     // 下一个节点的指针,初始为空
    tag: 0          // 标记,区分是函数更新还是对象更新
  };

  // 2. 关键步骤:将 Update 放入 pending 队列
  // 这里是一个环形链表的插入操作
  if (queue.pending === null) {
    // 如果队列为空,这就是第一个元素
    // 它既是第一个,也是最后一个
    update.next = update;
    queue.pending = update;
    queue.last = update;
  } else {
    // 如果队列不空,我们把它挂在最后一个元素的后面
    // 也就是把新的 Update 接在链表尾巴上
    const last = queue.last;
    last.next = update; // 原来的尾巴指向新的 Update

    // 新的 Update 变成新的尾巴
    update.next = queue.pending; // 新的 Update 指向原来的头,形成闭环

    // 更新 last 指针
    queue.last = update;
  }

  // 3. 触发调度
  scheduleUpdateOnFiber(fiber);
}

3. 代码演示:入队过程

咱们来通过代码演示一下这个过程。假设我们有一个组件,初始状态是 0

第一次调用 setCount(1)

  1. queue.pendingnull
  2. 创建 update1
  3. update1.next = update1(自己指自己)。
  4. queue.pending = update1
  5. queue.last = update1

此时队列结构:
[update1] (循环指向自己)

第二次调用 setCount(2)

  1. queue.pending 不为 null,它是 update1
  2. 获取 last,它是 update1
  3. update1.next = update2。现在链表变成了 update1 -> update2
  4. update2.next = queue.pending,也就是指向 update1。现在链表变成了 update1 -> update2 -> update1
  5. queue.last = update2

此时队列结构:
[update1] <-> [update2] (首尾相连)

第三次调用 setCount(3)

  1. lastupdate2
  2. update2.next = update3
  3. update3.next = update1
  4. queue.last = update3

此时队列结构:
[update1] <-> [update2] <-> [update3] (首尾相连)

看懂了吗?这就是 React 的环形链表。每次新的更新都会插入到链表的尾部,并且指向头部,形成一个闭环。


第三幕:为什么不用数组?——性能与批处理

你可能会问:“老哥,你这代码写得挺花哨,直接 queue.pending.push(update) 不就行了?”

朋友,React 的设计是为了极致的性能批量更新

1. 批处理

这是 React 的一大杀器。假设你在 onClick 事件里写了这样一段代码:

function MyComponent() {
  const [count, setCount] = useState(0);

  const handleClick = () => {
    setCount(1);
    setCount(2);
    setCount(3);
  };

  return <button onClick={handleClick}>Click me</button>;
}

如果你用数组 push

  1. setCount(1) 执行 -> 数组 [1] -> 立即触发渲染。
  2. setCount(2) 执行 -> 数组 [1, 2] -> 立即触发渲染。
  3. setCount(3) 执行 -> 数组 [1, 2, 3] -> 立即触发渲染。

结果:用户点一下,页面重绘了 3 次。这是灾难性的性能。

如果你用 React 的环形链表:

  1. setCount(1) 执行 -> 队列 [1] -> 不渲染
  2. setCount(2) 执行 -> 队列 [1] -> [2] -> 不渲染
  3. setCount(3) 执行 -> 队列 [1] -> [2] -> [3] -> 不渲染
  4. handleClick 结束。

然后,React 的调度器(Scheduler)介入了,它发现“哇,这个组件一口气来了三个更新”,于是它决定:只渲染一次! 读取队列,依次处理 1 -> 2 -> 3,最终状态变成 3

这就是 React 的“延迟满足”。dispatchAction 只负责把货堆好,不负责发货。发货(渲染)是由调度器统一安排的。

2. 内存效率

环形链表不需要扩容。数组如果满了还要 resize,还要复制数据,多麻烦。链表直接在内存里串起来就行。


第四幕:手写一个简易版 React —— 实战演练

光看不练假把式。为了让你彻底理解 pending 队列和 dispatchAction 的交互,咱们来手写一个极简版的 React。

这个 Demo 会模拟:

  1. 创建 Fiber 节点。
  2. 创建 updateQueue
  3. 实现 dispatchAction 的入队逻辑。
  4. 模拟调度渲染。
// 1. 定义 Update 对象结构
class Update {
  constructor(action) {
    this.action = action;
    this.next = null;
  }
}

// 2. 定义 UpdateQueue 结构
class UpdateQueue {
  constructor() {
    this.pending = null; // 环形链表头
    this.last = null;    // 环形链表尾
  }

  // 核心方法:入队
  enqueue(update) {
    if (this.pending === null) {
      // 空队列:update 指向自己,作为头也是作为尾
      this.pending = update;
      this.last = update;
      update.next = update;
    } else {
      // 非空队列:接在 last 后面,然后 update.next 指向 pending
      this.last.next = update;
      update.next = this.pending;
      this.last = update;
    }
  }
}

// 3. 模拟 Fiber 节点
class FiberNode {
  constructor() {
    this.memoizedState = null; // 当前渲染后的状态
    this.updateQueue = new UpdateQueue(); // 挂载队列
  }
}

// 4. 模拟 dispatchAction
function dispatchAction(fiber, action) {
  // 创建 Update
  const update = new Update(action);

  // 塞入队列
  fiber.updateQueue.enqueue(update);

  console.log(`[调度] 新增更新: ${action}, 当前队列状态:`, fiber.updateQueue.pending);

  // 模拟调度器:这里简单打印,实际是 scheduleWork
  console.log(`[调度] 触发重渲染...`);
  render(fiber);
}

// 5. 模拟渲染阶段:读取队列
function render(fiber) {
  const queue = fiber.updateQueue;
  let newState = fiber.memoizedState;

  if (queue.pending !== null) {
    // 这是一个循环,因为 pending 是环形链表
    // 我们需要遍历链表,处理所有的更新
    let update = queue.pending;
    let isFirst = true;

    // 注意:这里我们只打印,不真正修改 memoizedState,避免死循环
    do {
      if (isFirst) {
        // 第一次处理时,把 newState 初始化为第一个 update 的 action
        newState = update.action;
        isFirst = false;
      } else {
        newState = newState + update.action; // 简单的累加逻辑
      }
      update = update.next;
    } while (update !== queue.pending);

    console.log(`[渲染] 计算完成,最终状态: ${newState}`);

    // 模拟更新 memoizedState
    // fiber.memoizedState = newState; 
  }
}

// --- 测试开始 ---

// 初始化组件
const fiber = new FiberNode();
fiber.memoizedState = 0; // 初始状态 0

console.log("--- 第一次更新 ---");
dispatchAction(fiber, 1); 
// 预期输出: 
// [调度] 新增更新: 1, 当前队列状态: Update { action: 1, next: Update { ... } }
// [调度] 触发重渲染...
// [渲染] 计算完成,最终状态: 1

console.log("n--- 第二次更新 ---");
dispatchAction(fiber, 2);
// 预期输出:
// [调度] 新增更新: 2, 当前队列状态: Update { action: 1, next: Update { action: 2, ... } }
// [调度] 触发重渲染...
// [渲染] 计算完成,最终状态: 3

console.log("n--- 第三次更新 ---");
dispatchAction(fiber, 5);
// 预期输出:
// [调度] 新增更新: 5, 当前队列状态: Update { action: 1, next: Update { action: 2, next: Update { action: 5, ... } } }
// [调度] 触发重渲染...
// [渲染] 计算完成,最终状态: 8

看!当你在控制台看到 最终状态: 8 的时候,你就明白 dispatchAction 的功力了。它没有把 1 变成 2,也没有把 2 变成 5,它是把这三个更新打包在一起,交给渲染器去处理。渲染器拿到的是一串链条,它只需要从头走到尾,把结果算出来就行。


第五幕:pending 队列的“读取”与“清空”

咱们刚才只讲了“入队”,也就是 dispatchAction 的部分。但这只是半边天。

React 的更新是两阶段的:

  1. 渲染阶段: 计算 DOM,计算新的状态。这一步会读取 pending 队列。
  2. 提交阶段: 把 DOM 插入页面。

渲染阶段,有一个核心函数叫 processUpdateQueue。它的任务就是遍历 pending 链表,把所有的更新应用到 memoizedState 上。

当你遍历完链表后,React 会做什么?清空队列!

因为状态已经计算完了,新的 memoizedState 已经在 workInProgress(工作 Fiber)上了。原来的 pending 队列就没用了。

React 源码中的逻辑大致如下(伪代码):

function processUpdateQueue(workInProgressQueue) {
  const queue = workInProgressQueue.updateQueue;

  // 开始遍历
  let newState = queue.memoizedState; // 从当前状态开始
  let update = queue.pending;

  if (update !== null) {
    // 遍历环形链表...
    do {
      const action = update.action;
      newState = newState + action; // 简单的 reducer
      update = update.next;
    } while (update !== queue.pending);

    // 遍历结束,清空队列!
    queue.pending = null;
    queue.last = null;
  }

  // 将新状态赋给 workInProgress
  workInProgressQueue.memoizedState = newState;
}

这就解释了为什么你连续调用三次 setState,组件只渲染一次。因为三次调用只是把三个 Update 放进 pending,并没有真正修改 memoizedState。只有当渲染发生时,processUpdateQueue 才会把它们拿出来算一遍,然后清空队列,准备下一次更新。


第六幕:进阶细节——Tag 与 Update 的类型

咱们刚才的代码太简单了,只处理了数字累加。在 React 真实源码中,Update 对象可是个“多面手”。

它不仅仅存 action,它还有个 tag 属性。这个 tag 决定了这个更新是“同步”的还是“异步”的,是“函数式”的还是“替换式”的。

常见的 Tag 有:

  • UpdateState (0x1): 普通的状态更新,也就是我们最常用的 setState(value)
  • UpdateEffect (0x2): 副作用相关的更新,比如 useState 的依赖数组变化。
  • ReplaceState (0x3): 替换状态,也就是 setState(prev => 'new value') 这种形式,会完全替换掉旧的状态,而不是合并。
  • UpdateContextProvider (0x4): 上下文相关的更新。

dispatchAction 创建 Update 对象时,它会根据传入的参数类型设置不同的 Tag。比如,如果你传的是一个函数,它会被标记为 ReplaceState

虽然这个 Tag 不会改变我们今天讨论的“入队”逻辑(它依然会被塞进 pending 队列),但它决定了渲染器在读取队列时,如何处理这个更新。

想象一下,如果队列里有一个 ReplaceState 类型的更新,渲染器读到它时,就不会执行 newState = newState + action,而是直接 newState = action


第七幕:异步与调度

咱们之前提到 dispatchAction 是异步的。它是怎么做到的?

其实,dispatchAction 本身是同步的(它只是执行了 enqueue 操作)。它很快,几乎不耗时。

真正的异步来自于 scheduleUpdateOnFiber

当你调用 dispatchAction,它最后会调用 scheduleUpdateOnFiber。这个函数会调用 React 的调度器。

调度器会根据当前的宿主环境(浏览器、Node.js)来决定什么时候执行渲染。

  • 如果是 batchedUpdates(批量更新)模式(比如在同一个事件处理函数里),调度器会把这些任务攒着,等事件处理完一次性推入宏任务队列。
  • 如果是 legacy 模式,可能会有点不一样,但核心思想不变:不要渲染,先排队

所以,pending 队列就像是高速公路上的收费站入口。dispatchAction 是把车开进入口的司机,pending 队列是停车场。调度器是收费站的工作人员,它决定什么时候放行这些车去收费站(渲染阶段)。


第八幕:总结——队列的艺术

好了,咱们来回顾一下 dispatchAction 是如何将更新对象存入 pending 队列的。

  1. 封装: dispatchAction 接收 action,将其封装成 Update 对象。
  2. 判断: 检查当前队列是否为空。
  3. 插入:
    • 如果为空,形成自环update.next = update)。
    • 如果不为空,找到 last,将新的 update 接在 last 后面,并将新的 update.next 指向 pending,形成环形链表
  4. 调度: 触发调度器,等待渲染时机。

这不仅仅是一个数据结构的问题,更是一种设计哲学。React 选择了环形链表而不是数组,是为了在批量更新时,能够高效地合并状态,避免频繁的 DOM 操作,从而保证应用的流畅度。

下次当你写 setState 的时候,别忘了,你不仅仅是在改变一个变量。你是在向 React 的调度中心投递一份“包裹”。这份包裹静静地躺在 pending 队列里,等待着调度员的检阅。

这就是 React 的魔法,简单,但绝不简单。


附录:源码级对照

为了让你更有底气,我贴一下 React 源码中 enqueueUpdate(其实就是 dispatchAction 的核心逻辑)的精简版对照:

// packages/react-reconciler/src/ReactFiberHooks.js
function dispatchAction<S, A>(fiber: Fiber, queue: UpdateQueue<S, A>, action: A) {
  // 创建 update 对象
  const update: Update<S, A> = {
    // ...
    next: null,
  };

  // 核心:环形链表插入逻辑
  if (queue.pending === null) {
    update.next = update;
    queue.pending = update;
    queue.last = update;
  } else {
    // const last = queue.last;
    // last.next = update; // 1. 指向新元素
    // update.next = queue.pending; // 2. 新元素指向旧头
    // queue.last = update; // 3. 更新尾指针
    // 这三行代码就是精髓!

    // 为了可读性,源码通常会稍微拆开,但逻辑完全一致
    const last = queue.last;
    if (last !== null) {
      last.next = update;
    }
    update.next = queue.pending;
    queue.last = update;
  }

  // 触发调度
  scheduleUpdateOnFiber(fiber);
}

看到没?这就是 dispatchAction 的全部秘密。它没有复杂的逻辑,只有最朴素的链表操作。但正是这朴素的操作,支撑起了 React 整个庞大的状态管理大厦。

好了,今天的讲座就到这里。希望大家下次再看到 pending 队列时,脑海里浮现的不是一团乱麻,而是一条首尾相接的、优雅的链表。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注