🌑

Mocha's Blog

目录
  1. 始作俑者 - React 的更新机制
  2. 解决方案 - React Compiler
    1. 优化原理
      1. 非状态变量
      2. 状态变量
      3. 函数优化
      4. 子组件优化
      5. 循环优化
    2. 注意事项
    3. 拓展知识
      1. useMemoCache 源代码
      2. Symbol.for
  3. 如何体验
  4. 总结

一文读懂 React Compiler

发布时间:2024年10月7日

在 React@19 中,除了显式推出的新的 use hook,在编译时也推出了重磅更新 —— React Compiler。
React@16.8 正式推出 hooks 以来,关于与性能相关的 useCallback/memo/useMemo是否应该使用、在何时使用的争议一直延续至今,社区中的文章也是层出不穷,相较于 Vue@3 中对于组合式 API 的无感使用,hooks 使用的成本确实较高,对于官方层面的性能优化的呼声不绝于耳。
在 React Conf 2021 上,黄玄以 OneMoreThing 的形式向开发者介绍了 React Forget —— 一个 React 团队对编译时优化的尝试,演示中展示的无需 memo 的未来令 React 开发者心潮澎湃。 但 React Forget 的进展似乎永远停留在了 2021 年,直到 React Conf 2024 上,官方终于以 React Compiler 的名称正式发布了这个功能。

始作俑者 - React 的更新机制

在 React 中,任何一个组件发生状态的变更时,React 的更新机制都会从按照深度优先的思想从最顶层的根节点开始往下对比,通过双缓存机制判断出哪些节点发生了变化,然后更新节点。
由于一个组件的状态实际上是由 state、props、context 合并构成的,开发者如果未对组件做刻意的性能优化,再加上 React 的浅比较原则({} !== {}),在这个对比的过程中会生产大量冗余的 re-render,从而导致项目的性能出现问题。
为了避免项目性能出现问题,React 开发者需要熟练运用各类手段对项目代码进行优化,包括但不限于:

  1. 使用 useMemo 包裹子组件以防止父组件更新导致的子组件更新
  2. 使用 memo 包裹组件自身以防止 props 浅比较导致的重复更新
  3. 使用 useCallback 包裹回调函数防止函数重新创建导致的重复更新
  4. []{} 声明为常量以防止相关组件的重复更新

另外还有一些弊端:

  1. 函数式组件每次 re-render 会重新执行所有的代码,组件无法维持普通变量的状态,导致 state 的泛滥
  2. 性能优化带来的闭包管理,部分场景下还会出现闭包变量的相互依赖,管理混乱

各种各样的问题与对应的解决手段,对 React 开发者的能力提出了较高的要求,导致社区中一直存在的对 React 项目上手和维护成本高的讨伐。

解决方案 - React Compiler

既然手动做性能优化的场景这么多且复杂,那么是不是可以不做手动优化呢?于是就有了 React Compiler,它的目标就是让你忘记性能优化 —— forget memo。
React 官网中是这么介绍它的

它是一个仅在构建时使用的工具,可以自动优化你的 React 应用程序。它可以与纯 JavaScript 一起使用,并且了解 React 规则,因此你无需重写任何代码即可使用它。
React Compiler 利用其对 JavaScript 和 React 规则的了解,自动对组件和钩子中的值或值组进行记忆化。如果它检测到规则的破坏,它将自动跳过那些组件或钩子,并继续安全地编译其他代码。

优化原理

与 Vue@3 提供的 Playground 类似,React 也在新的官网提供了 React Playground 功能,让开发者可以快速的看到自己编写的代码被编译优化后的结果
我们以一个最基本的 Hello World 看一下优化后的代码结构。

// app.jsx
export default function MyApp() {
  return <p>hello world</p>
}
// compiler.js
function MyApp() {
  const $ = _c(1);
  let t0;
  if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
    t0 = <p>hello world</p>;
    $[0] = t0;
  } else {
    t0 = $[0];
  }
  return t0;
}

在编译后的 compiler.js 文件中,有几个关键点:

  • _c(1)
  • $[0] === Symbol.for("react.memo_cache_sentinel")

下面我给这些代码加上注释后,我们一起看一下

// compiler-with-comment.js
function MyApp() {
  /**
   * _c 是 Complier 中新增的 useMemoCache hook 压缩后的名称,
   * useMemoCache 是 Compiler 实现性能优化的关键
   * Compiler 会自动检索代码中可以被性能优化的内容并统计其数量
   * 通过 useMemoCache(length) 的方式实现缓存的初始化
   * 每个节点的初始化内容均为 Symbol.for("react.memo_cache_sentinel")
   */
  const $ = _c(1);

  /**
   * 定义组件 return 内容
   * return 的内容是默认缓存的
   */
  let t0;

  /**
   * 判断第 1 个被优化节点的内容是否为初始化的值
   * 条件的 true/false 表明该节点有没有被赋值为具体的渲染内容
   */
  if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
    /**
     * 没有被赋值过,给节点赋值
     */
    t0 = <p>hello world</p>;
    $[0] = t0;
  } else {
    /**
     * 已经赋过具体的值,直接复用
     */
    t0 = $[0];
  }

  return t0;
}

这里的优化逻辑与 Vue@3 中给静态节点打 static tag 的逻辑基本相同

非状态变量

组件中声明的非状态变量的缓存机制:

  1. 如果为基本类型,不缓存,且代码逻辑中不能对该变量进行修改
  2. 如果为高级类型,需要缓存,可以在代码逻辑中对该变量进行修改,但不会触发组件更新
// app.jsx

export default function MyApp() {
  let num = 0;
  let str = "";
  let arr = [];
  let obj = {};

  return (
    <div>
      <p>{num}</p>
      <p>{str}</p>
      <p>{arr}</p>
      <p>{obj}</p>
    </div>
  )
}
// compiler.js
function MyApp() {
  /**
   * 声明缓存内容长度
   * 1. 基本类型 num、str 不缓存,高级类型 arr、obj 缓存,所以 +2
   * 2. 默认的 return 内容缓存
   */
  const $ = _c(3);


  /**
   * 缓存数组
   */
  let t0;
  if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
    t0 = [];
    $[0] = t0;
  } else {
    t0 = $[0];
  }
  const arr = t0;

  /**
   * 缓存对象
   */
  let t1;
  if ($[1] === Symbol.for("react.memo_cache_sentinel")) {
    t1 = {};
    $[1] = t1;
  } else {
    t1 = $[1];
  }
  const obj = t1;

  /**
   * 缓存返回内容
   */
  let t2;
  if ($[2] === Symbol.for("react.memo_cache_sentinel")) {
    t2 = (
      <div>
        <p>{0}</p>
        <p>{""}</p>
        <p>{arr}</p>
        <p>{obj}</p>
      </div>
    );
    $[2] = t2;
  } else {
    t2 = $[2];
  }
  return t2;
}

可以看到关于非状态变量相关的缓存判断条件均为 value === Symbol.for("react.memo_cache_sentinel"),说明非状态变量的变更并不会引起组件的重新渲染,哪怕我们在向数组或者对象中添加了新的内容。

无需显式声明状态的未来还很遥远。

image.png

状态变量

组件中声明的状态变量的缓存机制

  1. 如果初始化值为基本类型,直接声明,初始值不缓存,状态缓存
  2. 如果初始化值为高级类型,先缓存初始值,用缓存后的值初始化状态并缓存

下面我们看一下常见的与状态相关的优化逻辑

// app.jsx
export default function MyApp() {
  const [num,] = useState(Date.now());

  return <p>hello world, {num}</p>
}
// compiler.js
function MyApp() {
  /**
   * 检测到可以对三个场景进行缓存优化
   * 1. 初始化 useState 的变量 Date.now()
   *    因为此处时通过函数调用实现的赋值,如果赋值为基础类型,则不会被缓存
   * 2. 返回节点中使用的变量 num
   * 3. 返回的节点
   * 所以声明的缓存优化数组长度为 3
   */
  const $ = _c(3);

  /**
   * 声明 t0
   * 检测第一个被缓存内容的值是否完成赋值
   * 未赋值时赋值,已赋值时复用
   */
  let t0;
  if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
    t0 = Date.now();
    $[0] = t0;
  } else {
    t0 = $[0];
  }

  /**
   * 声明状态
   */
  const [num] = useState(t0);

  /**
   * 声明组件返回内容
   */
  let t1;

  /**
   * 判断被缓存的状态与当前状态 num 的值是否相同
   * 不相同时赋值,想同时复用
   */
  if ($[1] !== num) {
    t1 = <p>hello world, {num}</p>;
    $[1] = num;
    $[2] = t1;
  } else {
    t1 = $[2];
  }

  /**
   * 返回内容
   */
  return t1;
}

函数优化

组件中声明的函数缓存机制

  1. 静态函数,函数声明上提到组件外,不缓存
  2. 使用了组件外的变量,函数声明上提到组件外,不缓存
  3. 使用了组件内的非状态变量,函数声明不上提到组件外,不缓存函数
  4. 使用了组件内的状态变量,函数声明不上提到组件外,缓存函数
// app.jsx
export default function MyApp() {
  const [num, setNum] = useState(0)

  let time = 0;

  // 不消费变量的静态函数
  const handleClickWithoutVar = () => {
    console.log('click')
  }


  // 消费变量的函数
  const handleClickWithVar = () => {
    console.log('time', time)
  }

  // 消费 num 的自增
  const handleClickWithState = () => {
    setNum(num + 1)
  }


  // 不消费 num 的自增
  const handleClickWithoutState = () => {
    setNum(pre => pre + 1)
  }

  return (
    <div>
      <p onClick={handleClickWithoutVar}>hello world</p>
      <p onClick={handleClickWithVar}>{time}</p>
      <p onClick={handleClickWithState}>
        依赖 num
      </p>
      <p onClick={handleClickWithoutState}>
        不依赖 num
      </p>
    </div>
  )
}
// compiler.js
/**
 * 静态函数声明在组件外部
 */
function _temp() {
  console.log("click");
}

function MyApp() {
  /**
   * 声明需要缓存优化的内容长度为 11
   */
  const $ = _c(11);

  /**
   * 声明状态
   */
  const [num, setNum] = useState(0);

  /**
   * 直接引用静态函数
   */
  const handleClickWithoutVar = _temp;

  /**
   * 消费不变变量(time永远为0)的函数的缓存
   */
  let t0;
  if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
    t0 = () => {
      console.log("time", 0);
    };
    $[0] = t0;
  } else {
    t0 = $[0];
  }
  const handleClickWithVar = t0;

  /**
   * 消费状态当前值的函数的缓存
   */
  let t1;
  if ($[1] !== num) {
    t1 = () => {
      setNum(num + 1);
    };
    $[1] = num;
    $[2] = t1;
  } else {
    t1 = $[2];
  }
  const handleClickWithState = t1;

  /**
   * 不消费状态当前值的函数的缓存
   */
  let t2;
  if ($[3] === Symbol.for("react.memo_cache_sentinel")) {
    t2 = () => {
      setNum((pre) => pre + 1);
    };
    $[3] = t2;
  } else {
    t2 = $[3];
  }
  const handleClickWithoutState = t2;

  /**
   * handleClickWithoutVar、handleClickWithVar 函数要么不消费变量要么消费的是不变的变量
   * 所以这里对两个节点的缓存的判断也是最基础的
   */
  let t3;
  let t4;
  if ($[4] === Symbol.for("react.memo_cache_sentinel")) {
    t3 = <p onClick={handleClickWithoutVar}>hello world</p>;
    t4 = <p onClick={handleClickWithVar}>{0}</p>;
    $[4] = t3;
    $[5] = t4;
  } else {
    t3 = $[4];
    t4 = $[5];
  }

  /**
   * handleClickWithoutVar 函数内容受 num 的值影响
   * 所以这里对节点的缓存的判断与 handleClickWithState 有关
   */
  let t5;
  if ($[6] !== handleClickWithState) {
    t5 = <p onClick={handleClickWithState}>依赖 num</p>;
    $[6] = handleClickWithState;
    $[7] = t5;
  } else {
    t5 = $[7];
  }


  /**
   * handleClickWithoutState 函数不受 num 的值影响
   * 所以这里对两个节点的缓存的判断也是最基础的
   */
  let t6;
  if ($[8] === Symbol.for("react.memo_cache_sentinel")) {
    t6 = <p onClick={handleClickWithoutState}>不依赖 num</p>;
    $[8] = t6;
  } else {
    t6 = $[8];
  }

  /**
   * 缓存组件渲染内容
   * 因为 <p onClick={handleClickWithState}>依赖 num</p> 节点可能存在更新
   * 所以此处的判断条件需要判断上述节点也就是 t5 有没有发生变动
   * 有的话需要重新缓存 没有的话复用
   */
  let t7;
  if ($[9] !== t5) {
    t7 = (
      <div>
        {t3}
        {t4}
        {t5}
        {t6}
      </div>
    );
    $[9] = t5;
    $[10] = t7;
  } else {
    t7 = $[10];
  }
  return t7;
}

子组件优化

组件中使用的子组件的缓存机制:

  1. 无 props,缓存组件
  2. 子组件 props 不包含动态变量(父组件的props & state),不缓存 props,缓存组件
  3. 子组件props 包含动态变量(父组件的props & state),缓存相关变量,缓存组件
// app.jsx
export default function MyApp() {
  const [dymArray] = useState([]);
  const [dymStr] = useState('');

  return (
    <div>
      <Child />
      <Child arr={[]} />
      <Child num={1} />
      <Child str={''} />
      <Child dymArray={dymArray} />
      <Child dymArray={dymArray} dymStr={dymStr} />
    </div>
  )
}
// compiler.js
function MyApp() {
  /**
   * 声明缓存优化内容长度
   */
  const $ = _c(13);

  /**
   * 缓存初始化状态的非基础类型变量
   */
  let t0;
  if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
    t0 = [];
    $[0] = t0;
  } else {
    t0 = $[0];
  }


  /**
   * 声明状态
   */
  const [dymArray] = useState(t0);
  const [dymStr] = useState("");


  /**
   * 缓存无 props 的子组件
   */
  let t1;
  if ($[1] === Symbol.for("react.memo_cache_sentinel")) {
    t1 = <Child />;
    $[1] = t1;
  } else {
    t1 = $[1];
  }

  /**
   * 缓存 props 为不变非基础类型变量子组件
   * 因为接下来相邻的组件的 props 值为不变的基础类型变量
   * 所以在此处一并缓存
   */
  let t2;
  let t3;
  let t4;
  if ($[2] === Symbol.for("react.memo_cache_sentinel")) {
    t2 = <Child arr={[]} />;
    t3 = <Child num={1} />;
    t4 = <Child str="" />;
    $[2] = t2;
    $[3] = t3;
    $[4] = t4;
  } else {
    t2 = $[2];
    t3 = $[3];
    t4 = $[4];
  }


  /**
   * 缓存 props 为动态变量的子组件
   * 此时不区分变量是基础类型还是高级类型
   */
  let t5;
  if ($[5] !== dymArray) {
    t5 = <Child dymArray={dymArray} />;
    $[5] = dymArray;
    $[6] = t5;
  } else {
    t5 = $[6];
  }


  /**
   * 同上
   */
  let t6;
  if ($[7] !== dymArray || $[8] !== dymStr) {
    t6 = <Child dymArray={dymArray} dymStr={dymStr} />;
    $[7] = dymArray;
    $[8] = dymStr;
    $[9] = t6;
  } else {
    t6 = $[9];
  }


  /**
   * 缓存组件渲染内容
   */
  let t7;
  if ($[10] !== t5 || $[11] !== t6) {
    t7 = (
      <div>
        {t1}
        {t2}
        {t3}
        {t4}
        {t5}
        {t6}
      </div>
    );
    $[10] = t5;
    $[11] = t6;
    $[12] = t7;
  } else {
    t7 = $[12];
  }
  return t7;
}

循环优化

组件中循环逻辑的缓存机制:

  1. 不可变数组(固定长度和内容的数组)产生的循环,变量不缓存,循环结果缓存
  2. 动态数组产生的循环,数组缓存,循环结果缓存
// app.jsx
export default function MyApp({ arr }) {
  return (
    <div>
      // 不可变数组的循环
      {
        ["hello", "world"].map(
          (str) => <span key={str}>constant:{str}</span>
        )
      }
      // 依赖 props 的循环
      {
        arr.map(
          (str) => <span key={str}>props:{str}</span>
        )
      }
    </div>
  )
}
// compiler.js
// 声明两个场景下的循环函数
function _temp(str) {
  return <span key={str}>constant:{str}</span>;
}
function _temp2(str_0) {
  return <span key={str_0}>props:{str_0}</span>;
}


function MyApp(t0) {
  /**
   * 声明缓存优化内容长度
   */
  const $ = _c(5);

  const { arr } = t0;

  /**
   * 不可变数组循环的优化
   * 使用最基础的条件判断即可
   */
  let t1;
  if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
    t1 = ["hello", "world"].map(_temp);
    $[0] = t1;
  } else {
    t1 = $[0];
  }

  /**
   * 可变数组循环的优化
   * 需要判断前后使用的数组是否发生变动,浅比较
   */
  let t2;
  if ($[1] !== arr) {
    t2 = arr.map(_temp2);
    $[1] = arr;
    $[2] = t2;
  } else {
    t2 = $[2];
  }

  /**
   * 缓存组件内容
   * 需要判断可变数组的生成的节点是否发生了变化
   */
  let t3;
  if ($[3] !== t2) {
    t3 = (
      <div>
        {t1}
        {t2}
      </div>
    );
    $[3] = t2;
    $[4] = t3;
  } else {
    t3 = $[4];
  }
  return t3;
}

在实际业务中,循环基本都是依赖于可变数组的,这个场景下 Compiler 的收益不高

注意事项

  • Compiler 不是万能的,有一些内容是不会优化的
    • 开发者手动优化的,像 useCallback、useMemo、useDeferredValue、useTransition
    • ref 相关钩子,如 useImperativeHandle、useRef
    • 状态管理相关的,如 useState、 useContext、useReducer
    • 副作用相关,如 useEffect、useLayoutEffect
  • Compiler 的优化规则不是万能的
    • 可能导致功能出错
    • 不能覆盖所有的业务场景,极端情况下还需要开发者手动优化

拓展知识

useMemoCache 源代码

react-compiler-runtime

const $empty = Symbol.for('react.memo_cache_sentinel');

export const c =
  // @ts-expect-error
  typeof React.__COMPILER_RUNTIME?.c === 'function'
    ? // @ts-expect-error
      React.__COMPILER_RUNTIME.c
    : function c(size: number) {
        return React.useMemo<Array<unknown>>(() => {
          /**
           * 按照传入的 size 生成对应长度的缓存数组
           */
          const $ = new Array(size);

          /**
           * 对数组内容进行初始化,值为 Symbol.for('react.memo_cache_sentinel')
           */
          for (let ii = 0; ii < size; ii++) {
            $[ii] = $empty;
          }

          /**
           * 标记组件的已优化状态,便于在 react devtools 中查看
           */
          $[$empty] = true;

          /**
           * 返回缓存的数组
           */
          return $;
        }, []);
      };

Symbol.for

快速理解

  1. 通过 Symbol 声明的符号变量,哪怕内容相同,符号变量也不相同,即 —— Symbol('memo_cache_sentinel') !== Symbol('memo_cache_sentinel')
  2. 通过Symbol.for 声明的符号变量,只要内容相同,值就相同,即 —— Symbol.for('memo_cache_sentinel') === Symbol.for('memo_cache_sentinel')

结合 useMemoCache 中的缓存初始化逻辑,使用 Symbol.for 可以快速的判断某一个内容是不是只完成了初始化并未实际进行过缓存

如何体验

前面介绍了 React Compiler 的背景和优化逻辑,目前在 React@19 的 rc 版本中已经可以使用,不过还是推荐使用 nextjs 框架来帮助我们快速启动一个项目,具体步骤如下:

  1. 运行 npx create-next-app@canary 并完成项目配置
  2. 运行 npm install babel-plugin-react-compiler@experimental -D 安装 babel 插件
  3. 在 next.config.ts 文件中配置实验性配置中的 reactCompiler

image.png

总结

React@16.8 推出以来,官方在性能优化方面的做了以下几方面的努力:

  1. 使用 Fiber 架构 & Reconciler Diff ,实现了渲染的可打断性
  2. 提供了一些新的 hook - useDeferredValue & useTransition,便于开发者手动控制一些任务的优先级

现在我们迎来了第三种场景:在编译层自动对代码进行分析 & 缓存。
希望至此以后,React 开发者可以从性能优化的深渊中走出来。

Powered By Hexo.js Hexo and Minima. Support By Oracle & Docker-Compose.