【译】Josh:为什么 React 会重新渲染(Why React Re-Renders)

838 阅读13分钟

原文地址:why-react-re-renders

作者:Josh Comeau、 译者:程序员阿辉

诚实地说,多年来,我一直在专业地使用 React 工作,但并没有真正了解 React 的重新渲染过程是如何工作的。 😅

当前,这听起来可能不太对……毕竟,当它们的 props 发生变化时,组件不会重新渲染吗?context 呢?

事情是这样的:当一个组件重新渲染时,同时也会重新渲染它的所有后代。

让我们看一个例子: 在这个例子中,我们有三个组件 顶部的 App 渲染 CounterCounter 渲染 BigCountNumber。 在 React 中,每个状态变量都附加到特定的组件实例。在这个例子中,我们有一个状态,count,它与 Counter 组件相关联。每当此状态发生变化时,Counter 都会重新渲染。而且因为 BigCountNumber 是由 Counter 渲染的,所以它也会重新渲染。

这是一个交互式图表(本译文使用 gif 图展示),显示了此机制的运行情况。点击“Increment”按钮触发状态改变:

rerenders.gif

(绿色闪烁表示组件正在重新渲染)

误解 #1:每当状态变量发生变化时,整个应用程序都会重新渲染

好的,让我们澄清一下大误解 #1:每当状态变量发生变化时,整个应用程序都会重新渲染。据我所知一些开发人员认为 React 中的每个状态变化都会强制应用程序范围的渲染,但事实并非如此。重新渲染只会影响拥有状态的组件 + 其后代(如果有)。在此示例中,App 组件不必在 count 状态变量更改时重新渲染。然而,与其把它作为一个规则来记住,不如让我们更进一步,看看是否能弄清楚它为什么会这样工作。React 的“主要工作”是保持应用程序 UI 与 React 状态同步。重新渲染的目的是找出需要更改的内容。

让我们考虑上面的“计数器”示例。当应用程序第一次挂载时,React 会渲染我们所有的组件,并为 DOM 提供以下草图:

<main>
  <p>
    <span class="prefix">Count:</span>
    0
  </p>
  <button>
    Increment
  </button>
</main>
<footer>
  <p>Copyright 2022 Big Count Inc.</p>
</footer>

当用户点击按钮时,count 状态变量从 0 翻转到 1。这对 UI 有何影响?好吧,这就是我们希望从另一个渲染中学到的东西! React 重新运行 CounterBigCountNumber 组件的代码,我们生成了我们想要的 DOM 的新草图:

<main>
  <p>
    <span class="prefix">Count:</span>
    1
  </p>
  <button>
    Increment
  </button>
</main>
<footer>
  <p>Copyright 2022 Big Count Inc.</p>
</footer>

每个渲染都是一个快照,就像相机拍摄的照片一样,根据当前的应用程序状态显示 UI 应该是什么样子。React 玩了一个“找不同”的游戏来找出这两个快照之间的变化。在这种情况下,它看到我们的段落有一个从 0 变为 1 的文本节点,因此它编辑文本节点以匹配快照。对其工作完成感到满意,React 安定下来并等待下一次状态更改。这是核心的 React 循环。

考虑到这个框架,让我们再看看我们的渲染图: rerenders.gif 我们的 count 状态与 Counter 组件相关联。因为数据不能在 React 应用程序中“向上”流动,我们知道这种状态变化不可能影响 <App />。所以我们不需要重新渲染那个组件。但我们确实需要重新渲染 Counter 的子组件 BigCountNumber。这是实际显示 count 状态的组件。如果我们不渲染它,我们将不知道我们段落的文本节点应该从 0 变为 1。我们需要在我们的草图中包含这个组件。重新渲染的目的是弄清楚状态变化应该如何影响用户界面。因此,我们需要重新渲染所有可能受影响的组件,以获得准确的快照。

不是 props 的问题

好的,让我们谈谈大误解 #2:一个组件会因为它的 props 改变而重新渲染。 让我们用一个更新的例子来探索。 在下面的代码中,我们的 “Counter” 应用程序被赋予了一个全新的组件,Decoration 我们的窗口现在在角落里有一艘可爱的小帆船,由 Decoration 组件渲染。它不依赖于 count ,所以当 count 改变时它可能不会重新渲染,对吧?

嗯,呃,完全不是

rerenders2.gif 当一个组件重新渲染时,它会尝试重新渲染所有后代,无论它们是否通过 props 传递了特定的状态变量。这似乎违反直觉......如果我们不将 count 作为 props 传递给 <Decoration>,为什么需要重新渲染它?答案是:React 很难 100% 确定地知道 <Decoration> 是否直接或间接地取决于计数状态变量。在理想的世界里,React 组件总是“纯”的。纯组件是在给定相同 props 时始终生成相同 UI 的组件. 在现实世界中,我们的许多组件都是不纯的。创建不纯的组件非常容易

function CurrentTime() {
  const now = new Date();
  return (
    <p>It is currently {now.toString()}</p>
  );
}

该组件在渲染时将显示不同的值,因为它依赖于当前时间! 这个问题的一个 sneakier 的版本与 refs 有关。如果我们将 ref 作为 prop 传递,React 将无法判断自上次渲染以来我们是否已经改变了它。因此,为了安全起见,它选择重新渲染。React 的 #1 目标是确保用户看到的 UI 与应用程序状态保持“同步”。因此,React 会在渲染过多时出错。它不想冒险向用户展示陈旧的 UI.所以,回到我们的误解:props 与重新渲染无关。我们的 <BigCountNumber> 组件没有重新渲染,因为 count 属性发生了变化。当一个组件重新渲染时,因为它的一个状态变量已经更新,重新渲染将沿着树向下级联,以便 React 填充这个新草图的细节,以捕获新的快照。这是标准操作程序,但有一种方法可以稍微调整一下。

创建纯组件

您可能熟悉 React.memo 或 React.PureComponent 类组件。这两个工具允许我们忽略某些重新渲染请求。 这是它的样子:

function Decoration() {

return (

<div className="decoration">

⛵️

</div>

);

}

export default React.memo(Decoration);

通过用 React.memo 包装我们的装饰组件,我们告诉 React “嘿,我知道这个组件是纯的。除非它的 props 发生变化,否则你不需要重新渲染它。” 。这使用了一种称为 记忆 的技术。它缺少 R,但我们可以将其视为“记忆”。这个想法是 React 会记住之前的快照。如果没有任何 props 发生变化,React 将重新使用该过时的快照,而不是费力地生成一个全新的快照。 假设我用 React.memo 助手包装了 BigCountNumberDecoration。以下是这将如何影响重新渲染:

rerenders3.gifcount 改变时,我们重新渲染 Counter,React 会尝试渲染两个后代组件。 因为 BigCountNumbercount 作为一个道具,并且因为该 props 已更改,所以 BigCountNumber 被重新渲染。但是因为 Decorationprops 没有改变(因为它没有任何),所以使用原始快照代替。我喜欢假设 React.memo 有点像一个懒惰的摄影师。如果您要求它为完全相同的事物拍摄 5 张照片,它会拍摄 1 张照片并给您 5 张副本。当您的指示发生变化时,摄影师只会拍摄一张新照片。这是一个实时代码版本,如果您想自己戳一下。每个 memoized 组件都添加了一个 console.info 调用,因此您可以在控制台中准确查看每个组件何时呈现:

image.png

您可能想知道:为什么这不是默认行为?大多数时候,这不是我们想要的吗?如果我们跳过不需要渲染的渲染组件,我们肯定会提高性能吗?我认为作为开发人员,我们倾向于高估重新渲染的成本。对于我们的 Decoration 组件,重新渲染非常快。如果一个组件有一堆 props 而不是很多后代,与重新渲染组件相比,检查任何 props 是否发生变化实际上会更慢。 因此,记住我们创建的每一个组件会适得其反。 React 旨在非常快速地捕获这些快照!但在特定情况下,对于具有大量后代的组件或内部工作量很大的组件,此助手可以提供相当大的帮助。

这在未来可能会改变!

React 团队正在积极研究是否可以在编译步骤中“自动记忆”代码。它仍处于研究阶段,但早期的实验似乎很有希望。有关更多信息,请查看玄黄的演讲,名为“React without memo”

context

我们还没有讨论过 context,但幸运的是,它并没有使这些事情变得太复杂。

默认情况下,如果组件的状态发生变化,组件的所有后代都将重新渲染。因此,如果我们通过上下文向所有后代提供该状态,它并不会真正改变任何东西;无论哪种方式,这些组件都会重新渲染

const GreetUser = React.memo(() => {
  const user = React.useContext(UserContext);
  if (!user) {
    return "Hi there!";
  }
  return `Hello ${user.name}!`;
});

在这个例子中,GreetUser 是一个没有 props 的纯组件,但它有一个“invisible”或“internal”的依赖:用户被存储在 React 状态,并通过上下文传递。如果该用户状态变量发生更改,则会发生重新渲染,并且 GreetUser 将生成新的快照,而不是依赖于陈旧的图片。React 可以判断这个组件正在使用这个特定的上下文,所以它把它当作一个 props 来对待。 它或多或少等同于:

const GreetUser = React.memo(({ user }) => {
  if (!user) {
    return "Hi there!";
  }
  return `Hello ${user.name}!`;
});

一个活生生的例子: 请注意,这仅在纯组件使用 React.useContext 挂钩使用上下文时才会发生。您不必担心上下文会破坏一堆不尝试使用它的纯组件。

使用 React Devtools 进行分析

如果您已经使用 React 一段时间,您可能有过尝试找出特定组件重新渲染的原因的令人沮丧的经历。在现实世界的情况下,它通常根本不明显!幸运的是,React Devtools 可以提供帮助。首先,您需要下载 React Devtools 浏览器扩展。它目前可用于 ChromeFirefox。出于本教程的目的,我假设您使用的是 Chrome,尽管说明不会有太大差异。使用 Ctrl + Alt + I(或 ⌘ + Option + I 在 MacOS 上)弹出打开 devtools。您应该会看到两个新选项卡出现:

image.png我们对“Profiler”很感兴趣。选择该选项卡。 单击小齿轮图标,并启用标记为“记录每个组件在分析时呈现的原因”的选项:

rerenders4.gif 一般流程如下所示:

  • 通过点击蓝色的小“记录”圆圈开始记录。
  • 在您的应用程序中执行一些操作。
  • 停止录制。
  • 查看记录的快照以了解更多关于发生的事情。

每个渲染都被捕获为一个单独的快照,您可以使用箭头浏览它们。侧边栏中提供了有关渲染组件的原因的信息:

rerenders5.gif 通过单击您感兴趣的组件,您可以准确了解特定组件重新呈现的原因。在纯组件的情况下,它将让我们知道哪些 prop(s) 负责此更新。我个人不经常使用这个工具,但是当我这样做的时候,它就是救命稻草!

突出显示重新渲染

还有一个小技巧:React 分析器有一个选项,您可以在其中突出显示重新渲染的组件。 这是有问题的设置:

image.png启用此设置后,您应该会看到绿色矩形在重新渲染的组件周围闪烁:

rerenders6.gif 这可以帮助我们准确了解状态更新的深远程度,并测试我们的纯组件是否成功避免了重新渲染!

更深入

当您开始使用分析器时,您会注意到一件事:有时,即使看起来没有任何变化,纯组件也会重新渲染!React 的一个微妙的令人费解的事情是组件是 JavaScript 函数。当我们渲染一个组件时,我们正在调用该函数。这意味着在 React 组件中定义的任何内容都会在每次渲染时重新创建。举个简单的例子,思考一下:

function App() {
  const dog = {
    name: 'Spot',
    breed: 'Jack Russell Terrier'
  };
  return (
    <DogProfile dog={dog} />
  );
}

每次我们渲染这个 App 组件时,我们都会生成一个全新的对象。这会对我们的纯组件造成严重破坏;无论我们是否用 React.memo 包装它,这个 DogProfile 子组件都会重新渲染!几周后,我将在这篇博文中发布“第二部分”。我们将深入研究两个著名的难以理解的 React 钩子,useMemouseCallback。我们将看到如何使用它们来解决这个问题。我还要坦白说:这些教程直接来自我即将推出的课程“The Joy of React”。我已经使用 React 构建了 7 年多了,我学到了很多关于如何有效使用它的知识。我非常喜欢使用 React;我已经尝试了几乎所有的前端框架,没有什么比 React 更能让我感到高效。在“The Joy of React”中,我们将为 React 的真正工作方式构建一个心智模型,深入研究本教程中的概念。

加餐:性能提示

React 中的性能优化是一个巨大的话题,我可以轻松地写几篇关于它的博客文章。希望本教程帮助您打下了坚实的基础,您可以在此基础上了解 React 性能!也就是说,我将分享一些我学到的关于 React 性能优化的快速提示:

  • React Profiler 显示渲染所用的毫秒数,但这个数字不可信。我们通常在“开发模式”中分析事物,而 React 在“生产模式”中要快得多。要真正了解您的应用程序的性能,您应该使用“性能”选项卡对已部署的生产应用程序进行测量。这不仅会显示重新渲染的真实数字,还会显示布局/绘画更改。
  • 我强烈建议在低端硬件上测试你的应用程序,看看 90% 的体验是什么样的。这取决于您正在构建的产品,但是对于这个博客,我会定期在几年前在印度流行的廉价智能手机小米红米 8 上进行测试。我在推特上分享了这段经历。
  • Lighthouse 性能分数并不能准确反映真实的用户体验。我相信使用该应用程序的定性体验远胜于任何自动化工具显示的统计数据。
  • 几年前,我在 React Europe 发表了一个关于 React 性能的演讲!它更多地关注“加载后”体验,这是许多开发人员忽视的领域。你可以在油管上观看。
  • 不要过度优化!在学习 React 分析器时,为了尽可能减少渲染次数而进行优化是很诱人的……但老实说,React 已经非常优化,开箱即用。如果事情开始感觉有点迟钝,最好使用这些工具来解决性能问题。

如有感觉翻译不合适的地方,欢迎交流,目前处于学习翻译的路上。 公众号 前端校长 期待你的关注!