React Compiler

2024React Conf 上,React Compiler 正式开源了,早在 2021React Conf 上,由黄玄提出的 React Forget(React without memo)概念,后改名为 React Compiler

注意 React Compiler 目前仍处于实验阶段,需要 React 19 Beta 不建议在生产中使用~。(可观看在 React Conf 中的介绍)

https://react.dev/learn/react-compiler

使用场景(为什么要使用?)

在平时开发中,我们经常会因为避免不必要或不是预期的 rerenderpropsstatecontext) 或缓存一些计算结果,而使用 React.memouseMemouseCallback 等记忆函数,例如:

props 变化导致的 rerender

当父组件 rerender 后,若子组件未使用 React.memo,则子组件也会 rerender。然而当子组件使用 React.memo 后,若 props 中的某个属性为一个匿名函数或对象时,子组件仍会 rerender

const ChildComponent = (props) => {
  const { count, onChange, config } = props;

  console.log('render', config);

  return (
    <div>
      <button onClick={onChange}>click</button>
      <span>{count}</span>
    </div>
  );
};

const Page = () => {
  const [count, setCount] = useState(0);
  const [number, setNumber] = useState(0);

  return (
    <div>
      <ChildComponent
        count={count}
        onChange={() => console.log(123)}
        config={{ someConfig: 'some cinfig here' }}
      />
    </div>
  );
};

手动避免 rerender

为什么使用了 memo 还会触发 renderReact 中的 memo 依赖相关比较都是浅比较,当 props 中的某个属性为一个匿名函数或对象时,memo 会认为 props 发生了变化,从而触发 render

const objectIs: (x: any, y: any) => boolean =
  // $FlowFixMe[method-unbinding]
  typeof Object.is === 'function' ? Object.is : is;

function areHookInputsEqual(nextDeps, prevDeps) {
  //...
  // $FlowFixMe[incompatible-use] found when upgrading Flow
  for (let i = 0; i < prevDeps.length && i < nextDeps.length; i++) {
    // $FlowFixMe[incompatible-use] found when upgrading Flow
    if (objectIs(nextDeps[i], prevDeps[i])) {
      continue;
    }
    return false;
  }

  return true;
}

所以,我们在使用 React.memo 的同时,也需要在父组件中使用 useMemouseCallback 来避免不必要的 render

import { useMemo, useCallback, memo } from 'react';

const ChildComponent = memo((props) => {
  const { count, onChange, config } = props;

  console.log('render', config);

  return (
    <div>
      <button onClick={onChange}>click</button>
      <span>{count}</span>
    </div>
  );
});

const Page = () => {
  const [count, setCount] = useState(0);
  const [number, setNumber] = useState(0);

  const handleChange = useCallback(() => {
    console.log(123);
  }, []);

  const config = useMemo(() => {
    return { someConfig: 'some cinfig here' };
  }, []);

  return (
    <div>
      <ChildComponent count={count} onChange={handleChange} config={config} />
    </div>
  );
};

Checking compatibility - 检查老项目是否兼容使用 React Compiler

在上述描述后,或者平时在开发中早已被这些 “Memo” 产生了无形的性能焦虑。可以使用 react 提供的 healthcheck 在已有项目中尝试~ 可以先检查代码是否兼容。

npx react-compiler-healthcheck@latest

该脚本将会检查:

  • 多少组件可以成功优化
  • 检查是否使用了 <StrictMode>,启用 <StrictMode> 后,编译器的优化将更为轻松,说明你的项目已经在遵循 React 规则
  • 检查不兼容的库使用。
Successfully compiled 8 out of 9 components.
StrictMode usage not found.
Found no usage of incompatible libraries.

配置 eslint-plugin-react-compiler

React Compiler 提供了 eslint 插件,用于检查代码是否符合优化规则,且独立于 React Compiler。当该插件显示你的代码有违反 React Rules 时,编译器同样也会跳过优化。

例如,修改 props 的值。

pnpm install eslint-plugin-react-compiler

配置 .eslintrc.js

暂未提供 9.x

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

配置 react compiler

sources

在指定文件夹中优化。

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

compilationMode

支持 annotation、infer、syntax、all,当你只需要在某些组件或 hooks 中优化时,可以使用 annotation 模式,并在组件或 hooks 中添加 use memo 注释。

const ReactCompilerConfig = {
  compilationMode: 'annotation', // annotation | infer | syntax | all
};

// src/app.jsx
export default function App() {
  'use memo';
  // ...
}

或者只需要反选某些不需要的组件。需要注意的是,注释模式并不在长久计划内。

const ReactCompilerConfig = {
  compilationMode: 'all', // annotation | infer | syntax | all
};

// src/app.jsx
export default function App() {
  'use no memo';
  // ...
}

logger

目前该属性并没有明确的文档说明,但通过源码解读时,发现可以通过该属性来定义日志输出。

const ReactCompilerConfig = {
  logger: {
    logEvent: (fileName, event) => {
      console.log(fileName, event, 'reactCompiler');
    },
  },
};

在现有项目中使用 React Compiler

React Compiler 提供了 Babel 插件,将其添加至 babel 配置中即可。注意,插件需要在其他插件之前运行。

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

配置成功后,在运行或构建时。

在 Next 中使用 React Compiler

Next RC 15 中,支持 React Compiler,升级后,只需要在 next.config.js 中添加 reactCompiler 配置即可。

pnpm install next@canary react@canary react-dom@canary babel-plugin-react-compiler
next.config.ts
const nextConfig = { experimental: { // reactCompiler: true, reactCompiler: { compilationMode: 'annotation', }, }, }; module.exports = nextConfig;

配置成功后,可在 React Devtools 中发现 Memo 标志

源码浅析

可复制代码至 https://playground.react.dev/,查看编译后的代码

function ChildComponent(props) {
  const { count, onChange, config } = props;

  console.log('render', config);

  return (
    <div>
      <button onClick={onChange}>click</button>
      <span>{count}</span>
    </div>
  );
}

function Page() {
  const [count, setCount] = useState(0);
  const [number, setNumber] = useState(0);

  return (
    <div>
      <ChildComponent
        count={count}
        onChange={() => console.log(123)}
        config={{ someConfig: 'some cinfig here' }}
      />
    </div>
  );
}
function ChildComponent(props) {
  const $ = _c(7);

  const { count, onChange, config } = props;
  console.log('render', config);
  let t0;

  if ($[0] !== onChange) {
    t0 = <button onClick={onChange}>click</button>;
    $[0] = onChange;
    $[1] = t0;
  } else {
    t0 = $[1];
  }

  let t1;

  if ($[2] !== count) {
    t1 = <span>{count}</span>;
    $[2] = count;
    $[3] = t1;
  } else {
    t1 = $[3];
  }

  let t2;

  if ($[4] !== t0 || $[5] !== t1) {
    t2 = (
      <div>
        {t0}
        {t1}
      </div>
    );
    $[4] = t0;
    $[5] = t1;
    $[6] = t2;
  } else {
    t2 = $[6];
  }

  return t2;
}

function Page() {
  const $ = _c(4);

  const [count] = useState(0);
  useState(0);
  let t0;
  let t1;

  if ($[0] === Symbol.for('react.memo_cache_sentinel')) {
    t0 = () => console.log(123);

    t1 = {
      someConfig: 'some cinfig here',
    };
    $[0] = t0;
    $[1] = t1;
  } else {
    t0 = $[0];
    t1 = $[1];
  }

  let t2;

  if ($[2] !== count) {
    t2 = (
      <div>
        <ChildComponent count={count} onChange={t0} config={t1} />
      </div>
    );
    $[2] = count;
    $[3] = t2;
  } else {
    t2 = $[3];
  }

  return t2;
}

解析

React Compiler 并不是对 props 或相关变量增加 useMemouseCallback,而是将 props 或相关变量的值缓存至一个变量中(在测试时发现同一个 变量 被存了多份,暂不知是设计如此还是?),当值发生变化时,重新渲染,反之取缓存。
与其他优化相比,使用 Babel 后的 React Compiler 实现了更细粒度的优化,能精确地实现最小化更新。

react/compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Program.ts
function compileProgram(program: NodePath<t.Program>, pass: CompilerPass) { const useMemoCacheIdentifier = program.scope.generateUidIdentifier('c'); // .... compiledFn = compileFn( fn, config, fnType, useMemoCacheIdentifier.name, pass.opts.logger, pass.filename, pass.code, ); }
react/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/CodegenReactiveFunction.ts
function codegenFunction(fn: ReactiveFunction, uniqueIdentifiers: Set<string>) { // .... // 共缓存的变量 数量 => // const $ = _c(4); // t0 = $[0]; const cacheCount = compiled.memoSlotsUsed; // The import declaration for `useMemoCache` is inserted in the Babel plugin preface.push( t.variableDeclaration('const', [ t.variableDeclarator( t.identifier(cx.synthesizeName('$')), t.callExpression(t.identifier(fn.env.useMemoCacheIdentifier), [ t.numericLiteral(cacheCount), ]), ), ]), ); if (fastRefreshState !== null) { // ... } }

https://playground.react.dev/
https://react.dev/learn/react-compiler