React Compiler

245 阅读5分钟

什么是React Compiler

要了解 React Compiler,这还需要从 React 的更新机制说起。当React项目中某个组件的state状态发生变更,react更新机制就会从该组件开始往下进行对比,通过双缓存机制判断哪些节点发生了变化,然后更新节点。这样就会导致只要父组件渲染了,子组件也会重新渲染,这样的更新机制成本并不小,因为在判断过程中,如果 React 发现props,state,context任意一个不同,那么就认为该节点被更新了。因此,冗余的 re-render 在这个过程中会大量发生

优化策略

在这样的背景下,冗余的 re-render 在项目中大量发生,这也是React被吐槽性能不好的主要原因。因此React提供了useMemo、useCallback、memo可以缓存某些节点,但这需要开发者对于这几个缓存的API有深刻且准确的了解。有的开发者虽然在项目中大量使用了,但是未必就达到了理想的效果。React Compiler 则是为了解决这个问题,它可以自动帮助我们记忆已经存在、并且没有发生更新的组件,从而解决组件冗余 re-render 的问题

对我们的开发有没有影响

React Compiler 是被集成在代码自动编译中,因此只要我们在项目中引入成功,就不再需要关注它的存在。我们的开发方式不会发生任何改变。 它不会更改 React 现有的开发范式和更新方式,侵入性非常弱

React Forget

检测兼容性

检测我们现有的代码是否兼容React Compiler

前提

并非所有的组件都能被优化,因此早在 React 18 的版本中,React 官方团队就提前发布了严格模式。在顶层根节点中,套一层 StrictMode 即可。遵循严格模式的规范,我们的组件更容易符合 React Compiler 的优化规则

<StrictMode>
  <BrowserRouter>
    <App />
  </BrowserRouter>
</StrictMode>

怎样检测

在项目根目录下执行如下指令

npx react-compiler-healthcheck

该脚本主要用于检测

  • 项目中有多少组件可以成功优化 :越多越好
  • 是否使用严格模式,使用了优化成功率更高
  • 是否使用了与 Compiler 不兼容的三方库

在项目中引入

支持部分目录运行 Compiler

由于 JavaScript 的灵活性,Compiler 无法捕获所有可能的意外行为,甚至编译之后还会出现错误。因此,目前而言,Compiler 依然可能会有他粗糙的一面。因此,我们可以通过配置,在具体的某一个小目录中运行 Compiler

const ReactCompilerConfig = {
  sources: (filename) => {
    return filename.indexOf('src/test') !== -1;
  },
};

支持 eslint 插件

React Compiler 还支持对应的 eslint 插件。该插件可以独立运行。不用非得与 Compiler 一起使用

安装

npm i eslint-plugin-react-compiler

在 eslint 的配置中添加

module.exports = {
  plugins: [
    'eslint-plugin-react-compiler',
  ],
  rules: {
    'react-compiler/react-compiler': "error",
  },
}

用法

Compiler 目前结合 Babel 插件一起使用,因此,我们首先需要在项目中引入该插件

npm install babel-plugin-react-compiler

安装后,将其添加到你的 Babel 配置中

👀注意:该插件应该在其他Babel之前运行

// babel.config.js
const ReactCompilerConfig = { /* ... */ };
module.exports = function () {
  return {
    plugins: [
      ['babel-plugin-react-compiler', ReactCompilerConfig], // must run first!
      // ...
    ],
  };
};

在 vite 中使用

首先,我们需要安装 @vitejs/plugin-react ,v4.2.1之后的版本有问题,所以安装4.2.1版本即可。然后在vite.config.js中添加如下配置

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

实践

React 版本要求

github.com/undesicimo/…

react-compiler-runtime库依赖于React 19,为了能和React 18 一起使用,有两种解决方式

  • 实现该库中的 c 函数
  • 在package.json中配置
// polyfill
import { useState } from "react";
export function c(size) {
  return useState(() => new Array(size))[0];
}
// 修改package.json中配的配置
https://github.com/undesicimo/r17-with-compiler/blob/main/package.json

配置vite.config.js

const ReactCompilerConfig = {
  runtimeModule: '@/mycache'
};
export default defineConfig(() => {
  return {
    plugins: [
      react({
        babel: {
          plugins: [
            ["babel-plugin-react-compiler", ReactCompilerConfig],
          ],
        },
      }),
    ],
  };
});

对比

// 测试代码
const B = () => {
  console.log('B render')
  return (
    <div>
      我是 B 组件
    </div>
  )
}
const A = () => {
  const [counter, setCounter] = useState(0)
  console.log('A render')
  return (
    <div style={{ padding: 100 }}>
      <button onClick={() => setCounter(counter + 1)}>
        点击修改
      </button>
      counter:{counter}
      <B />
    </div>
  )
}

未使用Compiler

当 A 组件的 state 发生改变 ,B组件也会渲染,尽管 B 组件没有用到 A组件中的state或者props 或者context,B 组件也会重新渲染

使用Compiler

当 A 组件渲染的时候,B 组件没有重新渲染了

分析

React Compiler 编译之后的代码并非是在合适的时机使用 useMemo、memo等 API来缓存组件,而是使用了一个名为 useMemoCache 的 hook 来缓存代码片段

Compiler 会分析所有可能存在的返回结果,并且把每个返回结果存储在 useMemoCache 中。在这个例子中,编译器知道需要用 9 个长度的数组来保存,第一个位置存储初始化标志,后面的位置用于存储state、setState以及具有DOM 树的 jsx 的记忆版本。如果判断之后发现该结果可以复用,则直接通过读取序列的方式使用即可,比起之前链表存储结果,执行效率上会高不少

// 编译后的 A 组件
// export function c(size) {
//   return useState(() => new Array(size))[0];
// }
import { c as _c } from "/src/mycache/index.ts";
const A = () => {
  _s();
  const 
$ = _c(9);
  if ($
[0] !== "42701a5839b43d9af82882bafb131108d07937c6db852d9299461b77c362838e") {
    for (let 
$i = 0; $
i < 9; 
$i += 1) {
      $
[
$i] = Symbol.for("react.memo_cache_sentinel");
    }
    $
[0] = "42701a5839b43d9af82882bafb131108d07937c6db852d9299461b77c362838e";
  }
  const [counter, setCounter] = useState(0);
  console.log("A render");
  let t0;
  if (
$[1] !== counter) {
    t0 = () => setCounter(counter + 1);
    $
[1] = counter;
    
$[2] = t0;
  } else {
    t0 = $
[2];
  }
  let t1;
  if (
$[3] !== t0 || $
[4] !== counter) {
    t1 = jsxDEV("button", { onClick: t0, children: [
      "点击修改 counter:",
      counter
    ] }, void 0, true, {
      fileName: "/Users/ivy/zaihui/tiktok-1/src/App.tsx",
      lineNumber: 30,
      columnNumber: 134
    }, this);
    
$[3] = t0;
    $
[4] = counter;
    
$[5] = t1;
  } else {
    t1 = $
[5];
  }
  let t2;
  if (
$[6] === Symbol.for("react.memo_cache_sentinel")) {
    t2 = jsxDEV(B, {}, void 0, false, {
      fileName: "/Users/ivy/zaihui/tiktok-1/src/App.tsx",
      lineNumber: 33,
      columnNumber: 10
    }, this);
    $
[6] = t2;
  } else {
    t2 = 
$[6];
  }
  let t3;
  if ($
[7] !== t1) {
    t3 = jsxDEV("div", { children: [
      t1,
      t2
    ] }, void 0, true, {
      fileName: "/Users/ivy/zaihui/tiktok-1/src/App.tsx",
      lineNumber: 33,
      columnNumber: 75
    }, this);
    
$[7] = t1;
    $
[8] = t3;
  } else {
    t3 = $[8];
  }
  return t3;
};

因此,编译之后的代码看上去更加繁杂,但是执行却更加高效

参考