Re-rendering - The React Way ⚛️

State-based UI: How React works #

作为一个大名鼎鼎的现代前端框架,React 的本质是一个用于生成 UI 视图的 JavaScript 库。在 React 诞生的那个时代,它的先进性主要体现在数据驱动视图的能力上。

这里有一个非常简单的 React 组件示例:

import { useState } from 'react';

function App() {
  const [count, setCount] = useState(0);

  function handleClick() {
    setCount((c) => c + 1);
  }

  return (
    <div>
      <div>{count}</div>
      <button onClick={handleClick}>Next</button>
    </div>
  );
}

在这个示例中,App 函数返回的那个长得像 HTML 的代码片段(本质上还是 JS 代码,是一种语法糖,被称为 JSX)就是我们需要展示的视图,而 count 就是用于驱动视图的数据,在 React 中叫做 state。当我们把 state 绑定在视图上时,state 的改变会直接带动视图一起改变,从而使页面上的数据始终是最新的。

Re-rendering #

在使用者的视角里,页面会随着 state 的更新而更新,看起来好像 state 变量真的是被捆绑在了页面上。但实际上它的实现原理相当简单:每当 state 变化时,重新解析 JSX 表达式,生成整个视图。尽管视图的其它部分不需要变化,它们还是会被重新生成一次,只是结果和之前一模一样,而绑定的的变量会取得最新的值,因此看上去就好像只有变量重新渲染了。

在 React 中,这个 state 变化引起视图重新渲染的过程就是 React 的一次 re-render(重新渲染)。任何一个 React 应用都是基于这个简单的原则来实现数据单向绑定到视图上的。

Re-render 的触发条件 #

当下列的任一条件满足时,React 会立刻对当前组件进行一次 re-render:

  • 当前组件的 state 改变时。
  • 当前组件的父组件 re-render 时。
  • React Context 改变时。
  • React Hook 改变时。
💡

值得注意的是,在很多人的印象里,父组件传给子组件的 props 改变时,子组件也会 re-render,所以 props 改变也是当前组件 re-render 的条件之一。但是实际上我们容易忽略父组件传给子组件的 props 对于父组件来说是自己的 state,而当父组件的 state 改变时,父组件会立刻 re-render,而当父组件 re-render 时,子组件会随之无条件 re-render。

所以当 props 改变时,组件确实会 re-render,但不是因为 props 改变,而是因为父组件 re-render 了。

Concurrent Mode #

上面所讲的 re-render 机制是 React 哲学的基石,但是事情远远没有这么简单。如果我们按照前文所讲的思路去实现一个自制的 React,就会遇到一系列问题,比如:

  • 如何把 re-render 的触发条件落地到代码上?我们希望组件在 state 改变时 re-render 一次,但是我们如何监测 state 的改变?
  • 如何实现一次 re-render?
  • 如何考虑潜在的性能问题?

实际上,React 从 v16 到 v18,都一直在致力于提升这一套 re-render 机制的方方面面,并且在 React 18 最终推出了 Concurrent Rendering。

解决的问题 #

在页面元素很多,且需要频繁 re-render 的场景下,React 15 会出现掉帧的现象。其根本原因是大量的同步计算任务阻塞了浏览器的 UI 渲染。JS 运算、页面布局和绘制都是运行在浏览器的主线程当中,他们之间是互斥的。如果 JS 运算持续占用主线程,页面就没法得到及时的更新。当我们更新 state 触发 re-render 时,React 会遍历应用的所有节点,计算出差异,然后再更新 UI。更新一旦开始,中途就无法中断,直到遍历完整棵树,才能释放主线程。如果页面元素很多,整个过程占用的时机就可能超过 16ms,从而无法满足每秒 60 帧的刷新率,造成浏览器卡顿。

囿于 React 15 中同步渲染的性能缺点,在后面的版本中,React 推出了 Concurrent Mode(并发渲染模式),Concurrent Mode 的核心在于,它把 React 的每一次 re-render 都变成了异步、有优先级、可中断的。

<strong>架构</strong>演进 #

  1. React 15 时期还没有 Concurrent 的概念。它主要由 Reconciler 和 Renderer 两部分组成:Reconciler 负责生成虚拟 DOM 并进行 diff,找出变动的虚拟 DOM,然后 Renderer 负责将变化的组件渲染到不同的宿主环境中。
  2. React 16 的架构改动较大,多了一层 Scheduler,并且 Reconciler 的部分基于 Fiber 完成了重构。
  3. React 17 相较先前并没有在架构上有大的改动,它是一个用以稳定 Concurrent Mode 的过渡版本,另外,它使用 Lanes 重构了优先级算法。
  4. React 18 实现了更灵活的 Concurrent Rendering,提供了默认开启并发渲染的应用挂载方式,对异步的状态更新支持了批处理,同时还提供了几个可以让开发者手动设置优先级的 API。

核心实现 #

底层数据结构 - Fiber #

Fiber 是 React 团队设计的一种数据结构,React 源码中的定义在 这里。简单来讲,它的主要结构如下:

{
    ...
    stateNode, // 一般为 ReactComponent 的实例或者 DOM 元素
    child,     // 子 Fiber 节点
    sibling,   // 同层级的兄弟 Fiber 节点
    return,    // 指向父节点
    alternate, // 连接 Current Fiber 树和 WorkInProgress Fiber 树
    ...
}
🌲

ReactElement,Fiber,DOM 三者的关系:

  • ReactElement:所有采用 JSX 语法编写的节点都会被转译,最终会以 React.createElement(...) 的方式,创建出来一个与之对应的 ReactElement 对象。
  • Fiber:Fiber 节点是基于 ReactElement 对象创建的,多个 Fiber 节点构成了一棵 Fiber 树,Fiber 树是构造 DOM 树的数据模型,Fiber 树的任何改动,最后都体现到 DOM 树上。
  • DOM:将文档解析为一个由节点和对象(包含属性和方法的对象)组成的结构集合,也就是常说的 DOM 树。JS 可以访问和操作存储在 DOM 中的内容,也就是操作 DOM 对象,进而触发 UI 渲染。

开发人员通过编程只能控制 ReactElement 树的结构,ReactElement 树驱动 Fiber 树,Fiber 树再驱动 DOM 树,最后体现到页面上。所以 Fiber 树的构造过程,核心就是 ReactElement 对象到 Fiber 对象的转换过程,此处不做过多展开。

中断的原理 #

双缓存 #

React 应用中最多同时存在两棵 Fiber 树。当前屏幕上显示内容对应的 Fiber 树叫做 Current Fiber,正在内存中构建的 Fiber 树叫做 WorkInProgress Fiber,他们通过 alternate 属性相互连接。当 WorkInProgress Fiber 树构建好了以后,只需要切换一下 current 指针的指向,这两棵树的身份就会完成互换。

在这种双缓存的机制下,我们可以随时暂停或放弃对 WorkInProgress Fiber 树的修改,这就使得 React 更新的中断成为了可能。

时间分片 #

将整次 re-render 阶段的长任务拆分成多个小任务:

  • 每个任务执行的时间控制在 5ms。(由 Scheduler 的 shouldYield() 方法实现)
  • 把每一帧 5ms 内未执行的任务分配到后面的帧中。
  • 给任务划分优先级,优先执行高优任务。

Scheduler 的 <code>shouldYield()</code> 方法 #

React Scheduler 中提供了一个 shouldYield() 方法,在 re-render 进行时,可以通过它的返回值来控制是否中断当前的渲染流程。以下是 Sync Mode 和 Concurrent Mode 相应的 React 源码对比:

// Sync Mode,即 React 原本的不可中断的更新模式

function workLoopSync() {
  // Already timed out, so perform work without checking if we need to yield.

  while (workInProgress !== null) {
    performUnitOfWork(workInProgress);
  }
}

// Concurrent Mode

function workLoopConcurrent() {
  // Perform work until Scheduler asks us to yield

  while (workInProgress !== null && !shouldYield()) {
    performUnitOfWork(workInProgress);
  }
}

shouldYield() 方法通过综合判断已消耗的时间(是否超过 5ms)、是否有用户输入等高优事件来决定要不要中断遍历,给浏览器渲染和处理其它任务的时间,避免页面卡顿。

优先级控制 #

React 应用中的多个更新任务之间存在明确的优先级区分,React 内部预设了各种情况下的优先级,并且使用 Lanes 的数据结构来记录。

不同的 Lanes 可以简单理解为不同的数值,数值越小,表明优先级越高。比如用户的输入事件比较紧急,那么可以对应比较高的优先级如 SyncLane;UI 界面过渡的更新不那么紧急,可以对应比较低的优先级如 TransitionLane;网络加载的更新也不那么紧急,可以对应低优先级 RetryLane。

除了框架内置的优先级体系外,React 18 也开放了让开发者手动指定更新任务高低优先级的 API,如 startTransition 等,用法大致如下:

import { startTransition } from "react";

// Urgent, user input event
setSliderValue(input);

// Mark any state updates inside as transitions
startTransition(() => {
  // Transition: Show the results, non-urgent
  setGraphValue(input);
});

React 开发者的心智负担 #

在前端三大框架(Angular、Vue、React)中,React 的开发体验常常被形容成是在开一辆手动挡的汽车,开发者需要承受更重的心智负担。

因为 React 框架本身是一个偏向运行时的框架,它不会对应用的性能做优化处理,几乎所有的优化工作都落在了开发者的身上。如果一个 React 开发者没有系统地学习过 React 的优化方法,那么随着开发的应用体积越来越大,性能问题会显著地影响使用体验。更有甚者,如果使用了错误的优化方式,反而会造成各种意想不到的 bug。

举一个简单的例子,下面的 React app 中总共有三个组件,ParentChildAChildB,它们之间的关系正如名字里写的那样,一个父组件里面包含了两个同级的子组件。父组件有一个 state 叫 count,在点击按钮时数值会累加,并且父组件将 count 只传给了 ChildB 组件。

此时我们点击按钮,会发现两个子组件都触发了 re-render(当组件 re-render 时,它会有一层绿色的提示)。这两个子组件 re-render 的原因是父组件 re-render 了,而父组件 re-render 的原因是它的 state count 改变了。这完美符合 React 设计的 re-render 机制,但是我们会发现 ChildA 的 re-render 其实是毫无必要的,因为它完全不依赖父组件里的任何值,在重新渲染后也完全没有任何变化,每次点击按钮都要额外渲染一次 ChildA 是对浏览器资源的无谓消耗。

对于这种情况,“手动挡”的 React 框架为用户提供了 React.memo 方法,用于在没有 props 改变时,跳过子组件的无谓渲染。

我们从 React 包中引入 memo 方法,用它把 ChildA 组件包裹起来:

const ChildA = memo(() => {
  return (
    <div className="a" key={Math.random()}>
      Child-A Component
      <div>Pure</div>
    </div>
  );
});

此时,再点击父组件的按钮,可以看到,子组件 A 已经不再 re-render 了。

在“手动挡”的 React 中,这只是一个很简单的例子,除了 React.memo 以外,React 还提供了 useMemouseCallback 等一系列用于性能优化的手段(原理基本上都是利用缓存来减少无谓的 re-render)。

对于开发者而言,如果不用这些优化手段,当然不会影响程序的正常运行,只是应用的性能一定跑不过其它几个框架。如果用了这些优化手段,那么就会有极重的心智负担,因为这种对于数据的缓存,是需要写对依赖的变量的,如果少写了关键变量,极有可能导致“过度缓存”,该更新数据的时候没有及时更新,那就是 bug 了。

哪怕只是一个小小的 useEffect,Dan 都花了超长的篇幅来教开发者如何正确使用:

“手动挡汽车”的比喻真的很适合 React,它很强大、很灵活、很自由,但是对驾驶者的要求比“自动挡”的编译型框架要高很多。

React Compiler #

好消息是,在 2024.5.15 的 React Conf 上,React 团队发布了 React Compiler。

React Compiler 首次出现在三年前的 React Conf 2021,当时它还叫 React Forget,由 React 团队的黄玄负责。后来黄玄离开了 React 团队,就在大家都怀疑 React 是不是已经把这个项目忘记(Forget)的时候,它带着新的名字归来了。

React Compiler 能做什么?

In order to optimize applications, React Compiler automatically memoizes your code. You may be familiar today with memoization through APIs such as useMemo, useCallback, and React.memo. With these APIs you can tell React that certain parts of your application don’t need to recompute if their inputs haven’t changed, reducing work on updates. While powerful, it’s easy to forget to apply memoization or apply them incorrectly. This can lead to inefficient updates as React has to check parts of your UI that don’t have any meaningful changes. The compiler uses its knowledge of JavaScript and React’s rules to automatically memoize values or groups of values within your components and hooks. If it detects breakages of the rules, it will automatically skip over just those components or hooks, and continue safely compiling other code.

从官网的介绍来看,React Compiler 就是给“手动挡汽车”打了一个“辅助驾驶补丁”,在它的帮助下,用户不用再花时间思考怎么用 useMemouseCallback 等方法缓存数据了。开发者只需要正常写好业务代码,React Compiler 会在幕后完成性能优化的部分,就像其它框架那样。

用法 #

React Compiler 并不是万能的,它不能保证在任何情况下都能正常发挥作用,在项目根目录下执行如下指令可以检测代码库是否能正常使用 React Compiler:

npx react-compiler-healthcheck

在检测正常以后,安装它的 babel 插件:

npm i babel-plugin-react-compiler

修改配置(以 Vite 为例)如下:

export default defineConfig(() => {
  return {
    plugins: [
      react({
        babel: {
          plugins: [
            ["babel-plugin-react-compiler", ReactCompilerConfig],
          ],
        },
      }),
    ],
    _// ..._
  };
});

官网有更详细的介绍和说明:

原理 #

React Compiler 会自动缓存组件的 props 或者 state,当 props 或 state 没有改变时,直接返回上一次渲染的结果,而不是额外再渲染一次。

假设现在有一个 Counter 组件:

function Counter() {
  const [count, setCount] = useState(1);
 
  return (
    <div>
      <p>{count}</p>
    </div>
  );
}

React Compiler 会把它编译成:

function Counter() {
  const $ = _c(2);

  const [count] = useState(1);
  let t0;

  if ($[0] !== count) {
    t0 = (
      <div>
        <p>{count}</p>
      </div>
    );
    $[0] = count;
    $[1] = t0;
  } else {
    t0 = $[1];
  }

  return t0;
}

其中 _c 这个函数就是一个叫 useMemoCache 的 hook,传进去 2 表示这个组件共有 2 个需要缓存的表达式。这个 hook 的背后会生成一个长度为 2 的数组,用于存储组件中的表达式计算值,同时在初始化时会给每一个位置赋予 Symbol.for("react.memo_cache_sentinel") 的初值,方便数组初始化。

在这个例子中,$[0] 存储的是 count 的值,$[1] 存储的是 JSX 表达式 <div><p>{count}</p></div>

在第 7 行可以看到,只有 count 这个 state 改变的时候(或者第一次执行的时候)才会重新解析 JSX 表达式,生成新的值,否则就返回上一次缓存的值。

因此,React Compiler 编译后给组件套上了一层缓存,当组件的 props、state 等条件没有发生改变的时候,直接使用缓存的表达式计算值,从而有效节省了计算表达式(比如对 JSX 进行解析)的时间。