返回🔙

重回金山村

重回金山堡(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”和”独立的事件循环调用栈”的意思。

setTimeoutsetIntervalrequestAnimationFrameaddEventListener 加的原生事件处理器,全都是同一个道理——回调由浏览器调度,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更新)

所以原文那两句话翻译成大白话就是:

  1. 别以为你调一次 setState,React 就会老老实实渲染一次——可能不渲染(值没变)、可能合并渲染(批处理)、也可能拆开渲染(并发)。
  2. 别以为组件函数被调用了,屏幕就一定会变——渲染只是”算一遍”,算出来和上次一样就啥也不改。