译:React 编译器在真实代码中的表现

原文:https://www.developerway.com/posts/how-react-compiler-performs-on-real-code
作者:Nadia Makarevich
译者:ChatGPT 4 Turbo

编者注:这篇文章深入探讨了 React 编译器在实际项目中的表现。作者通过实际测试发现:1) 编译器对初始加载性能几乎没有影响,2) 对交互性能有明显提升,某些场景下阻塞时间从 280ms 降到 0ms,3) 但编译器并不能捕获所有的重渲染情况,特别是在使用外部库或遗留代码时。总的来说,对于大多数开发者来说开启编译器就足够了,但如果你需要榨干每一毫秒的性能,可能还是需要手动优化。文章通过真实案例展示了编译器的优缺点,对于想了解这个新特性的开发者来说非常有价值。

React 编译器在真实代码中的表现

探索 React 编译器对初始加载和交互性能的影响。包含数据。在真实应用中测量。

[Youtube channelReact 编译器在真实代码中的表现](

React 编译器在真实代码中的表现

本文遵循我在 “React Advanced” 会议上演讲的内容和结构。如果你更喜欢观看而不是阅读,可以查看:[React 编译器在 React 代码中的表现](


在过去几年中,React 社区最令人兴奋和期待的工具之一就是 React 编译器(之前称为 React Forget)。这是有原因的。编译器的核心承诺是它将提升我们 React 应用的整体性能。作为一个很好的副作用 – 我们将不再需要担心重渲染、记忆化以及 useMemouseCallback 钩子。

但 React 的性能到底有什么问题?为什么一半的开发者都迫切想要忘记记忆化和这些钩子?这个承诺有多现实?

这就是本文试图回答的问题。它总结了编译器试图解决的问题,在没有编译器的情况下如何解决这些问题,以及编译器在真实代码中的表现 – 我在一个我已经工作了一段时间的应用程序上运行它并测量了结果。

重渲染和记忆化在 React 中的问题

那么,这里到底有什么问题?

大多数 React 应用都是为了向用户展示一些交互式 UI(用户界面)而编写的。当用户与 UI 交互时,我们通常希望用一些从该交互中得到的新信息来更新页面。要在 React 中做到这一点,我们会触发所谓的 重渲染

re-renders.png

React 中的重渲染通常是 级联的。每当触发组件的重渲染时,它都会触发其内部每个嵌套组件的重渲染,这又会触发其内部每个组件的重渲染,如此循环,直到达到 React 组件树的末端。

re-renders-in-motion.gif

通常,这不是什么需要担心的事情 – React 现在相当快。然而,如果这些下游重渲染影响到一些重量级组件或只是重渲染太多的组件,这可能会导致性能问题。应用会变得缓慢。

slow-app.png

修复这种缓慢的一种方法是阻止重渲染链的发生。

re-renders-stopped.png

我们有多种技术来做到这一点 – 向下移动状态将组件作为 props 传递,将状态提取到类似 Context 的解决方案中以绕过 props 钻取,等等。当然还有记忆化。

记忆化从 React.memo 开始 – 这是 React 团队给我们的一个高阶组件。要使其工作,我们只需要用它包装我们的原始组件,并在其位置渲染"记忆化"的组件。

// 在这里记忆化一个慢组件
const VerySlowComponentMemo = React.memo(VerySlowComponent);

const Parent = () => {
  // 在某处触发重渲染

  // 在原始组件的位置渲染记忆化的组件
  return <VerySlowComponentMemo />;
};

现在,当 React 在树中到达这个组件时,它会停下来检查其 props 是否发生了变化。如果没有 props 发生变化,重渲染将被停止。然而,如果即使一个 prop 发生了变化,React 将继续重渲染,就好像没有记忆化一样!

这意味着要让 memo 正常工作,我们需要确保所有 props 在重渲染之间 保持完全相同

对于原始值,如字符串和布尔值,这很容易:我们只需要不改变这些值就可以了。

const VerySlowComponentMemo = React.memo(VerySlowComponent);

const Parent = () => {
  // 在某处触发重渲染

  // 重渲染之间的 "data" 字符串保持不变
  // 所以记忆化将按预期工作
  return <VerySlowComponentMemo data="123" />;
};

然而,非原始值,如对象、数组和函数,需要一些帮助。

React 使用引用相等性来检查重渲染之间的任何内容。如果我们在组件内部声明这些非原始值,它们将在每次重渲染时重新创建,对它们的引用将改变,记忆化将不起作用。

const VerySlowComponentMemo = React.memo(VerySlowComponent);

const Parent = () => {
  // 在某处触发重渲染

  // "data" 对象在每次重渲染时都会重新创建
  // 这里的记忆化失效了
  return <VerySlowComponentMemo data={{ id: "123" }} />;
};

要解决这个问题,我们有两个钩子:useMemouseCallback。这两个钩子都会在重渲染之间保持引用。useMemo 通常用于对象和数组,而 useCallback 用于函数。用这些钩子包装 props 就是我们通常所说的"记忆化 props"。

const Parent = () => {
  // { id:"123" } 对象的引用现在被保留了
  const data = useMemo(() => ({ id: "123" }), []);
  // 函数的引用现在被保留了
  const onClick = useCallback(() => {}, []);

  // 这里的 props 在重渲染之间不再改变
  // 记忆化将正确工作
  return (
    <VerySlowComponentMemo
      data={data}
      onClick={onClick}
    />
  );
};

现在,当 React 在渲染树中遇到 VerySlowComponentMemo 组件时,它会检查其 props 是否发生了变化,会发现没有变化,并且会跳过其重渲染。应用不再缓慢了。

这是一个非常简化的解释,但它已经相当复杂了。让情况更糟的是,如果我们通过一系列组件传递这些记忆化的 props,它会变得更加复杂 – 对它们的任何更改都需要来回追踪这些链,以确保引用在中间没有丢失。

结果,要么完全不做,要么到处都记忆化以防万一。这反过来又会将我们漂亮的代码变成一团难以理解和阅读的 useMemouseCallback 混乱。

app-under-re-renders.png

解决这种情况就是 React 编译器的主要承诺。

React 编译器 🚀 来救场

React 编译器是由 React 核心团队开发的 Babel 插件,Beta 版本于 2024 年 10 月发布

在构建时,它试图将"普通"的 React 代码转换为组件、它们的 props 和钩子的依赖项默认被记忆化的代码。最终结果是"普通"的 React 代码,表现得就像所有东西都被包装在 memouseMemouseCallback 中一样。

react-compiler-pic.png

差不多!实际上,它做了更复杂的转换,并试图尽可能高效地适应代码。例如,像这样的东西:

function Parent() {

  const data = { id: "123" };
  const onClick = () => {

  };

  return <Component onClick={onClick} data={data} />
}

将被转换成这样:

function Parent() {
  const $ = _c(1);
  let t0;
  if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
    const data = {
      id: "123",
    };
    const onClick = _temp;
    t0 = <Component onClick={onClick} data={data} />;
    $[0] = t0;
  } else {
    t0 = $[0];
  }
  return t0;
}
function _temp() {}

注意 onClick 是如何被缓存为 _temp 变量的,但 data 只是被移到了 if 语句内。你可以在编译器游乐场中进行更多尝试。

它如何工作的机制很有趣,所以如果你想了解更多,React 核心团队有一些视频可供参考,比如[深入了解编译器的演讲](

然而,就本文而言,我更感兴趣的是我们对编译器的期望是否符合现实,以及它是否已经准备好供像我这样的广大公众使用。

当人们听到"编译器将记忆化所有内容"时,几乎每个人都会立即想到的主要问题是:

  • 初始加载性能如何? 反对"默认记忆化所有内容"的一个重要论点一直是它可能会对初始加载产生负面影响,因为当所有内容都被记忆化时,React 必须提前做更多的工作
  • 它是否会产生积极的性能影响? 重渲染到底有多大问题?
  • 它真的能捕获所有重渲染吗? JavaScript 以其流动性和模糊性而闻名。编译器是否足够智能,能真正捕获所有内容?我们真的永远不用再考虑记忆化和重渲染了吗?

为了回答这些问题,我首先在一些合成示例上运行编译器,只是为了确保它确实有效,然后在我正在开发的应用程序的几个页面上运行它。

React 编译器在简单示例中的表现

第一个示例是这样的。

const SimpleCase = () => {
  const [isOpen, setIsOpen] = useState(false);

  return (
    <div>
      <button onClick={() => setIsOpen(!isOpen)}>toggle</button>
      {isOpen && <Dialog />}
      <VerySlowComponent />
    </div>
  );
};

我有一个带对话框的组件,一个用于这个对话框的状态,一个可以打开它的按钮,以及下面某处的一个 VerySlowComponent。假设它需要 500ms 来重渲染。

当状态改变时,正常的 React 行为是重渲染所有内容。结果,由于慢组件的原因,对话框会延迟弹出。如果我想用记忆化来修复它,我必须用 memo 包装慢组件:

const VerySlowComponentMemo = React.memo(VerySlowComponent);

const SimpleCase = () => {
  const [isOpen, setIsOpen] = useState(false);

  return (
    <div>
      <button onClick={() => setIsOpen(!isOpen)}>toggle</button>
      {isOpen && <Dialog />}
      <VerySlowComponentMemo />
    </div>
  );
};

让我们改为启用编译器。首先,我在 React Dev Tools 中看到这个:

react-compiler-working.png

这意味着 ButtonVerySlowComponent 被编译器记忆化了。如果我在 VerySlowComponent 内部添加一个 console.log,当我改变状态时它不会被触发。这意味着记忆化确实有效,而且工作正确,这里的性能问题已经解决。当我触发对话框时,它会立即弹出,没有延迟。

compiler-in-motion.gif

第二个示例中,我为慢组件添加了更多的 props:

const SimpleCase = () => {
  const [isOpen, setIsOpen] = useState(false);

  return (
    <div>
      <button onClick={() => setIsOpen(!isOpen)}>toggle</button>
      {isOpen && <Dialog />}
      // 添加 "data" 和 "onClick" props
      <VerySlowComponent data={{ id: "123" }} onClick={() => {}} />
    </div>
  );
};

手动的话,我需要使用所有三个工具来记忆化:memouseMemouseCallback

const SimpleCase = () => {
  const [isOpen, setIsOpen] = useState(false);
  const data = useMemo(() => ({ id: "123" }), []);
  const onClick = useCallback(() => {}, []);

  return (
    <div>
      <button onClick={() => setIsOpen(!isOpen)}>toggle</button>
      {isOpen && <Dialog />}
      <VerySlowComponentMemo data={data} onClick={onClick} />
    </div>
  );
};

编译器在这里再次完美表现,结果与第一个示例相同:一切都被正确记忆化,Dialog 弹出时没有延迟。

第三个示例中,我将另一个组件作为子组件传递给慢组件,像这样:

const SimpleCase = () => {
  const [isOpen, setIsOpen] = useState(false);

  return (
    <div>
      <button onClick={() => setIsOpen(!isOpen)}>toggle</button>
      {isOpen && <Dialog />}

      <!-- 现在接受子组件 -->
      <VerySlowComponent>
        <Child />
      </VerySlowComponent>
    </div>
  );
};

你知道如何正确记忆化这个东西吗?大多数人会认为是这样:

const VerySlowComponentMemo = React.memo(VerySlowComponent);
const ChildMemo = React.memo(Child);

const SimpleCase = () => {

  return (
    <div>
      ...
      <VerySlowComponentMemo>
        <ChildMemo />
      </VerySlowComponentMemo>
    </div>
  );
};

不幸的是,这是不正确的。这里的树状语法只是 children prop 的语法糖。上面的代码示例可以很容易地重写为:

const VerySlowComponentMemo = React.memo(VerySlowComponent);
const ChildMemo = React.memo(Child);

const SimpleCase = () => {

  return (
    <div>
      ...
      <VerySlowComponentMemo children={<ChildMemo />} />
    </div>
  );
};

同样,这里的 <ChildMemo /> 也只是语法糖,表示 React.createElement 函数调用的结果,这是一个 type 属性指向 ChildMemo 函数的对象:

const VerySlowComponentMemo = React.memo(VerySlowComponent);
const ChildMemo = React.memo(Child);

const SimpleCase = () => {

  return (
    <div>
      ...
      <VerySlowComponentMemo children={{ type: ChildMemo }} />
    </div>
  );
};

我们这里不幸地有一个非记忆化的对象作为记忆化组件的 prop。记忆化不起作用,VerySlowComponentMemo 将在每次状态改变时重新渲染。

正确的记忆化方式是像处理任何其他对象一样:

const VerySlowComponentMemo = React.memo(VerySlowComponent);
const ChildMemo = React.memo(Child);

const SimpleCase1 = () => {
  const children = useMemo(() => <ChildMemo />, []);

  return (
    <div>
      ...
      <VerySlowComponentMemo>
        {children}
      </VerySlowComponentMemo>
    </div>
  );
};

在未记忆化的第三个示例上启用编译器的结果与之前完全相同:编译器成功地正确记忆化了它,性能问题得到了解决。

到目前为止,编译器三战全胜。🏆🏆🏆

但像这样的小示例是"容易的"。要正确测试编译器,我在我已经工作了一段时间的真实应用的几个页面上运行它。

React 编译器在真实应用中的表现

这个应用是全新的,完全使用 TypeScript,没有遗留代码,只使用钩子,一切都是最新的最佳实践(或多或少)。它有一个登录页面,几个内部页面,大约有 15k 行代码。虽然不是最大的应用,但我认为足以进行适当的测试了。

在打开编译器之前,我运行了 React 团队提供的健康检查和 eslint 规则。以下是健康检查的结果:

Successfully compiled 361 out of 363 components.

Found no usage of incompatible libraries.

而且我没有任何 eslint 规则违反。

我使用 Lighthouse 来测量初始加载和交互性能。所有测量都是在"移动"模式下的生产构建中进行的,CPU 速度降低 4 倍。我运行了所有测试 5 次并提取了平均值。

是时候回答这些问题了。

初始加载性能和 React 编译器

我测量的第一个页面是应用的"登录"页面。以下是启用编译器之前的统计数据:

initial-load-stats-before.png

启用编译器并确保它工作:

initial-load-compiler-works.png

并测量结果:

intial-load-stats-after.png

第一张图是之前,第二张是之后。如你所见,结果几乎完全相同。

为了确保,我在更多页面上运行了它,结果或多或少都是一样的。有些数字会略微增加,有些甚至会减少。没有什么剧烈的变化。

我想我可以为编译器再添加一个胜利(🏆🏆🏆🏆)并回答我正在调查的第一个问题:编译器似乎对初始加载的影响最小或没有影响。这很好。尽管记忆化了所有内容,但它并没有让事情变得更糟。

交互性能和 React 编译器

测量第一个页面

为了测量交互性能,我从一个"组件"页面开始。在这个页面上,我展示了我正在开发的 UI 组件库的 React 组件预览。预览可以是任何东西,从按钮到整个页面。我测量了"设置"页面的预览。

预览页面有"浅色"和"深色"模式切换。如下所示,切换模式会导致预览重新渲染 – 绿线表示这一点。

components-preview-re-renders.gif

这个交互在启用编译器前后的性能表现如下:

settings-page-re-renders-performance.png

总阻塞时间从 280ms 降到了字面意思上的零!

这非常令人印象深刻。但它也让我很好奇:这是怎么发生的?我在代码中做错了什么?

这个页面的代码看起来是这样的:

export default function Preview() {
  const renderCode = useRenderCode();
  const darkMode = useDarkMode();

  return (
    <div
      className={merge(
        darkMode === "dark" ? "dark bg-buGray900" : "bg-buGray25",
      )}
    >
      <LiveProvider
        code={renderCode.trim()}
        language="tsx"
      >
        <LivePreview />
      </LiveProvider>
    </div>
  );
}

LiveProvider 块是将作为字符串传递给它的整个"设置"组件渲染的东西。我这里实际上有我在开始时探索的简单示例之一 – 一个很慢的组件(LiveProvider)带有一些 props。

编译器成功捕获到了这一点,这很酷。但也感觉有点像作弊 😅 更常见的场景是到处都有一堆小到中等大小的组件。所以,我测量了下一个页面,这个页面感觉更接近这种情况。

测量第二个页面

在下一个页面上,我在头部有一堆组件,一些页脚,中间是一个卡片列表。在头部有一些"快速过滤器":按钮、输入字段、复选框。当我选择按钮时,我会看到所有包含按钮的卡片的列表。当我启用复选框时 – 列表会更新,添加那些也包含复选框的额外卡片。

没有记忆化的情况下,整个页面,包括很长的卡片列表,都会重新渲染。

checkbox-re-renders-before-compiler.gif

在启用编译器前后,将复选框卡片添加到已存在列表的性能表现如下。

checkbox-re-renders-performance.png

阻塞时间从 130ms 降到了 90ms。仍然相当不错,而且更现实!然而,如果该页面上的所有重渲染都被消除了,我本期望数字会降得更多。向已存在的列表中添加几个卡片应该几乎是瞬间完成的。

我检查了这里的重渲染情况,不幸的是 – 是的。虽然大多数重渲染都被消除了,但卡片本身(恰好是页面上最重的部分)仍在重新渲染。

checkbox-re-renders-after-compiler.gif

再次检查代码 – 这是个谜。因为这是你在 React 中能看到的最标准的代码。只是遍历一个数据数组并在内部渲染 GalleryCard 项。

{data?.data?.map((example) => {
    return (
      <GalleryCard
        href={`/examples/code-examples/${example.key}`}
        key={example.key}
        title={example.name}
        preview={example.previewUrl}
      />
    );
  })}

我调试编译器问题时做的第一件事是使用经典工具重新实现记忆化。在这种情况下,我需要做的就是将卡片包裹在 React.memo 中,如果代码是好的,现有的卡片应该停止重新渲染,这意味着编译器因为某种原因放弃了该组件。

// somewhere before
const GalleryCardMemo = React.memo(GalleryCardMemo);


// somewhere in render function
{data?.data?.map((example) => {
  return (
    <GalleryCardMemo
      href={`/examples/code-examples/${example.key}`}
      key={example.key}
      title={example.name}
      preview={example.previewUrl}
    />
  );
})}

这并没有发生。

这意味着编译器没有问题 – 代码本身存在问题。

正如我们所知,如果记忆化组件上的任何单个属性发生变化,记忆化将不会生效,并且会重新渲染。因此,某些属性存在问题。仔细检查后,所有属性都变成了原始字符串,除了这个:example.previewUrl。这实际上是一个对象:

{
  light: "/public/light/...",
  dark: "/public/dark/...",
};

所以,对象在重新渲染之间改变了它的引用。但它是如何发生的呢?它来自 data 变量,该变量来自查询 REST 端点时使用 React Query 库 的查询:

const { data } = useQuery({
  queryKey: ["examples", elements.join(",")],
  queryFn: async () => {
    const json = await fetch(`/examples?elements=${elements.join(",")}`);
    const data = await json.json();
  return data;
},
});

React Query 根据提供的 queryKey 缓存从 queryFn 返回的数据。看起来,在我的例子中,我根据选定的元素通过连接 elements 数组来改变键。因此,如果只选择了按钮,键将是 button,如果将复选框添加到列表中,键将变为 button,checkbox

所以,我的理论是 React Query 将这两个键和返回的数据视为完全不同的数据数组。这对我来说很有意义 – 我没有以任何方式指示它那些数组是相同的并且可以更新。

所以,我怀疑的是,当键从 button 变为 button,checkbox 时,查询库获取新数据并将其作为完全新的数组返回,其中所有对象都具有完全新的引用。因此,记忆化 GalleryCard 组件接收其中一个非原始属性的新引用,记忆化对它不起作用,并且它仍然重新渲染,即使数据在技术上是相同的。

验证这一点非常简单:我只需要将该对象转换为原始属性以消除引用变化。

{data?.data?.map((example) => {
  return (
    <GalleryCardMemo
      href={`/examples/code-examples/${example.key}`}
      key={example.key}
      title={example.name}
      // pass primitives values instead of the entire object
      previewLight={example.previewUrl.light}
      previewDark={example.previewUrl.dark}
    />
  );
})}

而且,所有重渲染确实在完成此操作后完全停止了!

最后一步:测量它以查看我的更改实际产生了多少影响。

after-re-renders-are-fixed.png

Boom!阻塞时间降到了零,交互到下一帧的时间减半。这是一个 🎤 下降的情况,我感觉很好。编译器稍微提高了性能,但我做得更好 ✌🏼 💪🏼

我认为这可以回答第二个最常见的问题:编译器是否会影响交互性能?答案是:它可以,但效果因页面而异,而且如果人们真的尝试,人类仍然做得更好。

React 编译器能否捕获所有重渲染?

是时候回答最后一个问题了。编译器是否足够智能,能够真正捕获所有内容?我们已经看到,这里的答案可能是否定的。

但为了进一步测试,我收集了我应用中所有最明显的重渲染,并检查了启用编译器后还剩下多少重渲染。

我确定了 9 个明显的重渲染情况,例如“整个抽屉在标签变化时重新渲染”等等。这是最终结果。在 9 个案例中:

  • 我有两个案例,所有重渲染完全停止了
  • 两个案例中没有任何一个被修复
  • 其余的案例介于两者之间,就像上面的调查一样。

那些没有被修复的案例是编译器因为这一行而放弃组件的:

const filteredData = fuse.search(search);

仅仅这一行。我从未在任何地方使用过 filteredData 变量。fuse 在这里是一个外部模糊搜索库。因此,最可能的原因是该库正在做一些与编译器不兼容的事情,而这超出了我的控制范围。

所以,关于编译器是否可以捕获所有重渲染的问题,答案在这里很明确。它是一个明确的否。总会有一些外部依赖,它们与编译器本身或记忆化规则完全不兼容。

或者会有一些奇怪的遗留代码,编译器不知道如何处理。

或者我写的代码,它不是完全错误的,但也不是为记忆化而优化的。

快速总结

让我们快速总结一下调查的结果和结果。

final-summary.png

  • 初始加载性能 – 我没有看到任何负面影响。
  • 交互性能 – 它们有所改善,有些改善很大,有些改善很小。
  • 能否捕获所有重渲染 – 不能,而且永远不能。

这意味着“我们很快就可以忘记记忆化了吗?”这个问题答案是“不”吗?不一定!这取决于你。

如果应用的性能不是最重要的,或者如果它“还可以,但可以更好,但我懒得去优化”,启用编译器可能会使其稍微更好或甚至足够好。“足够好”的定义取决于你。但我怀疑对于大多数人来说,开启编译器并忘记记忆化就足够了。

然而!如果“足够好”对你来说还不够好,你需要从应用中榨取每一毫秒,欢迎回到手动记忆化。

对于你,答案是“不” – 你不能忘记它们。对不起。你需要知道我们现在需要知道的一切,再加上编译器做了什么以及它是如何工作的。所以你的工作会变得稍微困难一些。

但我怀疑实际上需要知道所有这些的人很少。

如果你想要成为这样的人,我写了很多关于这个话题的文章发布了很多 YouTube 视频,甚至写了一本书,其中一半是关于重渲染以及如何摆脱它们的。查看它们 😉