Skip to content
This page has been auto-translated and may contain errors.View in English

React 是如何渲染的

"渲染"是 React 中使用最频繁却最少被深入讨论的词汇。理解状态更新和屏幕像素变化之间究竟发生了什么,能够解答很多疑惑:为什么组件会在某些时刻运行,为什么大多数重新渲染的成本微乎其微,以及真正的性能优化应该从何处入手。这一章会建立这样的心智模型。接下来的两章,记忆化代码分割,会把它付诸实践。

渲染就是函数调用

当 React "渲染" 一个组件时,它会调用你的组件函数:

jsx
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 值也会运行同样的机制。

  1. 渲染。 React 调用状态改变的那个组件函数,然后递归地调用它返回的每个组件。结果是一个新的虚拟 DOM:对 UI 那部分的轻量级内存中描述。
  2. 协调 React 运行一个 diff 算法,将新的虚拟 DOM 与之前的进行比较,精确计算出两者之间的差异。
  3. 提交。 这些差异,仅仅是差异部分,被应用到真实的 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 是一个组件,你可以用它包装你的应用(或任何子树)来开启仅限开发的检查。它是 Setupmain.jsx 中用来包装 <App /> 的那个包装器。

在开发中,StrictMode 会故意双调用你的组件函数,以及 React 期望纯净的其他函数:useStateuseReducer 的初始化函数、你传给 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>

diff 步骤比"比较两个树"暗示的要廉价得多,因为 React 拒绝做一个完整的树比较,在通用情况下那会是 O(n³)。启发式方法是:不同类型的元素会完全拆掉旧子树并重建,相同类型的元素会保留且仅对改变的属性打补丁,列表协调由 keys 引导,这就是为什么 keys 必须是稳定的身份。

React 在指导工作的比较中依靠 Object.is:当你把状态设为相同的值时跳过更新,检查依赖数组,以及 memo 的浅层 props 比较。两个深度相等但身份不同的对象在这三种情况中都会失败。这条线索贯穿下一章:父组件传递 {} 或内联箭头函数会在每次渲染时创建一个新身份,默认情况下无害,但一旦你添加任何基于身份的优化,它就会击败。

React 自己的文档把前两个步骤分组为一个单一的渲染阶段:diff 发生在 React 逐个元素地遍历树时,所以没有什么要等待一个完整描述先被构建。一个跳过因此可以停在一个分支的中途。把协调分出来是一个教学便利,用来描述 diff 做什么,而二阶段的渲染/提交模型是 Profiler 和 React 源代码使用的。

那个渲染阶段也根据设计被允许被丢弃。React 可能开始渲染,丢弃工作,在提交前重新渲染,并发特性依靠这个来保持主线程响应。

这是渲染必须纯净的更深层原因:React 只保证 提交的 工作恰好发生一次。任何组件在渲染阶段做的事(mutations、订阅、你依赖的日志)可能发生零次、一次或多次。StrictMode 的双调用是对开发中这个现实的廉价模拟,在它下面破裂的代码是并发渲染能真正破裂的代码。

当 Profiler 确实显示一个真实问题时,把工具匹配到步骤。一个组件上的长渲染条做着重计算指向 useMemo;一个宽子树重新渲染但没有任何提交指向 memo;两者都在 Memoization 中。一个慢的初始加载,问题在于 JavaScript 在任何渲染能开始前到达,这是一个 bundle 问题而不是渲染问题,而 Code splitting 是杠杆。

有时修复是结构性的:把状态下移到使用它的组件,或把昂贵的子树作为 children 传递,这样有状态的父组件重新渲染时看到相同的元素对象并跳过那个分支,完全不需要任何记忆化就移除了渲染。

Juno渲染廉价,提交精确 渲染意味着 React 再次调用你的组件函数来询问屏幕应该长什么样。它把新答案和旧答案做比较,只更新页面上实际改变的部分。当一个父组件渲染时,所有子组件也会渲染,这是正常的,而且几乎总是很快的。

在你构建时用 StrictMode 包装你的应用:它在开发中故意把东西运行两次来帮助你提早发现 bug,而且它在完成的应用中会关闭自己。

Juno渲染廉价,提交精确 三个步骤:渲染调用你的组件函数并构建一个新虚拟 DOM,协调把它和旧的 diff,提交只对真实 DOM 应用差异。父组件渲染默认级联到子组件,而且因为大多数那些渲染提交什么都没有,它们几乎什么都不花。

优化任何东西之前,在 React DevTools Profiler 中记录那个慢交互,并把你的 CPU 节流下来看看用户看到的。

保持 StrictMode 打开:双调用的渲染暴露不纯的组件,重新运行的 effect 暴露缺失的清理,比如一个未清理的 setInterval

Juno渲染廉价,提交精确 协调是启发式的:类型改变重建子树,相同类型的元素原地打补丁,keys 驱动列表 diff,而指导工作的比较(状态跳过、依赖数组、memo)运行在 Object.is 上,所以新对象和函数身份读作改变。

渲染阶段工作根据约定是可丢弃的,这就是为什么纯净性很重要,也是为什么 StrictMode 模拟并发渲染能产生的双执行。

先 profile,然后把修复匹配到阶段:记忆化昂贵的渲染,为慢加载分割 bundle,或用 children 重组,这样工作根本不会发生。

接下来:记忆化,引用相等性决定了 React 能跳过什么。