为何 setState 从不立即重新渲染:React 的更新调度机制
从队列中的更新对象开始,通过路径标记与调度机制,跟随 React 状态更新流程,最终进入优先处理根节点的工作循环,从而跳过未受影响的子树。
来源: The Fiber Architecture In React Part 3: What Actually Happens When You Call setState
几乎每位 React 开发者都隐含着一个假设:调用如 setCount(count + 1) 这样的状态设置函数时,组件会在下一行代码执行之前立即重新渲染。但实际上并非如此,而调用与重新渲染之间的时间差正是批量处理机制的体现,也解释了为何无关组件不会受到影响,以及为何在设置状态后立即读取会得到旧值。本文将沿着 React 的纤维架构追踪一次状态更新的全过程,从该操作被记录下来的那一刻到新值最终存入组件状态的瞬间,帮助你理解状态设置函数的实际作用。
微观视角下的组件
一个最简单的计数器就足以揭示整个机制——它仅保存一条状态信息,并在点击时对其值进行递增。
function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(count + 1)}>{count}</button>;
}
setCount(count + 1) 这行代码看起来像是一个普通的同步函数,因此人们很自然地会认为 React 会在执行完该行代码后直接再次调用 Counter()。但实际上并不会发生这样的事情。setter 函数在没有任何渲染操作的情况下就返回了。它所做的其实是记录下这个请求,并安排在稍后执行。
第一步:setter 记录更新请求
当 setCount(1) 被执行时,React 会在内存中分配一个小的更新对象来描述这个请求。从概念上来看,它大致如下所示:
{
action: 1,
next: null,
}
action字段保存的是传入的值,此处为1。之所以有next字段,是因为单次更新往往无法解决所有问题。在单个处理函数中调用三次设置器,React就会通过next将这三个更新对象串联起来,形成附加在组件上的一个小列表。在React的实际源代码中,更新还会携带优先级信息,且待处理列表以循环结构存储,但核心思想仍是意图的队列,而非重新渲染。
{
type: Counter,
memoizedState: { count: 0 }, // still 0, untouched
updateQueue: { action: 1, next: null }, // the note, just added
}
注意那些没有变化的部分:存储的状态仍然是 0,屏幕上显示的仍是 0,而且 Counter() 也尚未被调用。唯一的区别是现在有一个对象在队列中等待。(准确地说:借助钩子机制,React 会将每个钩子的队列保存在纤维结构中该钩子对应的条目里,而非直接存放在名为 updateQueue 的字段中;函数组件纤维中的 memoizedState 就是那个钩子列表。上述的简化模型只是为了教学目的,但它保留了此处重要的行为特性。)
这也解释了一个常见的现象:如果在调用 setCount 后立即打印 count 的值,会看到的是旧数值,因为闭包中引用的 count 对应的组件函数尚未再次执行。
第二步:scheduleUpdateOnFiber 标记路径并请求处理任务
在更新被加入队列后,React会调用一个名为scheduleUpdateOnFiber的内部函数。该函数承担两项独立的职责,将它们分开处理能让后续流程更易于理解。
标记通向根节点的路径
首先,React从Counter纤维开始,通过每个父节点指针逐层向上遍历,直到到达树的根节点。在路径上的每一个祖先节点上,它都会设置一个名为childLanes的字段,该字段实际上表示“我的下方有待处理的任务”。而Counter纤维本身则有一个名为lanes的字段,表示“我自己也有待处理的任务”。
这些字段并非简单的布尔值,而是用于编码优先级的位掩码,而这本身就是一个独立的话题;可参阅React lanes如何利用位掩码来编码更新优先级。对于本教程而言,只需知道它们会从空状态变为非空状态,从而在已更新的组件与顶层之间留下追踪路径即可。
延迟实际渲染
其次,React要求渲染任务尽快执行,而非在事件处理函数内部完成。具体机制取决于任务的优先级。由React调度器处理的任务通常通过MessageChannel来提交(在缺乏该机制的环境中则使用setTimeout作为替代),因此会在事件循环的后续阶段执行。对于点击等离散且优先级较高的事件,现代版本的React通常会在处理函数执行完毕后立即将相关任务作为微任务处理,这依然是在你的代码之后,只是时间更早一些。不同版本之间的具体策略会有所变化,因此相关细节应视为特定版本的特有内容。
无论通过何种渠道,结果都是一样的:渲染会被刻意延迟到处理函数返回之后。这正是批量处理的实现基础。在一个处理函数中调用三次 setCount 会生成三个排队等待更新的请求,之后只需一次渲染流程就能将它们全部处理完毕,而无需进行三次渲染。
第三步:渲染始终从根节点开始
当预定的任务最终执行时,它并不会从 Counter 开始。而是从根纤维开始,即整个应用程序上方的那个特殊节点,它是在你调用 ReactDOM.createRoot 时创建的。无论更新源自树结构的哪个深度,都是如此。
从根节点开始听起来成本很高,但这并不意味着要遍历每一条路径。第二步中设置的childLanes路径正是遍历时所参考的。在每条路径上,React都会检查其下的子树是否有待处理的任务。如果没有,则React会直接跳过整个子树,而不需要继续深入其中。标记路径上的祖先节点会被快速处理,只有那些包含待处理任务的分支才会被进一步探索。
循环而非递归
这种遍历方式是通过循环实现的,而不是深度递归:
let node = rootFiber;
while (node !== null) {
node = processNode(node); // moves to node.child, node.sibling, or up via node.return
}
每个节点都包含三个指针:child指向它的第一个子节点,sibling指向同一层级中的下一个节点,return指向它的父节点。通过这三个链接,可以逐个步骤向下遍历子节点、横向移动到兄弟节点,以及在处理完某个分支后返回上层。无需通过数组来遍历子节点。由于节点在树中的位置存储在变量中而非调用栈上,React能够暂停当前工作并在之后继续执行,这正是并发渲染的基础。
为何无关组件不会被修改
这就是你可能已经观察到的现象背后的机制。在输入框中输入内容只会更新对应状态的那部分数据结构。那些距离较远且无关的组件不会被重新渲染,甚至都不会被访问,因为没有更新操作标记过它们的分支。这种跳过机制并非事后添加的特殊优化手段,而是直接源于对 childLanes 路径的追踪。
请记住这一限制:位于标记路径上的组件如果本身有待处理的操作,就会重新渲染;默认情况下,它的所有后代组件——无论是否被标记——都会重新渲染,除非它们已被缓存。这条路径指引着 React 的走向,但它并不能阻止需要重新渲染的父组件继续渲染其子组件。
第四步:到达 Counter 并处理队列
当执行流程到达 Counter 时,双缓冲机制开始起作用。React 会检查当前纤维上的 alternate 指针,要么重用现有的正在处理中的纤维,要么创建一个新的。新的状态就是在那个正在处理中的副本上计算得出的。
现在队列终于被读取了。React会处理待处理的更新,将相应操作应用到之前的状态上,并将结果写入正在处理的fiber的memoizedState中。在此步骤中实际上会调用Counter(),而useState则会返回新值。如果有多个更新被排入队列,它们会按顺序应用;像setCount(c => c + 1)这样的函数式更新会接收到前一次操作的结果,因此当批量执行时它们能正常工作,而三次调用setCount(count + 1)则只会导致一次数值增加。
// work-in-progress fiber, right after processing the queue
{
type: Counter,
memoizedState: { count: 1 }, // finally updated, using the queued action
updateQueue: null, // consumed, nothing left pending
alternate: Fiber A,
}
队列现已为空,新的状态保存在正在处理的线程中。此时三种机制已协同作用:第一步中的排队更新操作、第二步和第三步中的标记路径及从根节点开始的遍历过程,以及用于保存更新前后的当前/正在处理线程对。如需了解涵盖协调操作及DOM提交阶段的更完整追踪过程,请参阅从更新队列到DOM提交的React setState调用追踪。
完整流程
将各个步骤按顺序梳理,一次点击会经历以下过程:
- 点击处理函数调用
setCount,React会创建一个更新对象并将其添加到组件的队列中。
scheduleUpdateOnFiber会设置Counter中的lanes,以及从该节点到根节点的所有祖先节点中的childLanes。child、sibling和return节点,跳过没有待处理任务的子树。Counter节点处,React会准备正在处理的纤维节点,处理队列中的更新并计算新的状态。所有这些操作都不会在你调用设置函数的那一行发生,而是在之后由专门为支持这种延迟、可恢复的处理流程而设计的结构中依次执行。
关键要点
- 状态设置器会将更新操作加入队列并安排执行时间;它绝不会在处理函数中同步进行渲染。
- 在设置状态后立即读取该状态会得到旧值,因为组件尚未重新运行。
- 批量处理是延迟执行的自然结果:在渲染阶段之前加入队列的所有更新都会一次性处理完毕。
lanes和childLanes用于标记从已更新的计算单元到根节点的路径,从而使React能够跳过未被修改的子树。- 渲染循环使用
child、sibling和return指针而非递归,这正是React能够暂停和恢复工作的原因。 - 即使组件需要重新渲染,它仍会默认重新渲染其子元素;路径追踪机制限制的是React的查找范围,而非父组件实际渲染的内容。