React 是如何渲染的
"渲染"是 React 中使用最频繁却最少被深入讨论的词汇。理解状态更新和屏幕像素变化之间究竟发生了什么,能够解答很多疑惑:为什么组件会在某些时刻运行,为什么大多数重新渲染的成本微乎其微,以及真正的性能优化应该从何处入手。这一章会建立这样的心智模型。接下来的两章,记忆化和代码分割,会把它付诸实践。
渲染就是函数调用
当 React "渲染" 一个组件时,它会调用你的组件函数:
function Counter() {
const [count, setCount] = useState(0)
console.log('Counter rendered') // 每次 React 调用这个函数都会运行
return <button onClick={() => setCount(c => c + 1)}>{count}</button>
}就是这么简单。函数从上到下执行,其中的所有计算重新运行一遍,然后返回描述 UI 此时应该长什么样的 JSX。到此为止,浏览器的 DOM 还没有任何涉及。渲染只是产生一个描述,它不会改动页面。
这个区分很重要,因为这两件事的成本差异巨大。调用一个 JavaScript 函数并构建描述很快。改动真实的 DOM,这会在浏览器中触发布局和绘制工作,才是代价高昂的部分。React 的整个设计都围绕一个原则:尽情做廉价的事,把昂贵的事降到最少。
三个步骤
一次"重新渲染"会经历三个步骤。通常触发器是状态更新,不过初始挂载、父组件的重新渲染以及新的 context 值也会运行同样的机制。
- 渲染。 React 调用状态改变的那个组件函数,然后递归地调用它返回的每个组件。结果是一个新的虚拟 DOM:对 UI 那部分的轻量级内存中描述。
- 协调。 React 运行一个 diff 算法,将新的虚拟 DOM 与之前的进行比较,精确计算出两者之间的差异。
- 提交。 这些差异,仅仅是差异部分,被应用到真实的 DOM。这是用户能真正看到的部分。
这门课提供了一个很值得记住的比喻。一个建筑师更新一座建筑时,先重新画蓝图(渲染),然后列出新蓝图实际需要做的修改(协调),最后才让施工队接触这座建筑(提交)。重新画蓝图很快。施工才是花时间和金钱的地方,所以施工队会尽量做最少的工作。
渲染和协调保持高效,而提交的范围限制在 diff 找到的内容,这就是 React 为什么默认感觉很快。真的出现性能问题时,Profiler 会告诉你是哪一步有问题,而不是让你猜测。
点击那个 Counter 按钮每一次都会运行全部三步:日志输出,React 进行 diff,提交更新按钮的文本节点,其他部分都没动。
渲染是递归的,通常没问题
当一个组件渲染时,React 会渲染它返回的所有内容,然后渲染那些组件返回的所有内容,一直走到每个分支的底端再转向下一个分支。因此,父组件的状态改变会默认导致它的所有后代重新渲染,不管它们是否接收了改变后的状态作为 props。
这听起来很浪费,直到你再次区分这些步骤。那些子组件的渲染都是产生描述的函数调用。在协调阶段,React 发现它们上面没有改变的属性,所以提交对那个子树就没有 DOM 工作要做。用户看得见的 DOM 工作仍然范围严格。一个重新渲染的子树如果提交什么都没改,这是正常和健康的情况,对绝大多数组件来说都便宜到无法测量。
在两种情况下这就成问题了:一个组件在渲染时做了昂贵的工作,或者子树太大以至于即使廉价的渲染也会累加。这正是记忆化要解决的情况。在有证据之前,要抵住立刻使用它的冲动,这引出了测量的话题。
优化前先测量
React 已经很快了,协调已经把 DOM 更新降到最少。这门课的建议很直白:在应用任何技术修复性能问题之前,你得能测量出这个问题,还要把每一个优化都权衡在它所复杂化的代码的可读性上。没人能维护的聪明代码,那个成本也很高。
两个工具能满足大部分测量需求:
- 浏览器的 Performance 标签页。 记录一个会话,执行那个慢交互,停止记录,然后检查运行了什么和花了多长时间。它的 CPU 节流(试试 4 倍或 6 倍的减速)和网络节流能让你快速的开发机器模拟你用户实际使用的那些较慢的设备和连接。在你笔记本上看不见的问题会立即显现。
- React DevTools Profiler。 React 团队出品的浏览器扩展,在它自己的 Profiler 标签页。同样的记录-执行-停止流程,但输出的是 React 的形状:哪些组件渲染了,为什么渲染,每个花了多长时间,以及提交花了多长时间。对于诊断 React 应用,它通常是更直接的工具。
StrictMode:开发时的护栏
StrictMode 是一个组件,你可以用它包装你的应用(或任何子树)来开启仅限开发的检查。它是 Setup 在 main.jsx 中用来包装 <App /> 的那个包装器。
在开发中,StrictMode 会故意双调用你的组件函数,以及 React 期望纯净的其他函数:useState 和 useReducer 的初始化函数、你传给 setter 的更新函数、reducer,以及 useMemo 的计算。它会额外运行一遍每个 effect,设置一次,清理一次,再设置一次,还会重新附加 ref 回调。它也会警告使用已弃用的 API。在生产构建中它什么都不做,所以不会拖慢或双渲染你用户看到的应用。
双运行听起来像是自我破坏,直到你看到它能捕捉什么。组件函数应该是纯净的:相同的输入产生相同的输出,渲染期间没有副作用。如果渲染两次和渲染一次产生的结果不同,这个组件藏着一个 bug。
这门课用一个在渲染期间对定义在自身外的数组调用 push 的组件演示这一点;页面看起来没问题,直到任何状态改变重新运行这个函数并推入一个重复项。在 StrictMode 下,重复项在第一次加载时就出现,而修复方案(复制数组再修改,或把工作移出渲染)由此而生。
纯净性涵盖你的更新函数
同样的纯净性要求也适用于 setCount(c => c + 1) 中的更新函数,因为那个函数也会运行两次。
effect 重新运行测试从另一个角度测试了同样的约定。Effects 拥有清理规则,并展示在 StrictMode 下一个缺失的规则会是什么样子,当一个未清理的 setInterval 会让你计数翻倍并在每次挂载时泄漏一个计时器。
在开发中看到更多 bug 起初感觉很奇怪,但那正是全部要点。每个 StrictMode 暴露的 bug 都是已经存在的,否则会等到生产环境才显露。
版本说明
effect 行为在 React 18 中到来:在挂载时,StrictMode 运行每个 effect 的设置,然后清理,再设置一次。较旧的文章描述 StrictMode 只是双渲染组件。这门课把包装器写成 React.StrictMode;使用具名导入时是 <StrictMode>。
在你构建时用 StrictMode 包装你的应用:它在开发中故意把东西运行两次来帮助你提早发现 bug,而且它在完成的应用中会关闭自己。
接下来:记忆化,引用相等性决定了 React 能跳过什么。

