React协调算法与性能优化

avatar

原文地址,imkev.dev/react-recon… 由石欣翻译

我们想测量由一个(或多个道具)更改所导致的所有 CPU 时间的总和。这可能是互动的结果,例如点击或数据更改,渲染还可能涉及分布在整个页面上的多个组件,例如,从注销状态更改为已登录状态。这对我自己特别有用,因为我想衡量许多组件的大规模重组对 CPU 的影响 - 最终回答这个问题,新架构是否更快?

  React 16.6 引入了 React Profiler,使你能够使用 Chrome Dev Tools 性能选项卡来衡量组件如何挂载、卸载、更新和渲染( Profiling React Performance)。 就我而言,我想使用 User Timing API 在生产环境中测量 React。我之前曾将用户计时 API 用于其他重要的过程,是相对简单的。此外,在生产中包含 React Profiler 有一些轻微的开销,也是没有必要的。

image.png

第一步

第一步是了解在哪里设置 performance.mark  以指示渲染周期的开始和结束。通过遵循我已经知道的 React 组件生命周期,我知道渲染一个组件,之后它会触发 componentDidMount  方法。任何后续对组件属性或状态的更改,都将触发更新,这将触发 componentDidUpdate  方法。所以我认为我们可以使用这个简单的流程 render-mount-(render-update)*  来放置我们的标记。

对于功能组件(以及 React 16.8 及更高版本),这两个方法可以替换为 React 钩子 useEffect 。基于此,我认为通过在 render() 的开头和 useEffect  的开头进行标记,我能够大致测量每个渲染周期需要多长时间,使用以下简单代码段:

Simple.jsx:

import React, { useEffect } from "react";
// Represents a simple functional component
// ATTN: Incorrectly uses `useEffect` instead of `useLayoutEffect`
export default function Simple({ isHappy, showLogs = false }) {
  useEffect(() => {
    performance.mark("Simple:Render:End");
    performance.measure(
      "Simple:Render",
      "Simple:Render:Start",
      "Simple:Render:End"
    );
    if (showLogs) {
      console.log(
        "Simple:Render",
        performance.getEntriesByName("Simple:Render")[
          performance.getEntriesByName("Simple:Render").length - 1
        ].duration
      );
    }
  });
  performance.mark("Simple:Render:Start");
  return (
    <div className="container">
      <span className="value">{isHappy ? ":)" : ":("}</span>
    </div>
  );
}

所有代码都可以在 Code Sandbox 上找到。 Simple:Render:Start 在每次渲染时标记,而 Simple:Render:End 在装载或更新时标记。在更简单的世界中,这两者之间的区别在于组件渲染所花费的时间。

 

测试一下

用 React Profiler 的 onRender  运行了我的数字,我的测量值要大得多。更重要的是,我在一个渲染和另一个渲染之间没有一致性。探查器将报告减少,而我的度量值将报告增加。

 

要理解这一点,我们需要了解 React(指 v16.0 及更高版本)如何执行更新。为了大大简化流程,React 使用了一种数据结构,内部称为 fiber,fiber 可以被认为是工作单元的抽象。每当我们渲染一个 React 应用程序时,结果都是一个反映应用程序当前状态的 fiber tree ,方便地命名为 current 。当 React 处理交互或开始处理更新时,它会获取 current 树并在其上处理这些更改,从而生成一个新的更新树,称为 workInProgress

 

至少到目前为止,React 的一个核心原则是它不会渲染树的一部分,但只有在处理完所有更新并遍历整个树后, workInProgress  树被刷新到 DOM,根上的指针被交换,这样它就会成为新的 current  。

 

记住这一点,React 更新分为两个阶段,渲染阶段和提交阶段。渲染包括:

  • render()
  • shouldComponentUpdate
  • getDerivedStateFromProps

以及组件生命周期方法,这些方法已在 v16 中弃用 ( UNSAFE_...etc  )

 

重要的是要知道渲染阶段的工作可以异步执行。相比之下,提交阶段始终同步执行,因此可能包含副作用并触及 DOM。 (提示:这就是为什么渲染阶段中的某些方法(前缀为 UNSAFE_  )已被弃用的原因,因为它们被“错误地”用于执行副作用或与 DOM 交互)。

 

提交阶段的方法包括:

  • getSnapshotBeforeUpdate
  • componentDidMount
  • componentDidUpdate
  • componentWillUnmount

因此,渲染阶段会构建 workInProgress  树和效果列表,而提交阶段会遍历它并运行这些效果。如果你想阅读更多,强烈推荐 Max Koretskyi的 Inside Fiber: in-depth overview of the new reconciliation algorithm in React.

 

我们的措施

这是对 React 内部工作的一小部分的研究,但这与我们的绩效衡量有什么关系呢?如果查看  ReactFiberWorkLoop  的来源,会发现探查器计时器被包装( startProfilerTimer  和stopProfilerTimerIfRunningAndRecordDelta )围绕对 beginWork  和 completeWork  的调用。这两个功能涵盖了 fiber 的主要活动,因此分析器排除了在这些活动之外花费的任何时间,从而使测量更加简洁。相比之下,我们的度量还将包括与组件渲染无关的工作,但也包括在 React 算法中工作。

 

但是启动/停止计时器调用的位置并不能解释不一致。正如我们刚刚了解到的,渲染阶段是异步的,这意味着它不一定会在转到下一个方法之前调用并完成执行 render 方法。事实上,React 以一种“深度优先”的遍历方式渲染组件,因此,只有在所有子级都完成其工作后,它才能完成父级的工作。在我们的示例中,这意味着我们的 performance.mark(`${id}:Render:Start`) 不会同步触发,并且 useEffect 不太可能在 render() 方法完成后立即出现,因为这发生在提交阶段开始时。

 

下一步应该做什么

为了解决 useEffect 的问题,根据 React 文档,我们可以使用useLayoutEffect ,在所有 DOM 突变后同步触发。更新我们上面的代码(Code Sandbox)以使用useLayoutEffect 为我们提供了看似一致的测量值(尽管略大于分析器)。将此代码放在可重用的组件中会给我们留下:

 

MeasureRender.jsx

import { useLayoutEffect } from "react";
export default function MeasureRender({ id, children }) {
  useLayoutEffect(() => {
    performance.mark(`${id}:Render:End`);
    performance.measure(
      `${id}:Render`,
      `${id}:Render:Start`,
      `${id}:Render:End`
    );
    console.log(
      `${id}:Render`,
      performance.getEntriesByName(`${id}:Render`)[
        performance.getEntriesByName(`${id}:Render`).length - 1
      ].duration
    );
  });
  performance.mark(`${id}:Render:Start`);
  return children;
}

我们可能想要包含的一个小改动是, useLayoutEffect 将在每次组件挂载或更新时运行。在大多数情况下,第一个渲染度量与以下渲染不同。后续渲染利用了 React 协调算法中的优化,例如记忆,通常,如果组件需要获取数据,则第一个渲染不包括数据,因为这是在以下 useEffect 中获取的。因此,我们可能希望从组件中消除首次渲染。这可以通过更新我们的 MeasureRender 以使用 useRef 钩子来完成。

 

首先, useRef 用于存储对 DOM 的引用,但它不止于此,可以用作在重新渲染中持续存在的可变对象,类似于 setState 。但是,与setState 不同的是 useRef 不会在其值更改时触发重新渲染。这将导致以下结果:

 

MeasureRender.jsx

import { useLayoutEffect, useRef } from "react";
export default function MeasureRender({ id, children }) {
  const isMountedRef = useRef(false);
  useLayoutEffect(() => {
    if (isMountedRef.current) {
      performance.mark(`${id}:Update:End`);
      performance.measure(
        `${id}:Update`,
        `${id}:Update:Start`,
        `${id}:Update:End`
      );
      console.log(
        `${id}:Update`,
        performance.getEntriesByName(`${id}:Update`)[
          performance.getEntriesByName(`${id}:Update`).length - 1
        ].duration
      );
    } else {
      isMountedRef.current = true;
    }
  });
  if (isMountedRef.current) {
    performance.mark(`${id}:Update:Start`);
  }
  return children;
}

在上面的代码段中,注意到我们添加了默认为 false 的 isMountedRef 。这将用于告诉我们我们的组件是否已安装。因此,在 useLayoutEffect 中,如果以前它是假的,我们将其设置为 true 。此值将保留在isMountedRef.current 中,因此我们将不再使用挂载来扭曲我们的指标。

 

异步渲染

React 如何渲染组件可以通过拥有要测量的组件的两个或多个实例来说明。如果在每个 performance.mark(`${id}:Render:Start`)上放置一个日志点,注意到它将在每个组件到达第一个结束标记之前为每个组件创建开始标记。还会注意到,与放置在树末尾的同级组件相比,放置在树中首先的组件的渲染时间更长。这是因为“深度优先”遍历。

image (1).png

在上面的例子中,假设一个从 App 传递到 MyComponentA 的道具发生了变化,在渲染阶段,React 会在 App 上调用 beginWork ,然后是 MyComponentA、MyComponentB,最后是 MyComponentC。在我们的 MeasureRender 组件中,这意味着 performance.mark(`${id}:Render:Start`) 也将按该顺序调用。相反,completeWork 将按以下顺序调用:MyComponentA(无子级)、MyComponentC、MyComponentB(等待 MyComponentC 调用 completeWork ),最后是 App(等待 MyComponentA 和 MyComponentB 调用 completeWork )。 因此,我们对每个组件的测量值会有很大差异,即使它们执行的工作量与按顺序调用 performance.mark(`${id}:Render:Start`) 相同,而 performance.mark(`${id}:Render:End`) 是在提交阶段开始时调用的,这将发生在所有组件的 render() 之后。为了获得有意义的度量,我们将 MeasureRender 放置在 App 组件(在本例中)中,该组件源自道具更改。

我还使用静态方法 getDerivedStateFromProps() 实现了等效的类组件,该方法在渲染方法和 getSnapshotBeforeUpdate() (GitHub issue)之前调用;这是“在提交最近渲染的输出之前调用的”。结果稍微准确一些,主要是由于 getSnapshotBeforeUpdate() 被调用得更早(第一个函数在  commitRoot  中被调用)而不是 useLayoutEffect 钩子。

 

MeasureRenderClass.jsx: MeasureRenderClass.jsx:

import React from "react";
const supportsUserTiming =
  typeof performance !== "undefined" &&
  typeof performance.mark === "function" &&
  typeof performance.clearMarks === "function" &&
  typeof performance.measure === "function" &&
  typeof performance.clearMeasures === "function";
export default class MeasureRender extends React.PureComponent {
  constructor(props) {
    super(props);
    this.state = {};
    if (typeof props.on === "undefined") {
      console.warn(
        "Please specify a
        n `on` prop to listen to prop changes for the prop you would like to measure."
      );
    }
  }
  componentDidUpdate() {
    performance.measure(
      `${this.props.id}:Update`,
      `${this.props.id}:Update:Start`,
      `${this.props.id}:Update:End`
    );
    console.log(
      `${this.props.id}:Update`,
      performance.getEntriesByName(`${this.props.id}:Update`)[
        performance.getEntriesByName(`${this.props.id}:Update`).length - 1
      ].duration
    );
  }
  // getSnapshotBeforeUpdate() is invoked right before the most recently rendered output is committed
  getSnapshotBeforeUpdate() {
    performance.mark(`${this.props.id}:Update:End`);
    return null;
  }
  // getDerivedStateFromProps is invoked right before calling the render method
  static getDerivedStateFromProps(props) {
    if (supportsUserTiming) {
      performance.mark(`${props.id}:Update:Start`);
    }
    return null;
  }
  render() {
    return null;
  }
}

请注意,我们不再返回子项,并且当我们测量由于道具更改而更新的所有组件的渲染时间时,我们只是在测量子项的渲染时间。

因此,我更好地理解了 React 协调算法。测试它按预期执行,与 React 分析器相比,误差幅度很小,并且始终返回一致的结果。

在衡量更新DOM的JavaScript函数的性能时,我们可能希望包括花在布局和绘画上的时间,由Chrome DevTools性能选项卡上的紫色和绿色条表示。

注意:这些不包含在 React Profiler 的 onRender 回调中。

image (2).png Excluding Layout and Paint in Chrome DevTools

在 Chrome DevTools 中排除布局和绘制

 

广泛接受的解决方案是使用 requestAnimationFrame 和 setTimeout 的组合。根据 HTML5 spec 规范, requestAnimationFrame在计算样式和布局之前触发。因此,我们包含 setTimeout将我们带到事件队列的末尾,这将发生在样式,布局和绘画之后。

image (3).png rAF + setTimeout rAF + 设置超时

如上图所示,我们现在在用户计时测量中包括样式、布局和油漆。正如我们已经知道的,React 一次性刷新了对 DOM 的所有更改?这意味着,如果我们有一个交互,在两个不同的组件中更新 DOM,这两个度量将同时开始但一起结束,包括两个组件的渲染、布局和绘制时间。在我们的案例中,我们选择不使用 rAF + setTimeout,只排除布局和绘画,但我会在未来几周内进一步研究这个问题。

 

总结

我一直在生产环境中将此组件用于少数应用程序,并且正在监视其行为以评估其可靠性。到目前为止,它已被证明是令人满意的,因为我非常熟悉代码库的其余部分,并且我确切地知道我在测量什么。当然我也不会推荐你使用它,正如我学到的艰难方法一样,重要的是要了解你正在测量的内容,并通过将其与 trusthworty 标准(在我的情况下为 React Profiler)进行比较来确认你正在正确测量。 如果你花时间尝试实现自己的措施,我乐观地认为,与应用 onRender 回调相比,你将更详细地了解代码的性能和协调,因为不正确的实施将导致不正确的测量,了解测量方法不同的原因可能会使你走上以前从未探索过的道路。