【翻译】React 编译器的静默故障(及修复方案)

27 阅读10分钟

原文链接: acusti.ca/blog/2025/1…

作者:Andrew Patton

自 2017 年以来,我一直在开发高交互性的 React 界面,涵盖可视化编辑器、设计工具这类应用 —— 用户可以在其中拖拽元素、实时调整属性,并且期望每一次交互都能像 Figma 或 Photoshop 一样流畅响应。一次不必要的重渲染,就足以打破直接操作的流畅体验感,导致操作出现延迟、使用体验大打折扣。

过去八年里,我一直刻意训练自己用 useMemouseCallback 的思路来编写代码。我在脑海中形成了一套内在的判断逻辑,能够甄别出所有可能引发过度重渲染的变量,这对我而言早已成为一种本能。

然而,React Compiler 在短短几周内就颠覆了这一切。

手动记忆化的痛点

手动记忆化不仅繁琐枯燥,更会给你编写的每一个组件带来额外的认知负担。你需要反复思考以下这些问题:

  • 事件处理函数是否需要用 useCallback 进行包装?
  • 是否需要把这段逻辑提取到单独的 ComponentItem.tsx 文件中,只为了在 .map(...) 遍历中稳定 props
  • 是否需要提升这个样式对象的作用域,或者用 useMemo 来包裹它?
  • 上下文提供者是否会触发下游组件不必要的重渲染?

一旦判断失误,要么会严重拖累项目性能,要么会在代码库中充斥着各种不成熟的提前优化代码。即便判断无误,你也依然是把脑力精力花费在了这些底层琐碎工作上,而非聚焦于产品核心功能的开发。

React Compiler 彻底解决了这一问题。在 Outlyne 公司,我们已经在生产环境中运行它超过六个月了。它已经成为了一款不可或缺的工具,就像热模块替换(HMR)或自动代码格式化工具一样,我再也无法想象没有它的开发工作会是什么样子。

我再也不用去考虑记忆化优化的问题了。那些多年来形成的习惯性思维定式,也已经被彻底抹平。

以上都是好消息。而让我始料未及的是:当 React Compiler 无法编译某个组件时,它会以静默的方式失效

这种设计理念其实合乎情理。编译器的存在意义是让代码运行得更高效,而非保证代码本身能够运行。如果它无法对某段代码进行优化,就会回退到 React 的标准运行机制,你的应用依然可以正常运转。

但自从我彻底摒弃了手动记忆化的写法后,我才意识到:手动记忆化本质上是一种技术债务。这种多余的复杂度会让组件逻辑变得晦涩难懂,而依赖数组还会带来后续的维护负担。在 React Compiler 普及的开发环境下,手动记忆化更属于典型的过早优化—— 这可是编程领域的万恶之源。我绝不希望自己的代码库中再出现这种写法。

这意味着,如今我需要依赖编译器来成功处理部分特定组件,尤其是那些支撑高频交互逻辑、或是管理高开销上下文提供者的组件。一旦这些组件遭遇编译器的静默失效问题,用户体验就会大打折扣,甚至可能导致部分交互功能彻底崩溃。我们官网首页的打字机动画就曾出现过这种情况。

静默故障的隐患

以上都是好消息。而让我始料未及的是:当 React Compiler 无法编译某个组件时,它会以静默的方式失效

这种设计理念其实合乎情理。编译器的存在意义是让代码运行得更高效,而非保证代码本身能够运行。如果它无法对某段代码进行优化,就会回退到 React 的标准运行机制,你的应用依然可以正常运转。

但自从我彻底摒弃了手动记忆化的写法后,我才意识到:手动记忆化本质上是一种技术债务。这种多余的复杂度会让组件逻辑变得晦涩难懂,而依赖数组还会带来后续的维护负担。在 React Compiler 普及的开发环境下,手动记忆化更属于典型的过早优化—— 这可是编程领域的万恶之源。我绝不希望自己的代码库中再出现这种写法。

这意味着,如今我需要依赖编译器来成功处理部分特定组件,尤其是那些支撑高频交互逻辑、或是管理高开销上下文提供者的组件。一旦这些组件遭遇编译器的静默失效问题,用户体验就会大打折扣,甚至可能导致部分交互功能彻底崩溃。我们官网首页的打字机动画就曾出现过这种情况。

image.png

acusti.ca/media/outly…

我们将它从 SSE(服务器发送事件)重构为原生 fetch API,同时在 try 代码块中添加了带有空值合并运算符的 try/catch 异常捕获语句。这一改动导致该代码与 React Compiler 不兼容,进而引发了一个诡异的重渲染循环 —— 在这个循环中,输入框的 ref 回调函数出现了频繁触发且执行异常的问题。

image.png

acusti.ca/media/outly…

未公开文档的 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 中执行异步操作,那么不能使用以下写法:

  • trycatch 代码块中使用条件判断语句
  • 三元表达式、可选链运算符(?.)或空值合并运算符(??
  • 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 开发方式带来的变革,却是永久性的。