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 节点(组件实例)和对应的更新队列。
它的工作流程大概是这样的:
- 接收命令: 收到
setState传进来的参数(比如1)。 - 打包货物: 把这个
1封装成一个对象,我们叫它update对象。 - 排队入栈: 把这个
update对象塞进组件的pending队列里。 - 呼叫调度员: 告诉调度中心“我有活干了,有空来取”。
听着很简单,对吧?但第 3 步——“排队入栈”,才是今天的重头戏。
第二幕:神秘的 pending 队列——环形链表的艺术
在 React 源码中,每个 Fiber 节点都有一个 updateQueue 属性。这个 updateQueue 里面藏着什么?它是一个队列,用来存储待处理的更新。
很多初学者会想:“既然是队列,那用数组 push 一下不就完了吗?”
错!大错特错!
如果用数组,每次渲染时,你都得把数组清空,然后从头遍历。这叫“重置状态”。但这不符合 React 的设计哲学。React 想要的是状态合并。
比如你在同一个渲染周期内调用了两次 setCount(1) 和 setCount(2)。最终结果应该是 count = 3。如果你用数组 push,渲染时 1 进来,2 进来,渲染时 1 先被处理,然后 2 把 1 覆盖了。这很乱。
为了解决这个问题,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):
queue.pending是null。- 创建
update1。 update1.next = update1(自己指自己)。queue.pending = update1。queue.last = update1。
此时队列结构:
[update1] (循环指向自己)
第二次调用 setCount(2):
queue.pending不为null,它是update1。- 获取
last,它是update1。 update1.next = update2。现在链表变成了update1 -> update2。update2.next = queue.pending,也就是指向update1。现在链表变成了update1 -> update2 -> update1。queue.last = update2。
此时队列结构:
[update1] <-> [update2] (首尾相连)
第三次调用 setCount(3):
last是update2。update2.next = update3。update3.next = update1。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:
setCount(1)执行 -> 数组[1]-> 立即触发渲染。setCount(2)执行 -> 数组[1, 2]-> 立即触发渲染。setCount(3)执行 -> 数组[1, 2, 3]-> 立即触发渲染。
结果:用户点一下,页面重绘了 3 次。这是灾难性的性能。
如果你用 React 的环形链表:
setCount(1)执行 -> 队列[1]-> 不渲染。setCount(2)执行 -> 队列[1] -> [2]-> 不渲染。setCount(3)执行 -> 队列[1] -> [2] -> [3]-> 不渲染。handleClick结束。
然后,React 的调度器(Scheduler)介入了,它发现“哇,这个组件一口气来了三个更新”,于是它决定:只渲染一次! 读取队列,依次处理 1 -> 2 -> 3,最终状态变成 3。
这就是 React 的“延迟满足”。dispatchAction 只负责把货堆好,不负责发货。发货(渲染)是由调度器统一安排的。
2. 内存效率
环形链表不需要扩容。数组如果满了还要 resize,还要复制数据,多麻烦。链表直接在内存里串起来就行。
第四幕:手写一个简易版 React —— 实战演练
光看不练假把式。为了让你彻底理解 pending 队列和 dispatchAction 的交互,咱们来手写一个极简版的 React。
这个 Demo 会模拟:
- 创建 Fiber 节点。
- 创建
updateQueue。 - 实现
dispatchAction的入队逻辑。 - 模拟调度渲染。
// 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 的更新是两阶段的:
- 渲染阶段: 计算 DOM,计算新的状态。这一步会读取
pending队列。 - 提交阶段: 把 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 队列的。
- 封装:
dispatchAction接收 action,将其封装成Update对象。 - 判断: 检查当前队列是否为空。
- 插入:
- 如果为空,形成自环(
update.next = update)。 - 如果不为空,找到
last,将新的update接在last后面,并将新的update.next指向pending,形成环形链表。
- 如果为空,形成自环(
- 调度: 触发调度器,等待渲染时机。
这不仅仅是一个数据结构的问题,更是一种设计哲学。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 队列时,脑海里浮现的不是一团乱麻,而是一条首尾相接的、优雅的链表。