原文链接: acusti.ca/blog/2025/1…
自 2017 年以来,我一直在开发高交互性的 React 界面,涵盖可视化编辑器、设计工具这类应用 —— 用户可以在其中拖拽元素、实时调整属性,并且期望每一次交互都能像 Figma 或 Photoshop 一样流畅响应。一次不必要的重渲染,就足以打破直接操作的流畅体验感,导致操作出现延迟、使用体验大打折扣。
过去八年里,我一直刻意训练自己用 useMemo 和 useCallback 的思路来编写代码。我在脑海中形成了一套内在的判断逻辑,能够甄别出所有可能引发过度重渲染的变量,这对我而言早已成为一种本能。
然而,React Compiler 在短短几周内就颠覆了这一切。
手动记忆化的痛点
手动记忆化不仅繁琐枯燥,更会给你编写的每一个组件带来额外的认知负担。你需要反复思考以下这些问题:
- 事件处理函数是否需要用
useCallback进行包装? - 是否需要把这段逻辑提取到单独的
ComponentItem.tsx文件中,只为了在.map(...)遍历中稳定props? - 是否需要提升这个样式对象的作用域,或者用
useMemo来包裹它? - 上下文提供者是否会触发下游组件不必要的重渲染?
一旦判断失误,要么会严重拖累项目性能,要么会在代码库中充斥着各种不成熟的提前优化代码。即便判断无误,你也依然是把脑力精力花费在了这些底层琐碎工作上,而非聚焦于产品核心功能的开发。
React Compiler 彻底解决了这一问题。在 Outlyne 公司,我们已经在生产环境中运行它超过六个月了。它已经成为了一款不可或缺的工具,就像热模块替换(HMR)或自动代码格式化工具一样,我再也无法想象没有它的开发工作会是什么样子。
我再也不用去考虑记忆化优化的问题了。那些多年来形成的习惯性思维定式,也已经被彻底抹平。
以上都是好消息。而让我始料未及的是:当 React Compiler 无法编译某个组件时,它会以静默的方式失效。
这种设计理念其实合乎情理。编译器的存在意义是让代码运行得更高效,而非保证代码本身能够运行。如果它无法对某段代码进行优化,就会回退到 React 的标准运行机制,你的应用依然可以正常运转。
但自从我彻底摒弃了手动记忆化的写法后,我才意识到:手动记忆化本质上是一种技术债务。这种多余的复杂度会让组件逻辑变得晦涩难懂,而依赖数组还会带来后续的维护负担。在 React Compiler 普及的开发环境下,手动记忆化更属于典型的过早优化—— 这可是编程领域的万恶之源。我绝不希望自己的代码库中再出现这种写法。
这意味着,如今我需要依赖编译器来成功处理部分特定组件,尤其是那些支撑高频交互逻辑、或是管理高开销上下文提供者的组件。一旦这些组件遭遇编译器的静默失效问题,用户体验就会大打折扣,甚至可能导致部分交互功能彻底崩溃。我们官网首页的打字机动画就曾出现过这种情况。
静默故障的隐患
以上都是好消息。而让我始料未及的是:当 React Compiler 无法编译某个组件时,它会以静默的方式失效。
这种设计理念其实合乎情理。编译器的存在意义是让代码运行得更高效,而非保证代码本身能够运行。如果它无法对某段代码进行优化,就会回退到 React 的标准运行机制,你的应用依然可以正常运转。
但自从我彻底摒弃了手动记忆化的写法后,我才意识到:手动记忆化本质上是一种技术债务。这种多余的复杂度会让组件逻辑变得晦涩难懂,而依赖数组还会带来后续的维护负担。在 React Compiler 普及的开发环境下,手动记忆化更属于典型的过早优化—— 这可是编程领域的万恶之源。我绝不希望自己的代码库中再出现这种写法。
这意味着,如今我需要依赖编译器来成功处理部分特定组件,尤其是那些支撑高频交互逻辑、或是管理高开销上下文提供者的组件。一旦这些组件遭遇编译器的静默失效问题,用户体验就会大打折扣,甚至可能导致部分交互功能彻底崩溃。我们官网首页的打字机动画就曾出现过这种情况。
我们将它从 SSE(服务器发送事件)重构为原生 fetch API,同时在 try 代码块中添加了带有空值合并运算符的 try/catch 异常捕获语句。这一改动导致该代码与 React Compiler 不兼容,进而引发了一个诡异的重渲染循环 —— 在这个循环中,输入框的 ref 回调函数出现了频繁触发且执行异常的问题。
未公开文档的 ESLint 规则
在深入研读 react-compiler-babel-plugin 这款 Babel 插件的源码后,我找到了对应的解决方案:
case ErrorCategory.Todo: {
return {
category,
severity: ErrorSeverity.Hint,
name: 'todo',
description: 'Unimplemented features',
preset: LintRulePreset.Off,
};
}
该规则的名称为 todo,因此在大多数配置中(除非你为 eslint-plugin-react-hooks 插件配置了自定义规则名称),这条规则的完整名称是 react-hooks/todo。我在所有可查询的官方资料中都未找到关于它的文档记录(例如 React 编译器官方列出的那些 ESLint 规则),但将该规则设置为错误级别(error)并启用后,一旦遇到编译器目前无法处理的语法,它就会终止包含该语法的组件对应的项目构建流程。
配置完成后,以我官网首页的案例为例,相关代码如下:
const handleGeneration = useEffectEvent(async (fetchURL: string) => {
try {
const response = await fetch(fetchURL);
const data = (await response.json()) as { response?: string };
const finalResult = (data.response ?? '').trim();
const prompt = getPromptFromResponse(finalResult);
if (!prompt) {
handleError();
} else {
setPromptSuggestion(prompt);
setEventSourceURL('');
}
} catch (error) {
logError('Home fetch error', error);
}
});
最终触发了如下这个 ESLint 校验错误:
/outlyne/app/components/Home.tsx
86:34 error Todo: Support value blocks (conditional, logical, optional chaining, etc) within a try/catch statement
/outlyne/app/components/Home.tsx:86:34
84 | const response = await fetch(fetchURL);
85 | const data = (await response.json()) as { response?: string }; >
86 | const finalResult = (data.response ?? '').trim(); | ^^^^ Support value blocks (conditional, logical, optional chaining, etc) within a try/catch statement
87 | const prompt = getPromptFromResponse(finalResult);
88 | if (!prompt) {
89 | handleError();
下面就来介绍它的配置方法:
import reactHooks from 'eslint-plugin-react-hooks';
export default [
{
files: ['**/*.{js,jsx,ts,tsx}'],
plugins: { 'react-hooks': reactHooks },
// ...
rules: {
// spread the preset to avoid overwriting it from the specific rules below
...reactHooks.configs.recommended.rules,
//
https://github.com/facebook/react/blob/3640f38/compiler/packages/babel-plugin-react-compiler/src/CompilerError.ts#L807-L1111
'react-hooks/todo': 'error',
// other useful rules:
'react-hooks/capitalized-calls': 'error', // avoid calling capitalized functions (should use JSX)
'react-hooks/hooks': 'error', // largely reimplements the "rules-of-hooks" non-compiler rule
'react-hooks/rule-suppression': 'error', // validates against suppression of other rules 'react-hooks/syntax': 'error', // validates against invalid syntax
'react-hooks/unsupported-syntax': 'error',// `warn` by default, use `error` to break the build
// ...
}
},
];
启用这个规则后,你会惊讶地发现有大量组件无法通过编译优化。在我摸清 React Compiler 目前尚不支持的代码模式之前,我项目中就有超过一百个组件无法被它编译优化。
哪些情况会导致编译器失效
我遇到的最常见的不被支持的代码模式是:先解构 props,随后再对解构后的属性进行修改。
以下这种写法会导致编译优化失效:
function MyComponent({ value }) {
// If value is undefined, fall back to state
value = value ?? someStateValue;
// Or normalize the value
value = normalizeValue(value);
// Use value...
}
庆幸的是,对应的修复方法简洁优雅,而且可以说,这本身也是一种代码优化 —— 只需创建一个新变量,即可避免修改解构后的 props 属性。
function MyComponent({ value: valueFromProps }) {
const value = valueFromProps ?? someStateValue;
// Use value...
}
另一项限制:带有复杂逻辑的 try/catch 代码块。如果你的组件在 try/catch 中执行异步操作,那么不能使用以下写法:
- 在
try或catch代码块中使用条件判断语句 - 三元表达式、可选链运算符(
?.)或空值合并运算符(??) throw抛出语句
其中 “禁止使用条件判断” 这一点着实令人头疼。通常情况下,当我开发一个执行可能会抛出异常的操作的组件时,要么在 try 块、要么在 catch 块中,总会包含一些条件判断逻辑。
try {
const response = await fetch(url);
if (response.ok) { // Breaks compilation
setResponse(await response.json());
} else {
setError(`Error ${response.status}`);
}
} catch (error) {
setError(`${error}`);
}
显然,这些都只是暂时的限制 —— 从这条 ESLint 规则的名称(“todo”)和描述(“Unimplemented features”,即 “未实现的功能”)中便可看出这一点。我相信,即便不是所有这些限制,绝大多数也终将被解决。不过值得一提的是,针对「支持在 try/catch 块内使用 ThrowStatement」这条 todo 错误,其源码前面附有这样一条注释:
/*
* NOTE: we could support this, but a `throw` inside try/catch is using exceptions
* for control-flow and is generally considered an anti-pattern. we can likely
* just not support this pattern, unless it really becomes necessary for some reason.
*/
这么说来,或许并非所有限制都能被解决?颇具讽刺意味的是,为了规避「Support value bloks……」这一错误,我在 try 代码块中使用了不安全的属性访问方式,隐晦地依赖抛出的异常来控制程序流程。
尽管如此,在此期间,我发现自己竟然在刻意记住这些限制条件 —— 就像我以前那样,牢记那些早已内化于心的、用于防止 React 重渲染的优化技巧最佳实践。而这绝对不是我想要的结果。
借助 ESLint 校验的力量
这也是为什么这条 ESLint 规则如此有价值:它让我无需再去刻意记忆这些代码模式。但有些组件所使用的代码模式,我并不愿意仅仅为了迎合编译器而将其复杂化。
对于这类组件,我会显式地禁用这条规则:
/* eslint-disable react-hooks/todo */
function NonCriticalPathComponent() {
// This component doesn't need to be compiled for the app to perform well, and I'm not
// willing to refactor the try/catch logic
}
这种方法实现了两全其美:
- 核心组件必须通过编译优化,否则项目构建会直接终止(杜绝静默故障流入生产环境)
- 非核心组件可以采用任何能让代码更简洁清晰的写法(无需为兼容编译器复杂化代码)
- 无需考虑任何记忆化优化的问题(彻底摆脱手动优化的认知负担)
是否应该使用 React Compiler?
当然应该!尤其是当你在开发对性能有要求的高交互性界面时,单是这份认知负担的减轻,就值得一试。
但在使用前要明确一点:编译器默认情况下会以静默方式失效。如果你的项目中存在一些核心代码流程,其中的组件必须得到妥善的记忆化优化,那么就配置好这条 ESLint 规则,让项目在编译失败时直接终止构建。随后,你可以针对哪些组件需要编译优化、哪些不需要,做出理性的取舍决策。
这些限制都是暂时的,而它给 UI 开发方式带来的变革,却是永久性的。