React 编译器优化了什么,又留下了什么任务给你处理
了解 React Compiler 能自动处理哪些性能相关任务,为何慢速的 API 和庞大的代码包仍需由你处理,以及如何在现有的 React 代码库中安全地采用它。
React 编译器省去了许多手动实现的记忆化操作,这让人误以为它能解决应用运行缓慢的问题。但实际上并非如此:它仅优化 React 所控制的渲染工作,其他部分则不予处理。下文将说明这一界限所在、哪些性能问题仍需工程师介入,以及如何在现有代码库中启用编译器而不引发功能退化。
从记忆化检查清单到自动记忆化
长期以来,关于 React 性能优化的建议都集中在几款工具上:
useMemo()
useCallback()
React.memo()
某个组件重新渲染过于频繁,于是对其进行了封装;某项计算成本较高,便将其缓存起来;某个回调函数会传递给子组件,因此对其进行了稳定处理。所有这些方法都属于手动记忆化,需要通过依赖数组来维护。
React Compiler改变了这一默认设置。1.0版本已被标记为稳定版,React团队对于新代码的建议是让编译器负责处理记忆化操作,而非手动使用useMemo和useCallback。请查阅最新的React文档以获取相关建议。
其局限性可以用一句话概括:编译器能够降低React自身的处理成本,但对React外部发生的操作则无能为力。
编译器实际的作用
在构建时,编译器会分析组件和钩子函数,并在分析认为有必要处插入记忆化逻辑。以一个在渲染前先过滤产品列表的组件为例:
function ProductList({ products }) {
const visible = products.filter(
product => product.inStock
);
经过过滤后的数组会直接传递给子组件:
return <List items={visible} />;
}
在编译器出现之前,注重性能的开发者通常会显式缓存过滤器,这样在products发生变化之前,List会一直使用同一个数组引用:
const visible = useMemo(
() => products.filter(
product => product.inStock
),
[products]
);
有了编译器之后,通常就可以跳过那段代码了。它能够自行判断依赖关系,比手动编写的钩子更精准地进行记忆化处理,而且不需要开发者准确列出所有依赖项。React将这种组件的自动记忆化功能称为钩子的自动记忆化。
不过要注意描述中的范围限制——列表中的每个元素都是React的构造物。
思维模型:渲染优化器
可以将编译器想象成一种作用于两种React操作并对其进行裁剪的工具:
React Compiler
│
┌───────────┴───────────┐
▼ ▼
Component work Hook calculations
│ │
▼ ▼
memoization memoization
│ │
└───────────┬───────────┘
▼
fewer unnecessary
React updates
减少不必要的重新渲染确实能带来显著优势。不过,该图表的重点在于它省略了哪些内容。
速度缓慢的后端不在其处理范围内
试想一个在渲染控制面板之前需要等待 API 响应的异步组件:
async function Dashboard() {
const data = await fetch(
"https://api.example.com/dashboard"
);
数据一旦到达,就会被传递给用户界面:
return <DashboardUI data={data} />;
}
如果该接口的响应时间为800毫秒,编译器也无法将其缩短到50毫秒。它只能简化React将数据转换为界面的过程,而无法让后端更快响应。时间线如下:
Browser
│
▼
React
│
▼
API request ──────────► 800ms
│
▼
Response
│
▼
React render
缓存机制几乎完全体现在最后一步。如果大部分时间都耗费在网络等待上,那说明你优化的是错误的部分;解决方式应是加快查询速度、使用缓存、减少响应数据量或实现并行获取。关于如何将网络处理时间与客户端处理时间分开,可参阅《诊断 API 响应时间之外的 React 性能问题》。
不应被纳入发布的 JavaScript 代码
包大小也存在同样的限制。想象这样一个情况:在用户打开需要使用这些功能的页面之前,首页就已经加载了庞大的图表库、富文本编辑器以及分析 SDK:
Initial page
│
├── React
├── UI
├── Chart library
├── Editor
└── Analytics SDK
编译器并不会移除任何依赖项;所有的字节仍然需要被下载、解析并执行。有效的解决方法就是代码分割,例如按需加载分析功能页面:
const Analytics = lazy(
() => import("./Analytics")
);
现在,代码只有在功能真正被渲染时才会被加载,而在加载期间,周围的 Suspense 区域会显示备用内容。这是模块化架构所做出的决策,再多的缓存策略也无法替代它。
预期为何会落空
一个常见的情况是:应用程序运行缓慢,有人启用了编译器,团队便期望能得到如下效果:
Slow app
│
▼
React Compiler
│
▼
Fast app
这太简单了。性能缓慢的原因有很多,而编译器只解决了其中一部分问题:
SLOW APP
│
┌───────────────┼───────────────┐
▼ ▼ ▼
React work Network Browser
│ │ │
▼ ▼ ▼
Compiler API/cache JS/rendering
│ │ │
▼ ▼ ▼
Optimized Engineer Engineer
编译器只是提升性能的工具箱中的其中一个工具,并非整个工具箱。网络延迟以及浏览器端的脚本评估、布局渲染等开销仍然需要人工处理。
是否应该删除现有的 useMemo 调用?
不会自动完成。在新代码中,官方的默认做法是让编译器负责记忆化。而对于现有代码,React建议保持当前的记忆化方式不变,或仅在经过仔细测试后才予以移除。其原因与性能有关,但更多是出于行为层面的考虑:作为Effect依赖项的记忆化值决定了该Effect何时重新运行,因此改变其记忆化方式可能会影响副作用的执行时机。
所以请不要急于对整个代码库进行此类清理操作:
useMemo → DELETE
useCallback → DELETE
React.memo → DELETE
更安全的方式是逐步进行,并在每一步都进行评估:
Existing app
│
▼
Enable compiler
│
▼
Run tests
│
▼
Profile
│
▼
Remove unnecessary
manual memoization
carefully
编译器的存在是为了让未来手动记忆化的需求逐渐减少,而非一夜之间就宣布所有现有的优化方式都是错误的。
作为代码质量检查工具的编译器
一个常被忽视的优势是:编译器包含了基于 React 规则的验证流程,其代码检查功能还能检测出类似以下的问题:
set-state-in-render
set-state-in-effect
unsafe ref access
React 指出,这些诊断信息能够揭示应用中已存在的错误。由于编译器会跳过那些违反其假设的代码,因此这些警告还能显示哪些组件未被优化,从而使其成为日常反馈机制的一部分,而不仅仅是一个性能插件。
先进行性能分析,再选择修复方案
当某个界面显得运行缓慢时,在使用任何工具之前先进行测量,让测量结果来指引需要优化的部分:
SLOW UI
│
▼
PROFILE
│
┌────────┼────────┐
▼ ▼ ▼
React Network Browser
work │ │
│ │ │
▼ ▼ ▼
Compiler API/cache JS/render
│
▼
Targeted fix
然后根据性能分析的结果来选择对应的修复方案:
- 不必要的 React 重新渲染:这时 React Compiler 可以提供帮助。
- 缓慢的 API:改进接口或数据获取策略。
核心要点
旧模式需要手动指定 React 应该缓存哪些内容;而新模式则允许你编写正确的 React 代码,并将大部分渲染工作交给编译器处理。这提升了以下技能的重要性:能够了解实际的时间消耗点。
- 编译器只能优化它所理解的代码,也就是 React 的渲染逻辑和钩子计算。
- 它无法缩小过大的 API 响应体积、加快数据库查询速度、移除第三方脚本或压缩 5 MB 的图片。
- 它也无法挽救那些处处产生不必要的工作的架构。
- 在现有代码中先启用该功能,进行测试和性能分析,之后再移除手动缓存机制。
- 将其诊断结果视为关于违反 React 规则的免费错误报告。
不要去问编译器是否处于运行状态,而应关注应用将时间花在了何处;编译器的作用正是处理 React 相关的部分。