React Compiler
React Compiler 并非全能,如果你写的代码过于灵活,无法被提前预判执行行为,那么 React Compiler 将会跳过这一部分的优化。
因此好的方式是在项目中引入严格模式,在严格模式的指导下完成的开发,基本都在 React Compiler 的辐射范围之内
背景
要了解 React Compiler,这还需要从 React 的更新机制说起。
React 项目中的任何一个组件发生 state 状态的变更,React 更新机制都会从最顶层的根节点开始往下递归对比,通过双缓存机制判断出哪些节点发生了变化,然后更新节点。
这样的更新机制成本并不小,因为在判断过程中,如果 React 发现 props、state、context 任意一个不同,那么就认为该节点被更新了。
因此,冗余的 re-render 在这个过程中会大量发生。
对比的成本非常小,但是 re-render 的成本较高,当我们在短时间之内快速更改 state 时,程序大概率会存在性能问题。 因此在以往的开发方式中,掌握性能优化的手段是高级 React 开发者的必备能力
从使用结果的体验来看,React Compiler 被集成在代码自动编译中,因此只要我们在项目中引入成功,就不再需要关注它的存在。我们的开发方式不会发生任何改变。
它不会更改 React 现有的开发范式和更新方式,侵入性非常弱。这一点对于老项目来说,非常非常重要。
检测
并非所有的组件都能被优化。
因此早在 React 18 的版本中,React 官方团队就提前发布了严格模式。在顶层根节点中,套一层 StrictMode 即可。
<StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>;
遵循严格模式的规范,我们的组件更容易符合 React Compiler 的优化规则。
我们可以使用如下方式首先检测代码库是否兼容。在项目根目录下执行如下指令。
npx react-compiler-healthcheck
该脚本主要用于检测
1、项目中有多少组件可以成功优化**:越多越好**
2、是否使用严格模式,使用了优化成功率更高
3、是否使用了与 Compiler 不兼容的三方库
在项目中引入
官方文档中已经明确表示,由于 JavaScript 的灵活性,Compiler 无法捕获所有可能的意外行为,甚至编译之后还会出现错误。
因此,目前而言,Compiler 依然可能会有他粗糙的一面。
因此,我们可以通过配置,在具体的某一个小目录中运行 Compiler。
const ReactCompilerConfig = {
sources: (filename) => {
return filename.includes('src/path/to/dir');
},
};
React Compiler 还支持对应的 eslint 插件。该插件可以独立运行。不用非得与 Compiler 一起运行。
可以使用如下指令安装该插件
npm i eslint-plugin-react-compiler
然后在 eslint 的配置中添加
module.exports = {
plugins: [
'eslint-plugin-react-compiler',
],
rules: {
'react-compiler/react-compiler': 2,
},
};
Compiler 目前结合 Babel 插件一起使用,因此,我们首先需要在项目中引入该插件
npm i babel-plugin-react-compiler
然后,在不同的项目中,有不同的配置。
添加到 Babel 的配置中,如下所示
module.exports = function () {
return {
plugins: [
['babel-plugin-react-compiler', ReactCompilerConfig], // must run first!
// ...
],
};
};
注意,该插件应该在其他 Babel 插件之前运行
在 vite 中使用
首先,我们需要安装 vite-plugin-react,注意不用搞错了,有的同学使用了 vite-plugin-react-swc 结果搞了很久没配置成功。然后在 vite.config.js 中,添加如下配置
export default defineConfig(() => {
return {
plugins: [
react({
babel: {
plugins: [
['babel-plugin-react-compiler', ReactCompilerConfig],
],
},
}),
],
// ...
};
});
在 Webpack 中使用
我们可以单独为 Compiler 创建一个 Loader. 代码如下所示。
const ReactCompilerConfig = { /* ... */ };
const BabelPluginReactCompiler = require('babel-plugin-react-compiler');
function reactCompilerLoader(sourceCode, sourceMap) {
// ...
const result = transformSync(sourceCode, {
// ...
plugins: [
[BabelPluginReactCompiler, ReactCompilerConfig],
],
// ...
});
if (result === null) {
this.callback(
new Error(
`Failed to transform "${options.filename}"`
)
);
return;
}
this.callback(
null,
result.code,
result.map === null ? undefined : result.map
)
;
}
module.exports = reactCompilerLoader;
在 webpack 中引入会稍微麻烦一点,因为我们要自己定义一个 Loader,许多小伙伴对于 webpack 自定义 loader 比较陌生,因此我这里直接放了一个比较完整的代码如下
const babel = require('@babel/core');
const ReactCompilerConfig = {
// 这里是针对 react 低版本的配置,务必要按需使用
// runtimeModule: "src/hooks/useMemoCache",
};
const BabelPluginReactCompiler = require('babel-plugin-react-compiler');
function reactCompilerLoader(sourceCode, sourceMap) {
// ...
const result = babel.transformSync(sourceCode, {
// ...
sourceFileName: this.resourcePath,
filename: this.resourcePath,
plugins: [
[BabelPluginReactCompiler, ReactCompilerConfig],
],
// ...
});
if (result === null) {
this.callback(
new Error(
`Failed to transform "${options.filename}"`
)
);
return;
}
this.callback(
null,
result.code,
result.map === null ? undefined : result.map
);
}
module.exports = reactCompilerLoader;
定义好了这个 loader 之后,这里我们需要注意,如果你是 React 低版本,那么就需要使用我们自己定义一个 react-compiler-runtime,这里需要注意的是,与 vite 中低版本使用的项目的细微差异。
// src/hooks/useMemoCache
const React = require('react');
const $empty = Symbol.for("react.memo_cache_sentinel");
/**
* DANGER: this hook is NEVER meant to be called directly!
*
* Note that this is a temporary userspace implementation of this function
* from React 19. It is not as efficient and may invalidate more frequently
* than the official API. Please upgrade to React 19 as soon as you can.
**/
export function c(size: number) {
return React.useState(() => {
const $ = new Array(size);
for (let ii = 0; ii < size; ii++) {
$[ii] = $empty;
}
// @ts-ignore
$[$empty] = true;
return $;
})[0];
}
然后在 Loader 中修改 ReactCompilerConfig 的配置,植入到你自己的项目中时,请一定要注意路径要对应上
const ReactCompilerConfig = {
runtimeModule: 'src/hooks/useMemoCache',
};
然后在 webpack 的 Loader 配置中引入即可。
你也可以使用 react-compiler-webpack
这个插件使用。具体的使用方式请结合它的github说明使用。
只是需要注意的是,在低版本中,我们需要额外配置 runtime 的指向
{
test: /\.[mc]?[jt]sx$/i,
exclude: /node_modules/,
use: [
// babel-loader, swc-loader, esbuild-loader, or anything you like to transpile JSX should go here.
// If you are using rspack, the rspack's buiilt-in react transformation is sufficient.
// { loader: 'swc-loader' },
// Now add forgetti-loader
{
loader: reactCompilerLoader,
options: defineReactCompilerLoaderOption({
runtimeModule: "src/hooks/useMemoCache"
})
}
]
}
我们可以在 React 官方了解到更多关于 React Compiler 的介绍与注意事项。具体地址如下
https://react.dev/learn/react-compiler
引入成功之后,我们可以在开发者工具中的 Sources 面板的 Page 目录中查看编译之后的代码
Compiler编译原理
如何查看编译之后的代码
通常情况下,你只需要在合适的位置打印一个 log。
然后我们就可以通过下图所示的位置,在 console 面板中,点击跳转到编译之后的代码。
Chrome 开发者工具 -> Sources -> Page -> src
当然,我们可以直接在 Sources 面板中查看。
除此之外,你也可以把代码拷贝到 React Compiler Playground 。
这是一个在线的代码编译转换工具。我们可以利用这个工具方便的将代码转换成 Compiler 编译之后的代码,学习非常方便。
如果你存在任何疑问,完整的链接可以包含你的具体案例,在沟通和交流上非常方便。你可以在 react 的 issue 里看到大量 Compiler 不支持的骚操作。
知道了怎么查看编译之后的代码之后,那我们就需要看得懂才行。
Symbol.for
我本来最初的想法是看懂编译之后的代码不是很有必要。但是偶尔会出现一些情况,程序运行的结果跟我预想的不一样。
出现这种迷惑行为的时候就感觉贼困惑,为啥会这样呢?布吉岛 ~,如何调整我自己的写法呢?也不知道。我很不喜欢这种一脸懵逼的感觉。
看是得看懂才行。虽然这个代码很不像是正常人应该去阅读的代码。先来感受一下编译之后的代码长什么样,下面是一个案例的运行结果与其对应的代码。
function Counter() {
const $ = _c(10);
if ($[0] !== 'a13b836c47c4cd480504d73b45661476522265776f255f2150833079731132ac') {
for (let $i = 0; $i < 10; $i += 1) {
$[$i] = Symbol.for('react.memo_cache_sentinel');
}
$[0] = 'a13b836c47c4cd480504d73b45661476522265776f255f2150833079731132ac';
}
const [count, setCount] = useState(0);
let t0;
if ($[0] !== count) {
t0 = function __clickHanler() {
setCount(count + 1);
};
$[0] = count;
$[1] = t0;
}
else {
t0 = $[1];
}
const __clickHanler = t0;
let t1;
if ($[2] === Symbol.for('react.memo_cache_sentinel')) {
t1 = <div>A Base Case</div>;
$[2] = t1;
}
else {
t1 = $[2];
}
let t2;
if ($[3] !== count) {
t2 = (
<div>
currnt count is:
{count}
</div>
);
$[3] = count;
$[4] = t2;
}
else {
t2 = $[4];
}
let t3;
if ($[5] !== __clickHanler) {
t3 = <button onClick={__clickHanler}>Increment</button>;
$[5] = __clickHanler;
$[6] = t3;
}
else {
t3 = $[6];
}
let t4;
if ($[7] !== t2 || $[8] !== t3) {
t4 = (
<div>
{t1}
<div className="flex items-center justify-between">
{t2}
{t3}
</div>
</div>
);
$[7] = t2;
$[8] = t3;
$[9] = t4;
}
else {
t4 = $[9];
}
return t4;
}
在 Compiler 编译后的代码中,有一个比较少见的语法会频繁出现:Symbol.for,我先把这个知识点科普一下。
Symbol 在 JavaScript 中,是一种基础数据类型。
我们常常用 Symbol 来创建全局唯一值。例如,下面两个变量,虽然写法是一样的,但是他们的比较结果并不相等
const a = Symbol('hello');
const b = Symbol('hello');
a === b; // false
Symbol.for 则不同,Symbol.for 传入相同字符串时,它不会重复创建不同的值。
而是在后续的调用中,读取之前已经创建好的值。因此下面的代码对比结果为 true
const a = Symbol.for('for');
const b = Symbol.for('for');
a === b; // true
或者我们用另外一种说法来表达这种创建 -> 读取的过程。
// 创建一个 symbol 并放入 symbol 注册表中,键为 "foo"
Symbol.for('foo');
// 从 symbol 注册表中读取键为"foo"的 symbol
Symbol.for('foo');
在 Compiler 编译后的代码中,组件依赖 useMemoCache 来缓存所有运算表达式,包括组件、函数等。
在下面的例子中,useMemoCache 传入参数为 12,说明在该组件中,有 12 个单位需要被缓存。
在初始化时,会默认给所有的缓存变量初始一个值。
$ = useMemoCache(12);
for (let $i = 0; $i < 12; $i += 1) {
$[$i] = Symbol.for('react.memo_cache_sentinel');
}
那么,组件就可以根据缓存值是否等于 Symbol.for 的初始值,来判断某一段内容是否被初始化过。如果相等,则没有被初始化。
如下:
let t1;
if ($[1] === Symbol.for('react.memo_cache_sentinel')) {
t1 = <div id="tips">Tab 切换</div>;
$[1] = t1;
}
else {
t1 = $[1];
}
缓存原理详细分析
我们需要重新详细解读一下上面那段代码。这是整个编译原理的核心理论。对于每一段可缓存内容,这里以一个元素为例,
<div>A Base Case</div>;
我们会先声明一个中间变量,用于接收元素对象。
但是在接收之前,我们需要判断一下是否已经初始化过。
如果没有初始化,那么则执行如下逻辑,创建该元素对象。创建完成之后,赋值给 t1,并缓存在 $[1] 中。
if ($[2] === Symbol.for('react.memo_cache_sentinel')) {
t1 = <div>A Base Case</div>;
$[2] = t1;
}
如果已经初始化过,那么就直接读取之前缓存在 $[1] 中的值即可。
...
} else {
t1 = $[2];
}
这样,当函数组件多次执行时,该元素组件就永远只会创建一次,而不会多次创建。
这里需要注意的是,判断成本非常低,但是创建元素的成本会偏高,因此这种置换是非常划算的,我们后续会明确用数据告诉大家判断的成本
对于一个函数组件中声明的函数而言,缓存的逻辑会根据情况不同有所变化。这里主要分为两种情况,一种情况是函数内部不依赖外部状态,例如
function __clickHanler(index) {
tabRef.current[index].appeared = true;
setCurrent(index);
}
那么编译缓存逻辑与上面的元素是完全一致的,代码如下
let t0;
if ($[0] === Symbol.for('react.memo_cache_sentinel')) {
t0 = function __clickHanler(index) {
tabRef.current[index].appeared = true;
setCurrent(index);
};
$[0] = t0;
}
else {
t0 = $[0];
}
另外一种情况是有依赖外部状态,例如
const [count, setCount] = useState(0);
// 此时依赖 counter,注意区分他们的细微差别
function __clickHanler() {
setCount(count + 1);
}
那么编译结果,则只需要把是否重新初始化的判断条件调整一下即可
let t0;
if ($[0] !== count) {
t0 = function __clickHanler() {
setCount(count + 1);
};
$[0] = count;
$[1] = t0;
}
else {
t0 = $[1];
}
这样,当 count 发生变化,t0 就会重新赋值,而不会采用缓存值,从而完美的绕开了闭包问题。
除此在外,无论是函数、还是组件元素的缓存判断条件,都会优先考虑外部条件,使用 Symbol.for 来判断时,则表示没有其他任何值的变化会影响到该缓存结果。
例如,一个组件元素如下所示
<button onClick={__clickHanler}>counter++</button>;
此时它的渲染结果受到 __clickHanler 的影响,因此,判断条件则不会使用 Symbol.for,编译结果如下
let t3;
if ($[5] !== __clickHanler) {
t3 = <button onClick={__clickHanler}>Increment</button>;
$[5] = __clickHanler;
$[6] = t3;
}
else {
t3 = $[6];
}
又例如下面这个元素组件,他的渲染结果受到 counter 的影响。
<div>
currnt count is:
{count}
</div>;
因此,它的编译结果为:
let t2;
if ($[3] !== count) {
t2 = (
<div>
currnt count is:
{count}
</div>
);
$[3] = count;
$[4] = t2;
}
else {
t2 = $[4];
}
对与这样的编译细节的理解至关重要。在以后的开发中,我们就可以完全不用担心闭包问题而导致程序出现你意想不到的结果了。
所有的可缓存对象,全部都是这个类似的逻辑。他的粒度细到每一个函数,每一个元素。这一点意义非凡,他具体代表着什么,我们在后续聊性能优化的时候再来明确。
不过需要注意的是,对于 map 的循环语法,在编译结果中,缓存的是整个结果,而不是渲染出来的每一个元素。
{
tabs.map((item, index) => {
return (
<item.component
appearder={item.appeared}
key={item.title}
selected={current === index}
/>
);
});
}
编译结果表现如下:
let t4;
if ($[7] !== current) {
t4 = tabs.map((item_0, index_1) => (
<item_0.component
appearder={item_0.appeared}
key={item_0.title}
selected={current === index_1}
/>
));
$[7] = current;
$[8] = t4;
}
else {
t4 = $[8];
}
对这种情况的了解非常重要,因为有的时候我们需要做更极限的性能优化时,map 循环可能无法满足我们的需求。
强悍的性能:细粒度记忆化更新
首先明确一点,和 Vue 等其他框架的依赖收集不同,React Compiler 依然不做依赖收集。
React 依然通过从根节点自上而下的 diff 来找出需要更新的节点。在这个过程中,我们会通过大量的判断来决定使用缓存值。
可以明确的是,Compiler 编译之后的代码,缓存命中的概率非常高,几乎所有应该缓存的元素和函数都会被缓存起来。
因此,React Compiler 也能够在不做依赖收集的情况下,做到元素级别的超级细粒度更细。
但是,这样做的代价就是,React 需要经历大量的判断来决定是否需要更新。
所以这个时候,我们就需要明确,我所谓的大量判断的时间成本,到底有多少?它会不会导致新的性能问题?
可以看到,几乎所有的比较都是使用了全等比较,因此,我们可以写一个例子来感知一下,超大量的全等比较到底需要花费多少时间。
测试代码如下
const cur = performance.now();
for (let i = 0; i < 1000000; i++) {
'xxx' == 'xx';
}
const now = performance.now();
console.log(now - cur);
执行结果,比较 100 万次,只需要花费不到 1.3 毫秒。
卧槽(¬д¬。),这太强了啊。我们很难有项目能够达到 1000,000 次的比较级别,甚至许多达到 10000 都难。
那也就意味着,这里大量的比较成本,几乎可以忽略不计。
为了对比具体的效果,我们可以判断一下依赖收集的时间成本。
首先是使用数组来收集依赖。依然是 100 万次,具体执行结果如下。耗时 8 毫秒。
使用 Map 来收集依赖。100 万次依赖收集耗时 54 ms。
使用 WeakMap 来收集依赖,那就更慢了。100万次依赖收集耗时 200 毫秒。
WeakMap 的 key 不能是一个 number 类型
React Compiler 最佳实践
有许多骚操作,React Compiler 并不支持,例如下面这种写法。
{
[1, 2, 3, 4, 5].map((counter) => {
const [number, setNumber] = useState(0);
return (
<div key={`hello${counter}`} onClick={() => setNumber(number + 1)}>
number:
{' '}
{number}
</div>
);
});
}
这个操作骚归骚,但是真的有大佬想要这样写。React 之前的版本依然不支持这种写法。不过好消息是,React 19 支持了...
但是 React Compiler 并不支持。对于这些不支持的语法,React Compiler 的做法就是直接跳过不编译,而直接沿用原组件写法。
因此,React Compiler 的最佳实践我总结了几条
1、不再使用 useCallback、useMemo、Memo 等缓存函数
2、丢掉闭包的心智负担,放心使用即可
3、引入严格模式
4、在你不熟悉的时候引入 eslint-plugin-react-compiler
5、当你熟练之后,弃用它,因为有的时候我们就是不想让它编译我们的组件
6、更多的使用 use 与 Action 来处理异步逻辑
7、尽可能少的使用 useEffect
因此,一个小小的彩蛋就是,当你不希望你的组件被 Compiler 编译时,你只需要使用 var 来声明状态即可。因为这不符合它的语法规范
const [counter, setCounter] = useState(0);
而你改成 const/let,它就会又重新编译该组件。可控性与自由度非常高。
总结
React Compiler 在保持了函数式编程的开发范式的同时,弥补了之前可能存在性能问题的缺陷,这无疑是进一步确认了 React 在前端框架方向都领先地位。
并且 React Compiler 上手成本低,理解起来也不难,未来肯定会快速被开发者所接受。
作为开发者我们只需要耐心等待整个生态的跟进,目前从 npm 下载数据上来看,整个生态适配 React19 的积极性非常高。
因此距离能成熟使用的时间肯定是不会太长。