在我介绍了 React 19 之后,不少同学都纷纷尝试了 React Compiler,但是,苦于团队项目无法那么顺利的升级到 React 19,因此对于 React 19 的一些非常有吸引力的特性都无法使用。
所以,群里有不少同学都尝试过想要在低版本中使用 Compiler,结果都没有太成功。然后我花了一点时间做调研,最后研究出来了一种比较靠谱的方法,让低版本也能顺利享受 Compiler 给项目带来的性能提升。
在如下这篇两篇文章中,我曾经详细分析过 React Compiler 的编译原理
结合对原理的综合分析,并在我使用很长一段时间之后,我发现,Compiler 对 React 代码逻辑的侵入性非常弱。他并没有改变代码的执行顺序和执行逻辑,它只做了一件事情,对于没必要重复执行的逻辑进行缓存
用一个非常简单的案例来探索思考这种改变。
有如下代码,我们在函数组件中给一个按钮添加了点击事件的回调。
function clickHandler() {
console.log('you has clicked the button!')
}
<button onClick={clickHandler}>点击</button>
此时有一个冗余的现象就是,如果由于其他原因导致了函数重新执行,这里的回调函数 clickHandler
就会重新创建。但是我们看到了,clickHandler 内容是完全一致的,那么此时的重新创建就是一种重复工作
因此,在这种情况之下,我们可以使用缓存的方式将第一次创建好的函数缓存下来,当函数组件重复执行时,再从缓存中取出来即可
一个比较常规的手段是,使用 useCallback 来缓存
const clickHandler = useCallback(() => {
console.log('you has clicked the button!')
}, [])
useCallback 提供了两个小能力,一个是缓存函数,一个是在指定状态发生改变时重新声明函数,通过开发者指定依赖的方式。我们知道 React Compiler 已经帮助我们自动识别了依赖的变化,因此,我们不需要引入新的机制去手动指定依赖项。
那么在低版本运行中,缺失的,就应该只是一个用于缓存的 hook 了。在一些资料中,把这个 hook,称之为 useCacheMemo
,当然叫什么名字无所谓,我们关心的重点是,在低版本中,能不能通过已有的 hook 来做到同样的缓存能力呢?
当然,可以。
了解 React hook 底层原理的同学都应该知道,React 中的几乎每一个 hook,都天然具备缓存能力。理解这一点非常重要,因此,我们是有办法在低版本中,自己基于已有的 hook,自己实现一个 Compiler 需要的缓存 hook 的。
那么我们要如何封装这个代码呢,首先要做的事情,就是先分析一下它是如何使用的,我们来查看一下这段编译之后的代码
function Counter() {
const $ = _c(25);
const [count, setCount] = useState(0);
const [other, setOther] = useState(0);
let t0;
if ($[0] !== count || $[1] !== other) {
t0 = function handle() {
setTimeout(() => {
setCount(count + 1);
setOther(other + 1);
console.log(other);
}, 500);
};
$[0] = count;
$[1] = other;
$[2] = t0;
} else {
t0 = $[2];
}
...
核心的就是开头这一句
const $ = _c(25);
在后面的使用中,我们发现, 是一个数组,因为后面我们可以看到许多使用索引来使用 [0] 的方式。
并且,在后续的使用中,我们发现,数组 $
的每一个子元素,都代表了一个缓存值。例如如下逻辑
let t1;
if ($[3] !== count) {
t1 = <div>{count}</div>;
$[3] = count;
$[4] = t1;
} else {
t1 = $[4];
}
并且从上面代码我们能观察到,他还可以通过直接赋值的方式将值缓存到数组里。
于是到这里,这个自定义 hook 的实现方式就呼之欲出。很明显,我们可以利用 useState
的缓存能力来做到这个事情,并且最终要返回一个数组,数组的个数由传入的参数决定,于是,这个 hook 的实现为:
import { useState } from "react";
export function c(size) {
const [$] = useState(() => new Array(size))
return $
}
代码非常简单,但是要透彻理解这段代码是如何做到缓存的,必须要结合闭包与引用数据类型的特性,大家可以通过下面这段代码来辅助理解和消化
import {useState} from 'react'
function App() {
const [$] = useState([0])
const [counter, setCounter] = useState($[0])
function clickHandler() {
$[0] = counter
setCounter($[0] + 1)
}
return (
<div>
<div>counter increment with $[0]: {counter}</div>
<button onClick={clickHandler} className='mt-4'>counter++</button>
</div>
)
}
export default App;
注意看这个例子,状态 counter 的初始化与更新的值,都来自于 $[0]
. 他的执行表现结果如下
OK,具体的细节大家可以作为检验自己基础能力的思考题细想一下,我这里就不多做赘述了,相信每一位道友都能够把这个事情想明白。
写完之后,发现字数有点少,再水两句吧,嘿嘿 ~ ~
React 的 useState 之所以具有缓存的能力,是因为他本身就是基于闭包来实现的。因此,在函数多次执行的过程中,我们可以始终获取到 useState 初始化时的那个值。
此时,如果这个值是引用数据类型的话,例如数组,那么,我们就可以通过直接修改引用数据类型的子项而使得该值的内容发生变化。但是由于我们并没有使用 setState
的方式去修改它,所以这也不会引发组件的重新渲染。
这个时候,它的作用,就跟 useRef
是类似的。因此,在实践中,你也可以通过这种 useState 的方式,去替代 useRef
的使用。例如,如果你是 vue 开发者,那么我们可以模拟一个 .value
的 useRef 让你找到熟悉的感觉
function useRef(value) {
return useState({value})[0]
}
!这里只是我写着玩一下,让大家体会一下骚操作,实践中如果你要这样用的话,请务必明确自己的需求是否完全符合。只能说,论骚操作,还得是 React,想咋玩就咋玩
function App() {
const c = useRef(0)
const [counter, setCounter] = useState(c.value)
function clickHandler() {
c.value = counter
setCounter(c.value + 1)
}
return (
<div>
<div>counter increment with c.value: {counter}</div>
<button onClick={clickHandler} className='mt-4'>counter++</button>
</div>
)
}
案例运行结果如图所示
接下来,我们只需要在 React Compiler 的配置中,将低版本缺失的 react-compiler-runtime
指向我们刚才新自定义的 hook 即可。
const ReactCompilerConfig = {
runtimeModule: "@/usecache",
};
@/*
是在 vite 中配置的路径别名,完整的配置文件如下
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
const ReactCompilerConfig = {
runtimeModule: "@/usecache",
};
// https://vitejs.dev/config/
export default defineConfig({
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
}
},
plugins: [
react({
babel: {
plugins: [["babel-plugin-react-compiler", ReactCompilerConfig]],
}
})
],
})
配置搞好之后,我们再引入 babel 插件,就可以正常运行了。
yarn add babel-plugin-react-compiler
✓其他脚手架的配置方式需要参考我之前的文章或者官方文档,配置方式都是一样的了,核心的关键只是 runtimeModule 的配置项需要指向我们自定义的那个 hook
运行项目,查看开发者工具的 Sources 面板中的 Page 目录,我们发现 App.jsx 已经被编译完成了。搞定!
我已经使用了很长一段时间的 Compiler,感觉非常的好。无论是在开发方式上,还是在代码逻辑的编译上,他的侵入性都非常非常弱。用久了之后,你甚至都感觉不到自己的代码被做了额外的编译。
这跟我了解之初的感受完全不一样。我刚开始还比较担心会有语法上的魔改,后来发现并没有。因此对于 React 开发者来说,它的使用是无痛、无感的。Compiler 的编译方式也比较保守,如果是遇到过于骚的操作,他会直接放弃对你的代码进行任何修改
因此,我非常推荐大家在实践项目中尝试使用 Compiler,虽然还没有正式发版,但我的感受是它目前已经是处于一个比较完善的状态。当然,也不排除有一些骚操作是我没用过,但是你已经在使用的,这个可能需要大家进一步交流使用心得