React 18 的并发渲染有一个很容易被误解的地方:高优先级更新可以先参与渲染,但这不代表 React 会永久打乱状态更新的顺序。
更准确地说,在使用 Transition 等并发特性时,低优先级渲染可以被更紧急的更新打断。React 可能先把高优先级结果展示出来,再回头处理之前跳过的低优先级更新。不过到了最终计算阶段,它仍然要保留更新之间原本的依赖关系。
这也是为什么页面可能出现 1 → 3 → 9,而不是简单地得到 5。
先看一个例子
假设初始状态是 1,先入队一个低优先级的"加 2",随后又入队一个普通的"乘 3":
jsx
import { startTransition, useState } from 'react';
function Demo() {
const [value, setValue] = useState(1);
function handleClick() {
startTransition(() => {
setValue(value => value + 2); // 低优先级,先入队
});
setValue(value => value * 3); // 更紧急,后入队
}
return <button onClick={handleClick}>{value}</button>;
}
如果完全按照入队顺序计算,结果应该是:
ini
(1 + 2) * 3 = 9
但高优先级更新需要更快响应。那 React 能不能直接把"乘 3"挪到前面,变成下面这样?
ini
(1 * 3) + 2 = 5
不能。因为这会改变更新原本的语义。
高优先级先展示,不等于永久改变计算顺序
React 的更新队列不是按优先级重新排序的,新更新仍然按照入队顺序追加。优先级真正影响的是:当前这轮渲染要处理哪些更新。
在一次可能的调度过程中,计算会变成这样:
| 阶段 | React 做了什么 | 结果 |
|---|---|---|
| 初始状态 | 页面已经提交 | 1 |
| 紧急更新渲染 | 暂时跳过低优先级的"加 2",先执行"乘 3" | 3 |
| 后续低优先级渲染 | 从保留的基础状态重新计算,依次执行"加 2"和"乘 3" | 9 |
所以,页面有可能呈现:
1 → 3 → 9
关键点在于,低优先级更新被跳过时并没有被丢掉。React 会保留当时的基础状态和后续更新。等低优先级任务重新开始时,后面的高优先级更新也会一起被重放,也就是常说的 rebase。
这样既能让紧急更新尽快出现在页面上,又能保证最终结果仍然等价于按入队顺序执行:
ini
(1 + 2) * 3 = 9
中间状态不是固定承诺
1 → 3 → 9 只是一种可能观察到的提交顺序,不是每次运行都会稳定出现的动画过程。
React 官方对并发渲染的定义本身就允许暂停、继续,甚至放弃一轮尚未完成的渲染。设备性能、任务耗时以及后续是否又来了更紧急的更新,都会影响中间结果有没有机会真正提交到页面。
因此可以把它理解成:
- 页面不保证展示队列里的每一个中间状态。
- 更新之间的计算依赖仍然按照入队顺序保留。
- 在更新函数纯净、更新序列相同的前提下,优先级可以影响中间展示,但不应该改变最终结果。
写业务代码时要注意什么
第一,如果下一次状态依赖上一次状态,使用函数式更新:
jsx
setValue(value => value + 2);
不要把这类例子直接写成:
jsx
setValue(value + 2);
后者拿到的是当前这次渲染里的状态快照,表达的已经不是"基于队列中的上一个状态继续计算"了。
第二,更新函数必须保持纯净。并发渲染可能重启,跳过的更新也可能在 rebase 时被重新执行。如果在更新函数里修改外部变量、发送请求或者做其他副作用,重放时就可能产生意料之外的结果。
第三,不要让业务逻辑依赖某个中间状态一定会显示。React 保证的是提交后的 UI 一致性和最终计算结果,不是每一轮尝试渲染都能被用户看到。
总结
整体看下来,React 18 并不是把状态更新按优先级重新排个序,然后从头算一遍。
它做的是:紧急的先处理,暂时处理不了的先留下;等后面继续时,再从正确的基础状态把更新按原顺序接回去。页面中间可能先看到 3,最终仍然会回到 (1 + 2) * 3 = 9 这条计算链路上。