重回金山堡(pǔ),小学3年级到初中毕业,生活在这里。
很多次都有想重新回金山堡的冲动,但是到底是去那里做些什么呢,我也没有头绪,可能我就是想双脚,站在那里,期望时光隧道为我暂时开放几秒,回到小时候。
这段话确实很抽象,我们把它拆开,每一条都配一个能跑的例子,你就明白了。
先建立一个总的心智模型:
setState 调用 ──✕──> 组件函数被调用(渲染) ──✕──> DOM 真的被改(视觉更新)这两个箭头都不是一一对应的。第 1 点讲第一个箭头,第 2 点讲第二个箭头。
第 1 点:一次 setState ≠ 一次渲染
1.1 状态没有”有意义的变化”→ 渲染可能被省掉
React 用 Object.is(旧值, 新值) 判断状态是否真的变了:
function App() {
const [count, setCount] = useState(0);
console.log("渲染了");
return <button onClick={() => setCount(0)}>点我</button>;
// ↑ 新值 0,旧值也是 0
}点击按钮:Object.is(0, 0) 为 true → React 认为”这次更新没意义”→ 组件函数根本不会被调用(严格来说:同一状态下再次 set 相同值,React 可能还会渲染一次该组件以确认,但会 bail out 子树;连续 set 相同值则完全不渲染。细节不影响理解主旨)。
这就是”1 次 setState,0 次渲染”。
1.2 批处理:多次 setState 合并成一次渲染
function App() {
const [a, setA] = useState(0);
const [b, setB] = useState(0);
console.log("渲染了");
function handleClick() {
setA(a + 1); // 更新 1
setB(b + 1); // 更新 2
setA(a + 2); // 更新 3
}
return <button onClick={handleClick}>点我</button>;
}点击一次,控制台只打印一次”渲染了”。
为什么?React 在执行你的事件处理函数时,并不是每遇到一个 setState 就立刻渲染,而是把更新先放进队列,等你的函数执行完,再一次性计算最终状态、渲染一次。
这就是”3 次 setState,1 次渲染”。直觉上你可能以为会渲染 3 次——批处理就是为了避免这种浪费。
1.3 为什么 React 17 里 promise/setTimeout 中的更新无法批处理?
这条最难懂,关键要理解React 17 的批处理是怎么实现的。
React 17 的批处理很”原始”:它只在自己能包住的代码里生效。React 的合成事件处理器是 React 自己调用的,所以它可以这样做(伪代码):
// React 17 内部(伪代码)
function 触发你的onClick() {
isBatching = true; // 开启批处理开关
你的onClick(); // ← 这里面的 setState 都只是进队列
isBatching = false; // 关闭开关
开始渲染(); // 统一处理队列,渲染一次
}关键:React 必须”在场”——你的代码执行前后它都有机会插手,才能批处理。
现在看这个:
function handleClick() {
fetch("/api").then(() => {
setA(a + 1); // React 17:渲染一次
setB(b + 1); // React 17:又渲染一次!
});
}.then 里的回调是什么时候执行的?是网络请求回来后,由浏览器的事件循环在一个全新的调用栈里执行的。此时:
- 你的
handleClick早就执行完退出了 - React 设置的
isBatching = true早就被恢复成 false 了 - 这个回调是浏览器直接调用的,React 完全不知道它开始了、也不知道它什么时候结束
React”不在场”,没法在回调前后插手,于是每个 setState 只能各自立刻触发一次渲染。这就是原文说的”React 无法控制它们何时被 resolve”和”独立的事件循环调用栈”的意思。
setTimeout、setInterval、requestAnimationFrame、addEventListener 加的原生事件处理器,全都是同一个道理——回调由浏览器调度,React 不在场。
React 18 怎么解决的? 换了思路:setState 不再依赖”开关”,而是把更新入队后调度一个微任务,在微任务里统一渲染。无论 setState 发生在哪个调用栈里,同一批同步代码里的多次 setState 都会等到当前调用栈清空后统一处理。所以 React 18 里上面的例子也只渲染一次——这就是”自动批处理”(automatic batching)。
1.4 并发特性:一次更新的渲染工作可能被拆成多块
这条你先有个印象即可:在并发模式下(如使用 startTransition),React 渲染一棵大组件树时,可以渲染一部分 → 暂停,让出主线程给浏览器响应用户输入 → 再继续,甚至发现有更高优先级的更新时丢弃已渲染的一半,重头再来(你的组件函数会被调用两次,但 UI 只更新一次)。所以”一次更新”和”渲染过程”之间也不是简单的一对一。
第 2 点:一次渲染 ≠ 一次 UI 视觉更新
这一点要先分清 React 工作的两个阶段:
渲染阶段(render phase):调用你的组件函数,算出"UI 应该长什么样"
↓ 对比新旧结果(diff)
提交阶段(commit phase):只把有差异的部分写入真实 DOM“渲染”只是调用你的函数做计算,不等于改 DOM。 两种典型的”渲染了但 UI 没变”:
情况一:渲染结果和上次一样
回到之前讨论过的被动渲染例子:
function Parent() {
const [count, setCount] = useState(0);
return (
<div>
<button onClick={() => setCount(count + 1)}>{count}</button>
<Child /> {/* props 引用变了,不满足 bail out,会被重新渲染 */}
</div>
);
}
function Child() {
console.log("Child 渲染了"); // 每次点击都会打印
return <div>我永远不变</div>; // 但这个 div 的内容从没变过
}每次点击,Child 函数都被调用(控制台打印),但它返回的内容和上次一模一样 → diff 发现没有差异 → 提交阶段对这个 div 什么都不做。
这就是”1 次渲染,0 次 UI 更新”。也是为什么文章反复强调:重新渲染本身不可怕(只是函数调用 + diff 的开销),只有当它多到影响性能时才需要优化。
情况二:并发模式下被丢弃的渲染
上面 1.4 提到的:React 渲染到一半发现有更紧急的更新,把已完成的渲染结果整个扔掉重来。你的组件函数被调用了,但那次调用的结果从未到达屏幕。(这也是为什么 React 要求组件是纯函数——它可能被调用任意多次,其中一些调用的结果会被丢弃,如果你在渲染中夹带副作用就会出 bug。)
一图总结
setState ──> 更新入队 ──┬─ Object.is 判断没变化 ──> 可能不渲染 (1次更新,0次渲染)
├─ 和其他更新批处理 ──────> 合并成一次渲染 (N次更新,1次渲染)
└─ React 17 + 异步回调 ──> 各自单独渲染 (N次更新,N次渲染)
渲染(调用你的函数)──┬─ diff 有差异 ──> commit,改 DOM (渲染 → UI更新)
├─ diff 无差异 ──> 什么都不做 (渲染 ≠ UI更新)
└─ 并发模式被中断 ──> 结果丢弃,可能重渲染 (渲染 ≠ UI更新)所以原文那两句话翻译成大白话就是:
- 别以为你调一次 setState,React 就会老老实实渲染一次——可能不渲染(值没变)、可能合并渲染(批处理)、也可能拆开渲染(并发)。
- 别以为组件函数被调用了,屏幕就一定会变——渲染只是”算一遍”,算出来和上次一样就啥也不改。