探究我们现在是否可以忘记 React 中的 memoization,因为 React 编译器现在已经开源了。
这可能是我想出的最具有点击诱饵性质的标题,但我觉得关于 React 社区这些天最热门话题之一的文章值得这样做 😅。
在过去两年半的时间里,每当我发布任何提及与重新渲染和 memoization 相关的模式的内容时,来自未来的访问者就会涌入评论区,并亲切地通知我,因为我所说的所有内容都不再相关了,因为有了 React Forget(现在被称为 React 编译器)。
现在,我们的时间线终于赶上了他们的,React 编译器实际上已经作为实验性功能向公众发布,是时候调查那些来自未来的访问者是否正确,并亲眼看看我们现在是否可以开始忘记 React 中的 memoization。
什么是 React 编译器
但首先,非常、非常简短地,这个编译器是什么,它解决了什么问题,以及如何开始使用它?
问题所在:React 中的重新渲染是级联的。每次你在 React 组件中更改状态时,你都会触发该组件的重新渲染,组件内的每个组件,那些组件内的组件等等,直到达到组件树的末端。
如果这些下游的重新渲染影响到一些重量级的组件或者发生得过于频繁,这可能会导致我们的应用程序出现性能问题。
解决这些性能问题的一种方法是防止重新渲染链的发生,实现这一目的的一种方法是借助 memoization:React.memo
、useMemo
和 useCallback
。通常,我们会用 React.memo
包装一个组件,用 useMemo
和 useCallback
包装它的所有 props,下一次,当父组件重新渲染时,被包装在 memo
中的组件(即,“memoized”)将不会重新渲染。
但正确使用这些工具很难,非常难。如果你想了解更多关于这个话题的知识,我写了几篇文章并制作了一些视频(如何使用 useMemo 和 useCallback:你可以移除大部分,掌握 React 中的 memoization - 高级 React 课程,第 5 集)。
这就是 React 编译器的用武之地。编译器是由 React 核心团队开发的工具。它插入我们的构建系统,获取原始组件的代码,并尝试将其转换为默认情况下组件、它们的 props 和 hooks 的依赖项都被 memoized 的代码。最终结果类似于用 memo
、useMemo
或 useCallback
包装所有内容。
当然,这只是一个开始,让我们大致了解它。实际上,它进行更复杂的转换。Jack Herrington 在他最近的视频中对此进行了很好的概述(React 编译器:2024 年 React 大会之外的深入探究),如果你想了解实际的细节。或者,如果你想彻底弄乱你的大脑并真正欣赏这个编译器的复杂性,可以观看 Sathya Gunasekaran 解释编译器然后 Mofei Zhang 在 20 分钟内现场编码的视频(“React 编译器深度剖析”)🤯。
如果你想自己尝试编译器,只需按照文档操作即可:https://react.dev/learn/react-compiler。文档已经足够好,并且包含了所有的要求和操作步骤。只是记住:这仍然是一个非常实验性的东西,依赖于安装 React 的金丝雀版本,所以要小心。
准备得差不多了。让我们最终看看它能做什么,以及在现实生活中的表现如何。
尝试编译器
对我来说,这篇文章的主要目的是调查我们对编译器的期望是否符合现实。目前的承诺是什么?
- 编译器是即插即用的:你安装它,它就能正常工作;不需要重写现有代码。
- 安装它之后,我们将不再考虑
React.memo
、useMemo
和useCallback
:将不再需要它们。
为了测试这些假设,我实现了一些简单的示例来孤立地测试编译器,然后将其运行在我有的三个不同的应用程序上。
简单示例:孤立测试编译器
所有简单示例的完整代码可以在此处找到:https://github.com/developerway/react-compiler-test
从头开始使用编译器的最简单方法是安装 Next.js 的金丝雀版本。基本上,这将给你提供你需要的一切:
npm install next@canary babel\-plugin\-react\-compiler
然后我们可以在 next.config.js
中打开编译器:
const nextConfig = {
experimental: {
reactCompiler: true,
},
};
module.exports = nextConfig;
瞧!我们将立即在 React Dev Tools 中看到自动 memoized 的组件。
到目前为止,假设一是正确的:安装非常简单,它就能正常工作。
让我们开始编写代码,看看编译器如何处理它。
第一个示例:简单的状态变化。
到目前为止,一切顺利,但前一个示例是最简单的。让我们使它更复杂一些,并将 props 引入到等式中。
假设我们的 VerySlowComponent
有一个期望函数的 onSubmit
prop,以及一个接受数组的 data
prop:
const SimpleCase2 = () => {
const [isOpen, setIsOpen] = useState(false);
const onSubmit = () => {};
const data = [{ id: 'bla' }];
return (
<>
...
<VerySlowComponent onSubmit={onSubmit} data={data} />
</>
);
};
现在,在手动 memoization 的情况下,除了用 React.memo
包装 VerySlowComponent
,我们还需要用 useMemo
(假设我们不能简单地将其移出,原因可能是某些原因)和 useCallback
包装数组和 onSubmit
:
const VerySlowComponentMemo = React.memo(VerySlowComponent);
export const SimpleCase2Memo = () => {
const [isOpen, setIsOpen] = useState(false);
// 在这里进行 memoization
const onSubmit = useCallback(() => {}, []);
// 在这里进行 memoization
const data = useMemo(() => [{ id: 'bla' }], []);
return (
<div>
...
<VerySlowComponentMemo
onSubmit={onSubmit}
data={data}
/>
</div>
);
};
但是有了编译器,我们仍然不需要这样做!VerySlowComponent
在 React 开发工具中仍然显示为 memoized,控制台中的“控制”console.log 仍然没有触发。
你可以从这个仓库 运行这些示例。
第三个示例:子元素作为 children。
好的,在测试真实应用程序之前,第三个示例。如果我们的慢组件接受子元素会怎样?
export const SimpleCase3 = () => {
const [isOpen, setIsOpen] = useState(false);
return (
<>
...
<VerySlowComponent>
<SomeOtherComponent />
</VerySlowComponent>
</>
);
};
你能立即记住如何在这里正确地 memoize VerySlowComponent
吗?
大多数人会假设我们需要将 VerySlowComponent
和 SomeOtherComponent
都用 React.memo
包装。这是错误的。我们实际上需要将 <SomeOtherComponent />
元素包装在 useMemo
中,像这样:
const VerySlowComponentMemo = React.memo(VerySlowComponent);
export const SimpleCase3 = () => {
const [isOpen, setIsOpen] = useState(false);
// 通过 useMemo 而不是 React.memo 来 memoize 子元素
const child = useMemo(() => <SomeOtherComponent />, []);
return (
<>
...
<VerySlowComponentMemo>{child}</VerySlowComponentMemo>
</>
);
};
如果你不确定为什么是这样,你可以观看这个详细解释 memoization 的视频,包括这种模式:掌握 React 中的 memoization - 高级 React 课程,第 5 集。 这篇文章也很有用:React 元素、子元素、父元素和重新渲染的神秘
幸运的是,React 编译器在这里仍然发挥着它的魔力 ✨! 一切都是 memoized,非常慢的组件没有重新渲染。
到目前为止,三个示例都成功了,这令人印象深刻!但这些示例非常简单。现实生活中生活会那么简单吗?现在让我们尝试一个真正的挑战。
在真实代码上测试编译器
为了真正挑战编译器,我在我可用的三个代码库上运行了它:
- 应用一:一个几年前的相当大的应用,基于 React,React Router 和 Webpack,由多个人编写。
- 应用二:稍微新一些但仍然相当大的 React & Next.js 应用,由多个人编写。
- 应用三:我的个人项目:非常新,最新的 Nextjs,非常小 - 只有几个带有 CRUD 操作的屏幕。
对于每个应用,我进行了以下操作:
- 初始健康检查 以确定应用对编译器的准备情况。
- 启用编译器的 eslint 规则并在整个代码库上运行它们。
- 将 React 版本更新到 19 金丝雀版本。
- 安装编译器。
- 在打开编译器之前确定一些明显的不必要重新渲染的案例。
- 打开编译器并检查这些不必要的重新渲染是否已修复。
在应用一上测试编译器:结果
这是最大的一个,可能大约有 150k 行 React 代码。我为这个应用确定了 10 个容易发现的不必要重新渲染的案例。有些是非常小的,比如点击里面的按钮时重新渲染整个头部组件。有些则更大,比如在输入字段中键入时重新渲染整个页面。
- 初始健康检查:97.7% 的组件可以被编译!没有不兼容的库。
- Eslint 检查:只有 20 条规则违规
- React 19 更新:一些次要的事情坏了,但在注释掉它们之后,应用似乎运行良好。
- 安装编译器:这个产生了一些 F 炸弹,并且因为已经有一段时间没有接触过任何 Webpack 或 Babel 相关的东西了,所以需要一些 ChatGPT 的帮助。但最终,它也工作了。
- 测试应用:在 10 个不必要的重新渲染案例中……只有 2 个被编译器修复了 😢
2 个中的 10 个是相当令人失望的结果。但这个应用有一些 eslint 违规我没有修复,也许这就是为什么?让我们看看下一个应用。
在应用二上测试编译器:结果
这个应用小得多,大约有 30k 行 React 代码。在这里我也确定了 10 个不必要的重新渲染。
- 初始健康检查:同样的结果,97.7% 的组件可以被编译。
- Eslint 检查:只有 1 条规则违规!🎉 完美的候选。
- React 19 更新 和 安装编译器:对于这个,我不得不将 Next.js 更新到金丝雀版本,它处理了其余的事情。安装后它就工作了,比更新基于 Webpack 的应用要容易得多。
- 测试应用:在 10 个不必要的重新渲染案例中……又是只有 2 个被编译器修复了 😢
再次 2 个中的 10 个!在一个完美的候选上……再次有点令人失望。这就是现实生活中与合成的“计数器”示例的对比。让我们在尝试调试发生了什么之前看看第三个应用。
在应用三上测试编译器:结果
这是它们中最小的一个,在一个周末或两个周末内编写。只有几页带有数据表的页面,以及在表中添加/编辑/删除实体的能力。整个应用如此之小且如此简单,以至于我能够确定其中只有 8 个不必要的重新渲染。每次交互都会重新渲染所有内容,我根本没有优化它。
完美的主题,让 React 编译器大幅改善重新渲染的情况!
- 初始健康检查:100% 的组件可以被编译
- Eslint 检查:没有违规 🎉
- React 19 更新 和 安装编译器:出人意料地比前一个更糟糕。我使用的其中一些库还与 React 19 不兼容。我不得不强制安装依赖项以消除警告。但实际的应用和所有库仍然工作,所以我想没什么害处。
- 测试应用:在 8 个不必要的重新渲染案例中,React 编译器设法修复了……敲鼓……一个。只有一个!🫠 到这个时候,我几乎开始哭了;我对这次测试抱有如此大的希望。
这是我的旧愤世嫉俗的天性所期望的,但绝对不是我天真的内在小孩所希望的。也许我只是写错了 React 代码?我可以通过编译器的 memoization 调查出了什么问题,并且可以修复吗?
调查编译器的 memoization 结果
为了以有用的方式调试这些问题,我从第三个应用中提取了一页到它自己的仓库中。如果你想跟随我的思路并做一个代码练习,可以在这里查看:(https://github.com/developerway/react-compiler-test/ )。它几乎完全是我在第三个应用中的一页,只是使用了假数据并删除了一些内容(比如 SSR)以简化调试体验。
UI 非常简单:一个带有国家列表的表格,每一行都有一个“删除”按钮,以及一个位于表格下方的输入组件,你可以在其中添加一个新的国家到列表中。
从代码的角度来看,它只是一个组件,一个状态,查询和变异。这是 完整代码。 简化版本,只包含调查所需的必要信息,看起来像这样:
export const Countries = () => {
// 存储我们在输入中键入的内容
const [value, setValue] = useState("");
// 使用 react-query 获取国家列表
const { data: countries } = useQuery(...);
// 使用 react-query 进行删除国家的变异操作
const deleteCountryMutation = useMutation(...);
// 使用 react-query 进行添加国家的变异操作
const addCountryMutation = useMutation(...);
// 传递给“删除”按钮的回调函数
const onDelete = (name: string) => deleteCountryMutation.mutate(name);
// 传递给“添加”按钮的回调函数
const onAddCountry = () => {
addCountryMutation.mutate(value);
setValue("");
};
return (
...
{countries?.map(({ name }, index) => (
<TableRow key={`${name.toLowerCase()}`}>
...
<TableCell className="text-right">
<!-- onDelete 回调函数在这里 -->
<Button onClick={() => onDelete(name)} variant="outline">
Delete
</Button>
</TableCell>
</TableRow>
))}
...
<Input
type="text"
placeholder="Add new country"
value={value}
onChange={(e) => setValue(e.target.value)}
/>
<button onClick={onAddCountry}>Add</button>
);
}
由于它只是一个组件,具有多个状态(本地 + 查询/变异更新),每次交互都会在每次状态更新时重新渲染所有内容。如果您启动应用,您将有这些不必要的重新渲染案例:
- 在“添加新国家”输入中键入会导致一切重新渲染。
- 点击“删除”会导致一切重新渲染。
- 点击“添加”会导致一切重新渲染。
对于像这样的简单组件,我期望编译器能够解决所有这些问题。特别是考虑到在 React Dev Tools 中,一切看起来都被 memoized 了:
然而,尝试启用“在组件渲染时突出显示更新”设置并享受灯光表演。
在表中的每个组件中添加 console.log
给我们确切的列表:除了头部组件之外的所有内容在所有来源的每次状态更新时仍然重新渲染。
那么,该如何调查原因呢?🤔
React Dev Tools 没有提供任何额外信息。我 可以 将那个组件复制粘贴到 Compiler Playground 中看看会发生什么…… 但看看输出!😬 那感觉像是朝着错误的方向迈出了一步,而且坦率地说,是我永远都不想做的事情。
我能想到的唯一事情是逐步 memoize 那个表,并看看组件或依赖项是否有可疑之处。
通过手动 memoization 进行调查
这部分是针对那些完全理解所有手动 memoization 技术的人。如果你对 React.memo
、useMemo
或 useCallback
感到不安,我建议先观看 这个视频。
此外,我建议打开代码本地 (https://github.com/developerway/react-compiler-test ) 并进行代码练习;这将使下面的思路更容易理解。
调查输入键入导致的重新渲染
让我们再次看看那个表,这次是完整的:
<Table>
<TableCaption>Supported countries list.</TableCaption>
<TableHeader>
<TableRow>
<TableHead className="w-[400px]">Name</TableHead>
<TableHead className="text-right">Action</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{countries?.map(({ name }, index) => (
<TableRow key={`${name.toLowerCase()}`}>
<TableCell className="font-medium">
<Link href={`/country/${name.toLowerCase()}`}>
{name}
</Link>
</TableCell>
<TableCell className="text-right">
<Button
onClick={() => onDelete(name)}
variant="outline"
>
Delete
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
头部组件被 memoized 的事实暗示了编译器所做的事情:它可能将所有组件包装在一个 React.memo
等价物中,而 TableBody
内的部分则使用 useMemo
等价物进行了 memoization。 useMemo
等价物的依赖项中有一些在每次重新渲染时都会更新,这反过来会导致 TableBody
内的所有内容重新渲染,包括 TableBody
本身。至少这是一个好的工作理论,需要测试。
如果我复制那部分内容的 memoization,它可能会给我们一些线索:
// 使 TableBody 的整个内容 memoized
const body = useMemo(
() =>
countries?.map(({ name }, index) => (
<TableRow key={`${name.toLowerCase()}`}>
<TableCell className="font-medium">
<Link href={`/country/${name.toLowerCase()}`}>
{name}
</Link>
</TableCell>
<TableCell className="text-right">
<Button
onClick={() => onDelete(name)}
variant="outline"
>
Delete
</Button>
</TableCell>
</TableRow>
)),
// 这些是那组代码中使用的依赖项
// 感谢 eslint!
[countries, onDelete],
);
现在很明显,这个部分完全依赖于 countries
数组数据和 onDelete
回调。 countries
数组来自一个查询,所以它不可能在每次重新渲染时重新创建 - 缓存是库的主要责任之一。
onDelete
回调看起来像这样:
const onDelete = (name: string) => {
deleteCountryMutation.mutate(name);
};
为了让它进入依赖项,它也应该被 memoized:
const onDelete = useCallback(
(name: string) => {
deleteCountryMutation.mutate(name);
},
[deleteCountryMutation],
);
而 deleteCountryMutation
又是 react-query 的一个变异,所以它可能没问题:
const deleteCountryMutation = useMutation({...});
最后一步是 memoize TableBody
并渲染 memoized 的子元素。如果一切都正确地 memoized,那么在输入中键入时行和单元格的重新渲染应该停止。
const TableBodyMemo = React.memo(TableBody);
// 在 Countries 内这样渲染
<TableBodyMemo>{body}</TableBodyMemo>;
然后,它没有工作 🤦🏻♀️ 现在我们有了一些进展 - 我在依赖项上出了问题,编译器可能也犯了同样的错误。但是是什么呢?除了 countries
,我只有一个依赖项 - deleteCountryMutation
。我假设它是安全的,但它真的是吗?里面到底有什么?幸运的是,源代码是可用的。 useMutation
是一个 hook,它做了很多事并返回这个:
const mutate = React.useCallback(...)
return { ...result, mutate, mutateAsync: result.mutate }
返回中的非 memoized 对象!!我假设我可以直接将其作为依赖项使用是错误的。
然而,mutate
本身是 memoized 的。所以从理论上讲,我只需要将它传递到依赖项中:
// 从返回对象中提取 mutate
const { mutate: deleteCountry } = useMutation(...);
// 直接在代码中使用它
const onDelete = useCallback(
(name: string) => {
// 直接在这里使用它
deleteCountry(name);
},
// 你好,memoized 依赖项
[deleteCountry],
);
在这一步之后,我们的手动 memoization 终于奏效了。
现在,从理论上讲,如果我删除所有手动 memoization 并保留 mutate
的修复,React 编译器应该能够捕捉到它。
确实,它做到了!当我键入某些内容时,表格行和单元格不再重新渲染 🎉。
然而,“添加”和“删除”国家的重新渲染仍然存在。让我们也修复这些。
调查“添加”和“删除”重新渲染
让我们再次看看 TableBody
代码。
<TableBody>
{countries?.map(({ name }, index) => (
<TableRow key={index}>
<TableCell className="font-medium">
<Link href={`/country/${name.toLowerCase()}`}>
{name}
</Link>
</TableCell>
<TableCell className="text-right">
<Button
onClick={() => onDelete(name)}
variant="outline"
>
Delete
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
每当我添加或从列表中删除一个国家时,这整个内容都会重新渲染。让我们再次应用相同的策略:如果我手动 memoize 这些组件,我会怎么做?
它是一个动态列表,所以我必须:
首先,确保“key”属性与国家匹配,而不是数组中的位置。index
是不够的 - 如果我从一个列表的开头删除一个国家,下面每一行的索引都会改变,这将导致重新渲染,不管 memoization 如何。在现实生活中,我必须为每个国家引入某种 id
。对于我们的简化案例,让我们只使用 name
并确保我们不会添加重复的名称 - 键应该是唯一的。
{
countries?.map(({ name }) => (
<TableRow key={name}>...</TableRow>
));
}
其次,用 React.memo
包装 TableRow
。简单。
const TableRowMemo = React.memo(TableRow);
第三,使用 useMemo
memoize TableRow
的 children
:
{
countries?.map(({ name }) => (
<TableRow key={name}>
... // 这里里面的一切都需要被 memoized
使用 useMemo
</TableRow>
));
}
这在渲染内部和数组内部是不可能的:hooks 只能在组件的最顶层和渲染函数外部使用。
要做到这一点,我们需要将整个 TableRow
及其内容提取到一个组件中:
const CountryRow = ({ name, onDelete }) => {
return (
<TableRow>
<TableCell className="font-medium">
<Link href={`/country/${name.toLowerCase()}`}>
{name}
</Link>
</TableCell>
<TableCell className="text-right">
<Button
onClick={() => onDelete(name)}
variant="outline"
>
Delete
</Button>
</TableCell>
</TableRow>
);
};
通过 props 传递数据:
<TableBody>
{countries?.map(({ name }) => (
<CountryRow
name={name}
onDelete={onDelete}
key={name}
/>
))}
</TableBody>
并以代替之包装 CountryRow
用 React.memo
。onDelete
已经被正确地 memoized - 我们已经修复了它。
我甚至不需要实现那种手动 memoization。一旦我将这些行提取到一个组件中,编译器立即捕捉到它们,并且重新渲染停止了 🎉。在人类与机器的战斗中,2 : 0。
有趣的是,编译器能够捕捉到 CountryRow
组件内部的所有内容,但不是组件本身。如果我移除手动 memoization 但保留 key
和 CountryRow
的更改,单元格和行将在添加/删除时停止重新渲染,但 CountryRow
组件本身仍然会重新渲染。
在这一点上,我已经没有想法如何用编译器修复它,而且这已经足够写一篇文章了,所以我就让它重新渲染。内部的一切都被 memoized,所以这不是什么大问题。
那么,裁决是什么?
编译器在简单案例和简单组件上表现得非常出色。三个案例全部成功!然而,现实生活有点更复杂。
在我尝试编译器的三个应用中,它只能修复我发现的 8-10 个明显不必要的重新渲染案例中的 1-2 个。
然而,通过一些推理思考和猜测,看起来通过轻微的代码更改,有可能改善这个结果。然而,调查这些是非常非平凡的,需要大量的创造性思维,以及对 React 算法和现有 memoization 技术的掌握。
为了让编译器行为正确,我必须在现有代码中进行的更改:
- 从
useMutation
hook 的返回值中提取mutate
并直接在代码中使用。 - 将
TableRow
及其内部的所有内容提取到一个隔离的组件中。 - 将“key”从
index
更改为name
。
至于我正在调查的假设:
它能“立即工作”吗? 技术上,是的。你可以只打开它,似乎没有什么是坏的。它不会正确地 memoize 一切,尽管在 React Dev Tools 中显示它被 memoized。
在安装编译器后,我们可以忘记 memo
、useMemo
和 useCallback
吗?绝对不行!至少在它目前的状态。事实上,你甚至需要比现在更了解它们,并发展出一种第六感,以编写优化为编译器的组件。或者只是使用它们来调试你想要修复的重新渲染。
当然,这是假设我们想要修复它们。我怀疑会发生的是:当我们准备好生产时,我们都会打开编译器。看到 Dev Tools 中所有的“memo ✨”会给我们一种安全感,所以每个人都会对重新渲染放松警惕,专注于编写功能。实际上一半的重新渲染仍然存在,没有人会注意到,因为大多数重新渲染对性能的影响微乎其微。
对于重新渲染实际上有性能影响的案例,使用组合技术如 将状态下移、将元素作为子元素或 props 传递 或者提取数据到 带有分割提供程序的 Context 或任何允许 memoized 选择器的外部状态管理工具将更容易。偶尔 - 手动 React.memo
和 useCallback
。
至于那些来自未来的访问者,我现在非常确定他们是来自一个平行宇宙。一个神奇的地方,React 恰好是用比众所周知灵活的 JavaScript 更有结构的东西编写的,编译器实际上可以解决 100% 的案例,因为它是这样的。