追踪从更新队列到DOM提交的React setState调用过程
逐步跟随 React 状态更新的流程,了解其如何经过 Hook 更新队列、调度器、渲染阶段、协调处理以及提交操作,从而明白为何状态不会立即发生变化。
几乎每个 React 开发者最终都会编写状态设置函数,随后调用 console.log,却惊讶地看到旧值被打印出来。setState 这个名称让人以为会立即完成赋值,但 React 实际并非如此:调用设置函数只是记录了未来状态的请求,在内容显示在屏幕上之前,还会经历一整套处理流程。本文将追踪一次状态更新在整个流程中的走向,包括从 Hook 的更新队列开始,经过调度、渲染、比对,直至最终提交阶段。一旦你能理解每个阶段的过程,那些起初看似反常的行为就会变得可以预测:为什么状态更新看起来是异步的,为什么多次更新会合并为一次渲染,为什么 React 有时会跳过某些操作,以及函数式更新存在的意义是什么。
让所有人惊讶的代码片段
想象在一次代码审查中,这样的写法看起来完全合理:
setCount(count + 1);
console.log(count);
预期控制台会显示递增后的值,但实际上显示的是之前的值。以下是在完整组件中出现的相同情况:
function Counter() {
const [count, setCount] = useState(0);
const handleClick = () => {
setCount(count + 1);
console.log(count);
};
return (
<button onClick={handleClick}>
{count}
</button>
);
}
在首次点击时,许多开发者期望看到的是:
1
实际上控制台显示的内容是:
0
原因是React尚未再次进行渲染。当执行console.log时,React仅收到了请求,队列中的任何操作都尚未应用,组件函数也未被再次调用,DOM也没有发生变化。你正在执行的代码属于当前的渲染过程,在该渲染过程中count只是一个在函数执行时就被确定的普通常量,没有任何东西可以重新赋值给它。
这就是思维模式的第一处转变:setter不会修改你正在读取的状态变量,它只是为React安排稍后执行的任务。
调用setter时React会存储什么
当你编写如下代码时:
setCount(count + 1);
很容易想象React在内部会这样做:
count = count + 1
但实际上并非如此。React会创建一个更新对象,并将其添加到属于该特定Hook的队列中。从概念上讲,点击之后的情况如下:
Current State
|
▼
count = 0
|
▼
User Clicks
|
▼
setCount(1)
|
▼
Update Queue
[ Update: 1 ]
状态本身并未被修改。React所做的只是给自己留下一条记录:下次处理该Hook的更新时,count的值应设为1。
为何更新要通过队列处理
当有多个更新几乎同时到来时,使用队列就很有意义了。假设有一个处理函数会调用setter三次:
const handleClick = () => {
setCount(count + 1);
setCount(count + 1);
setCount(count + 1);
};
刚接触 React 的人可能会认为只需点击一次就能实现:
count = 3
实际的结果却是:
count = 1
这三次调用都在同一次渲染过程中执行,而那次渲染遇到了:
count = 0
因此每个 count + 1 的计算结果都相同,React 会收到三个完全一致的请求:
setCount(1)
setCount(1)
setCount(1)
于是队列中会存有三条内容均为“设为 1”的记录:
[1]
[1]
[1]
即使按顺序处理它们,最终结果依然是:
1
而不是:
3
当数值不同时也适用同样的逻辑。假设某次渲染会发出这三次调用:
setCount(1)
setCount(6)
setCount(4)
此时队列中就会包含:
[1]
[6]
[4]
每个条目都会直接替换之前的状态,因此处理完成后只有最后一个条目有效,结果为4。普通值仅用于替换,而非用于在原有基础上进行构建的指令。正是由于这一限制,才有了函数式更新的存在。
函数式更新从最新状态开始计算
现在修改处理程序,让每次调用都传递一个函数:
const handleClick = () => {
setCount(prev => prev + 1);
setCount(prev => prev + 1);
setCount(prev => prev + 1);
};
这次队列中存放的更像是三个小型程序,而非三个值:
prev => prev + 1
prev => prev + 1
prev => prev + 1
当React处理队列时,它会将每个函数的输出传递给下一个函数:
0 → 1
1 → 2
2 → 3
最终的状态为:
count = 3
不同之处在于,更新函数不会捕获当前渲染中的值。它描述了如何根据 React 处理该条目时的状态来推导出下一个状态。每当新状态依赖于旧状态时,尤其是当有多个更新需要依次处理,或者更新发生在之前渲染过程中创建的回调函数中时,都应使用这种形式。
一次更新的完整生命周期
考虑到队列机制,我们来追踪一次点击所触发的流程:
setCount(count + 1);
接下来发生的一切的简化示意图:
User Click
|
▼
Create Update
|
▼
Place Update Into Queue
|
▼
Notify React Scheduler
|
▼
Schedule Render
|
▼
Render Phase
|
▼
Reconciliation
|
▼
Commit Phase
|
▼
DOM Updated
以下内容将逐一介绍每个阶段。
步骤1:创建更新记录
调用 setCount(1) 时永远不会直接重新渲染任何内容:
setCount(1);
React 会创建一个更新记录,可以将其视为一条备注:
Apply this update later.
该记录与 Hook 的内部状态相关联。Hook 数据与组件的 Fiber 一同存在,Fiber 是 React 为每个组件实例保留的内部节点,更新队列也存储在那里:
Fiber
|
└── useState
|
├── Current State
└── Update Queue
这也正是为什么在每次渲染时都必须以相同的顺序调用 Hooks:React 会根据列表中的位置来查找每个 Hook 的状态和队列。
步骤 2:React 安排任务执行
拿到更新内容后,React 需要决定何时处理它。这就是调度器的职责。React 并不会在调用 setter 的瞬间立即进行渲染,而是会在响应速度与避免不必要的工作之间取得平衡。
想象一下用户正在快速在输入框中打字:
A
AB
ABC
ABCD
ABCDE
如果在每次按键后都同步渲染界面中耗资源的部分,且无法对不同操作进行优先级排序,那么内容繁多的页面就会显得反应迟缓。通过调度机制,React可以将同时到达的更新合并处理,并借助过渡等并发功能,将像输入操作这样的紧急更新与过滤后的结果列表这类较不紧急的更新区分开来对待。正是这种能力使得React的架构倾向于采用调度式更新而非即时更新。
步骤3:渲染阶段会再次运行组件
当React决定是时候处理待处理的更新时,它就会启动新的渲染过程。渲染其实就是再次调用组件函数:
function Counter() {
const [count, setCount] = useState(0);
return <h1>{count}</h1>;
}
关键在于useState在本次调用时所执行的功能。在将值返回之前,它会根据之前的状态处理队列中的更新操作。具体流程如下:
Previous State = 0
React会依次处理队列中的更新:
Queue:
[ +1 ]Process QueueResult:
1
此时useState返回的值即为:
count = 1
组件会在本次渲染中使用这个值。这就是为什么新值只有在下一次渲染时才会显示出来:它是在那次渲染时计算得到的,而非在调用setter函数的时刻。
步骤4:生成新的元素树
运行该组件后会得到一组全新的React元素树。与上一次渲染相比,其结构如下:
Previous Render
<h1>0</h1>
New Render
<h1>1</h1>
浏览器的DOM尚未被修改。此时React所持有的只是经过更新的界面蓝图。
步骤5:比对找出差异
接下来,React会将之前的结果:
Old Tree
与新的结果进行比较:
New Tree
从而判断究竟发生了什么变化。在这个例子中,变化之前为:
Before:
<h1>0</h1>
变化之后为:
After:
<h1>1</h1>
只有标题内的文本有所不同,因此React仅记录这一变化。这种比较过程就是所谓的“协调”机制。如需更详细地了解React是如何决定保留什么内容以及替换什么的,可参阅关于React协调、状态和Hooks的思维模型构建。
第6步:提交阶段操作DOM
在确定了所有变化之后,React会进入提交阶段,并将这些变化应用到真实的DOM中:
DOM Before
<h1>0</h1>
DOM After
<h1>1</h1>
只有此时用户才能看到新的数值。这正是大多数人想象中调用设置器时会发生的情况,但实际上这只是多个阶段中的最后一步。
为何 React 不会立即应用更新
考虑这样一个一次更新多个状态值的处理函数:
setCount(c => c + 1);
setLoading(false);
setUser(data);
如果每次调用都会触发一次渲染,那么会出现:
Render 1
Render 2
Render 3
也就是说会有三次渲染,其中两次是多余的,而且还可能出现显示状态组合不一致的中间界面。相反,React 会批量处理这些更新,将调用顺序排队:
Update
Update
Update
然后一起处理:
|
▼Single Render
批量处理可以避免重复渲染,确保用户始终看到一致的最终状态。这也是 React 更倾向于调度更新而非立即执行的原因。React 还可以完全跳过某些操作:如果根据 Object.is 的判断,某次更新产生的值与当前值相同,React 可能会直接跳过操作而无需重新渲染组件的子元素。
实际应用中的表现
以一个搜索框为例,每次按键都会更新三个状态值:
query
results
loading
人们很容易认为每个状态设置器都会引发一次单独的渲染。但对这类组件的性能分析往往显示并非如此:在同一个事件中同时进行的更新会被批量处理,因此组件的渲染次数会比仅从代码表面看来的要少。React 的任务不仅是应用更新,还要高效地应用这些更新。
当多个组件有待更新时
更新并不限于单个组件。以这样的树结构为例:
App
├── Header
├── Sidebar
└── Dashboard
如果有多个更新分别作用于这棵树的不同部分,React无需盲目重建所有内容。Fiber树结构让React能够追踪:
- 哪些组件有待处理
- 每个更新源自树结构的哪个位置
- 哪些子树需要访问,哪些可以跳过
这正是React比“每次变化都重新渲染整个应用”的方式更具选择性的原因。需要注意的是,即使组件重新渲染,默认情况下其子组件仍会重新渲染;只有通过记忆化功能才能跳过未发生变化的子树。
再谈那个过时的console.log
回到最开始的代码片段:
setCount(count + 1);
console.log(count);
控制台输出为:
0
因为代码仍在当前的渲染过程中运行。队列尚未被处理,下一次渲染也还没有发生,更新正在等待轮到它。更准确的描述方式是:
Current Render
count = 0
Request Update
Future Render
count = 1
如果需要立即使用新值,可以将其计算到局部变量中并使用该变量,或者在下一次渲染时读取,又或者在一个依赖它的效应函数中读取。这样看的话,这种行为其实并不奇怪。
各部分之间的关联
综合起来,这些阶段形成了一个单一的链条:
- 状态与组件的 Hook 数据一起存储。
- Hook 数据会被附加到组件的 Fiber 上。
- 调用设置函数会创建一个更新操作。
- 这些更新会被添加到 Hook 的队列中。
- 调度器会选择合适的时机来处理这些更新。
- 渲染阶段会再次调用该组件并应用队列中的更新。
那些常常被单独讲解的概念实际上是一个流程中的连续步骤。用一句话概括就是:setter调用不会直接修改状态,而是请求安排后续更新,React会在下一次渲染时应用该更新,与之前的输出进行对比,只有在有差异的地方才会将其提交到DOM中。
关键要点
- setter从不直接修改状态,而是将更新任务加入队列,由React稍后处理。
- 更新是按Hook分开排队处理的,因此可以在单次渲染中处理多个更新。
- 普通值会直接替换状态;而更新函数则根据最新的状态推导出新的状态,从而避免使用过时的值。
- React会在适当时机安排任务执行,而非在每次调用时都立即渲染,这正是批量处理和优先级排序得以实现的基础。
- 渲染与更新DOM是两个独立的阶段:渲染阶段会生成描述信息,而提交阶段才会实际改变浏览器状态。
- 更新队列与组件Fiber中的Hook状态存储在一起。
将setState视为请求而非命令,虽然只是用词上的微小变化,却带来了深远的影响。正因如此,批量处理、任务调度、状态同步以及并发渲染才成为可能。接下来的自然问题是:React如何决定队列中的更新操作会触发一次渲染还是多次渲染?React 18引入的自动批量处理功能正是为了解决这一问题。