头图

有了React 19新编译器,真的就没有性能问题了吗?

原文链接:How React Compiler Performs on Real Code
作者:Adevnadia
译者:倔强青铜三

前言

大家好,我是倔强青铜三。我是一名热情的软件工程师,我热衷于分享和传播IT技术,致力于通过我的知识和技能推动技术交流与创新,欢迎关注我,微信公众号:倔强青铜三。欢迎点赞、收藏、关注,一键三连!!!

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

近几年,React 社区最令人兴奋和期待的工具之一被称为 React 编译器(之前称为 React Forget)。有充分的理由。编译器的核心前提是它将提高我们 React 应用的整体性能。并且作为一个不错的副作用 - 我们将不再需要担心重新渲染、记忆化以及 useMemouseCallback 钩子。

但 React 性能的问题究竟是什么呢?为什么有一半的开发人员极度希望忘记记忆化和这些钩子?这个承诺有多现实?

本文试图回答这些问题。它总结了编译器试图解决的问题,没有编译器时是如何解决的,以及编译器在真实代码上的工作方式 - 我将其运行在我工作了一段时间的应用程序上,并测量了结果。

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

那么,这里的确切问题是什么?

大多数 React 应用都旨在向用户显示一些交互式 UI(用户界面)。当用户与 UI 交互时,我们通常希望根据该交互更新页面,提供一些新信息。在 React 中,我们触发所谓的重新渲染。

img

React 中的重新渲染通常是级联的。每次触发组件的重新渲染时,它都会触发每个嵌套组件的重新渲染,这会触发每个组件内部的重新渲染,依此类推,直到到达 React 组件树的末尾。

img

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

img

解决这种缓慢的一种方法是阻止重新渲染链的发生。

img

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

记忆化从 React.memo 开始 - 一个由 React 团队提供给我们的高阶组件。要使其工作,我们所需要做的就是用它包装我们的原始组件,并在其位置渲染“记忆化”组件。

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

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

  // 用原始组件的位置渲染记忆化组件
  return ;
};

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

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

对于原始值,如字符串和布尔值,这很容易:我们不需要做任何事情,只是不改变这些值。

const VerySlowComponentMemo = React.memo(VerySlowComponent);

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

  // 重新渲染之间“data”字符串保持不变
  // 因此记忆化将按预期工作
  return ;
};

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

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

const VerySlowComponentMemo = React.memo(VerySlowComponent);

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

  // “data”对象在每次重新渲染时都被重新创建
  // 记忆化在这里被破坏了
  return ;
};

为了解决这个问题,我们有两个钩子:useMemouseCallback。这两个钩子将在重新渲染之间保留引用。useMemo 通常用于对象和数组,useCallback 用于函数。将 props 包装在这些钩子中就是我们通常所说的“记忆化 props”。

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

  // 这里的 props 在重新渲染之间不再变化了
  // 记忆化将正确工作
  return (

  );
};

现在,当 React 在渲染树中遇到 VerySlowComponentMemo 组件时,它将检查其 props 是否已更改,会看到它们都没有变化,并跳过其重新渲染。应用不再慢了。

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

结果,干脆完全不做或者到处记忆化一切可能更简单。这反过来又将我们美丽的代码变成了一个难以理解和阅读的 useMemouseCallback 的混乱堆。

img

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

React 编译器 🚀 来救援

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

在构建时,它试图将“正常”的 React 代码转换为默认记忆化组件、它们的 props 和钩子依赖项的代码。最终结果是“正常”的 React 代码,表现得就好像一切都被包裹在 memouseMemouseCallback 中。

几乎!实际上,它执行更复杂的转换,并尝试尽可能高效地适应代码。例如,像这样的代码:

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 中看到这一点:

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

第二个示例中,我向慢速组件添加了更多的 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>
  );
};

编译器在这里再次完美地执行了任务,结果与第一个示例相同:一切都被正确记忆化,对话框没有延迟地弹出。

第三个示例中,我将另一个组件作为子组件传递给慢速组件,如下所示:

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>
  );
};

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

到目前为止,编译器在 3 个案例中都做得很好。🏆🏆🏆

但是像这样的小示例是“容易的”。为了正确测试编译器,我将其运行在我工作了一段时间的应用程序上。

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

这个应用程序是全新的,完全 TypeScript 化,没有遗留代码,只有钩子,一切都是最新的最佳实践(或多或少)。它有一个着陆页,几个内部页面,大约有 15,000 行代码。不是有史以来最大的应用程序,但足够进行适当的测试,我认为。

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

成功编译了 363 个组件中的 361 个。

没有发现不兼容的库的使用。

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

是时候回答这些问题了。

初始加载性能和 React 编译器

我测量的第一个页面是应用程序的“着陆”页面。这些是在启用编译器之前的统计数据:

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

并测量结果:

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

为了确定,我在几个页面上运行了它,结果或多或少相同。一些数字略有增加,一些甚至略有下降。没有剧烈变化。

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

交互性能和 React 编译器

测量第一页

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

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

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

总阻塞时间从 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。

编译器成功地接管了这一点,这非常酷。但同时,它也有点像作弊 😅 更常见的情况是到处都有一些中小型组件。所以,我测量了下一个页面,它感觉更接近它。

测量第二页

在下一页上,我有一些组件在标题中,一些页脚,还有一些卡片列表在中间。在标题中,有一些“快速过滤器”:按钮、输入字段、复选框。当我选择按钮时,我会看到所有包含按钮的卡片列表。当我启用复选框时 - 列表会更新,包括还包含复选框的额外卡片。

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

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

阻塞数字从 130ms 降到了 90ms。仍然非常好,更现实!然而,如果页面上的所有重新渲染都被消除了,我期望数字会下降得更多。向已经存在的列表中添加几张卡片应该是几乎瞬间的。

我再次检查了这里的重新渲染情况,不幸的是 - 是的。尽管大部分重新渲染已经被消除,但卡片本身,恰好是页面上最重的,仍然重新渲染。

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

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

编译器在这里没有错 - 代码本身有些问题。

正如我们已经知道的,如果记忆化组件上的任何一个 prop 发生变化,那么记忆化就不会起作用,重新渲染就会发生。所以,一定有 prop 出了问题。仔细检查后,它们都变成了原始字符串,除了这一个:example.previewUrl。这原来是个对象:

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

所以,这个对象在重新渲染之间的引用发生了变化。但是为什么呢?它来自 data 变量,该变量又来自使用 React Query 库查询 REST 端点:

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 组件收到了其非原始 prop 的一个新引用,它的记忆化不起作用,它仍然重新渲染,即使数据从技术上讲是相同的。

这很容易验证:我只需要将该对象转换为原始 prop 以消除变化的引用。

{data?.data?.map((example) => {
  return (
    <GalleryCardMemo
      href={`/examples/code-examples/${example.key}`}
      key={example.key}
      title={example.name}
      // 传递原始值而不是整个对象
      previewLight={example.previewUrl.light}
      previewDark={example.previewUrl.dark}
    />
  );
})}

确实,所有重新渲染在完成后都完全停止了!

最后一步:测量看看我的更改实际上有多大影响。

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

我认为这可以回答第二个最常见的问题:编译器能否对交互性能产生影响?答案:它可以,它是明显的,但因页面而异,如果他们真的努力,人类仍然比它更好。

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

是时候回答最后的问题了。编译器是否足够智能,真的能捕获一切?我们已经看到答案可能是不。

但为了进一步测试它,我收集了我应用程序中最明显的重新渲染列表,并检查在我启用编译器后还有多少重新渲染仍然存在。

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

  • 有两个完全 100% 修复了所有重新渲染
  • 有两个一个也没有修复
  • 其余的在两者之间,就像上面的调查一样。

没有修复的案例是编译器因为这一行使组件失效:

const filteredData = fuse.search(search);

这只是这一行。我甚至没有在任何地方使用 filteredData 变量。这里的 fuse 是一个外部模糊搜索库。所以,这种行为最可能的原因是该库正在做一些与编译器不兼容的事情,这是我无法控制的。

所以,编译器能否绝对捕获每一个重新渲染的答案是明确的。不。总会有一些外部依赖项与编译器本身或记忆化规则不兼容。

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

或者我拥有的代码,它不完全是《错误》的,但只是没有为记忆化进行微调。

快速总结

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

  • 初始加载性能 - 我没有看到负面影响。
  • 交互性能 - 它们有所改善,有些改善了很多,有些改善了一点。
  • 能否捕获所有重新渲染 - 不,永远不会。

这是否意味着问题“我们能否很快忘记记忆化?”的答案是“不”?不一定!这取决于。

如果你的应用程序的性能不是世界上最重要的事情,或者它是“还可以,可以更好,但我懒得管”,启用编译器可能会使其稍微更好甚至足够好,成本很低。“足够好”的定义将由你来决定。但我认为,对于大多数人来说,打开编译器并忘记记忆化就足够了。

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

对于你来说,答案是不 - 你不能忘记它们。抱歉。你必须知道我们现在需要知道的一切,再加上 - 编译器做什么以及如何做。所以你的工作将变得稍微困难一些。

但我认为实际上需要知道所有这些的人非常少。

如果你想要成为那些人之一,我写了很多关于这个主题的文章,发布了一些 YouTube 视频,甚至写了一本书,其中一半专门讨论重新渲染以及如何摆脱它们。检查它们 😎


最初发表在 https://www.developerway.com。这个网站有更多像这样的文章 😉

查看高级 React 书籍,将你的 React 知识提升到下一个水平。

最后感谢阅读!欢迎关注我,微信公众号倔强青铜三。欢迎点赞收藏关注,一键三连!!!

倔强青铜三
23 声望0 粉丝