编写高效的 React 代码

在本文中,我们将讨论可以使用的各种组件类型,并讨论一些使 React 应用程序更快的技巧。

TLDR 提示

  • 优先使用 props 和状态不变性,并使用 PureComponent 组件作为默认选项

  • 按照惯例,只有在内部数据发生更改时,对象引用才应更改。

    • 注意不要将函数的新实例用作组件的 props(将它们用作 DOM 元素的 props 则没问题)。

    • 注意,如果内部数据没有更改,则不要更新引用。

  • 始终在优化之前进行测量,以对性能产生真正的影响。并且始终在优化之后进行测量,以证明您的更改确实产生了影响。

React 如何渲染普通组件

什么是普通组件?

首先,让我们讨论一下 React 如何渲染不使用 shouldComponentUpdate 的普通组件。这里我们所说的普通组件是指:

  class Application extends React.Component {
    render() {
      return <div>{this.props.content}</div>;
    }
  }
  • 接收一些 props 作为参数并返回一些 JSX 的普通函数。我们将这些函数称为无状态组件或函数组件。了解这些无状态组件在 React 中没有得到特别优化这一点很重要。

  function Application(props) {
    return <div>{props.content}</div>;
  }

这些函数等效于扩展 Component 的类。在本文的其余部分,我们将特别关注后者。除非另有说明,否则关于扩展 Component 的类的一切内容也适用于无状态/函数组件。

关于 JSX 用法的说明

因为我们还没有在 mozilla-central 中使用构建步骤,所以我们的一些工具不使用 JSX,而是使用 工厂

class Application extends React.Component {
  render() {
    return dom.div(null, this.props.content);
  }
}

为了更清晰起见,我们将在本文档中使用 JSX,但这完全等效。您可以在 React 文档 中了解更多信息。

第一次渲染

启动 React 应用程序并触发第一次渲染只有一种方法:调用 ReactDOM.render

ReactDOM.render(
  <Application content='Hello World!'/>,
  document.getElementById('root')
);

React 将调用该组件的 render 方法,然后递归调用每个子组件的 render 方法,生成一个渲染树,然后生成一个虚拟 DOM 树。然后它会将实际的 DOM 元素渲染到指定的容器中。

后续重新渲染

有几种方法可以触发重新渲染

  1. 我们再次使用相同的组件调用 ReactDOM.render

  ReactDOM.render(
    <Application content='Good Bye, Cruel World!'/>,
    document.getElementById('root')
  );
  1. 一个组件的状态发生变化,通过使用 setState。如果应用程序使用 Redux,那么这也是 Redux 连接的组件触发更新的方式。

  2. 一个组件的 props 发生变化。但请注意,这不可能自行发生,这始终是其父组件中情况 1 或情况 2 的结果。因此,在本节中我们将忽略这种情况。

当其中一个发生时,就像初始渲染一样,React 将调用该组件的 render 方法,然后递归调用每个子组件的 render 方法,但这次与上一次渲染相比,props 可能发生了变化。

这些递归调用会生成一个新的渲染树。这就是 React 使用称为虚拟 diffing协调 的算法来查找应用于 DOM 的最小更新集的地方。这很好,因为对 DOM 的更新越少,浏览器执行应用程序重排和重绘所需的工作就越少。

性能问题的主要来源

从这个解释中,我们可以了解到性能问题的主要来源可能是:

  1. 过于频繁地触发渲染过程,

  2. 代价高昂的渲染方法,

  3. 协调算法本身。根据 React 作者的说法,该算法为 O(n),这意味着处理持续时间会随着我们比较的树中元素的数量线性增加。因此,树越大,处理时间越长。

让我们更深入地了解每个问题。

不要过于频繁地渲染

调用 setState 以更改本地状态后,将发生重新渲染。

状态中的所有内容都应在 render 中使用。状态中在 render 中未使用的任何内容都不应该在状态中,而应该在实例变量中。这样,如果您更改某些不希望反映在 UI 中的内部状态,则不会触发更新。

如果您从事件处理程序中调用 setState,则可能过于频繁地调用它。这通常不是问题,因为 React 足够智能,可以合并接近的 setState 调用,并且每帧只触发一次重新渲染。但是,如果您的 render 非常昂贵(请参阅下文),这可能会导致问题,您可能需要使用 setTimeout 或其他类似技术来限制渲染次数。

使 render 方法尽可能精简

渲染列表时,我们通常会将此列表映射到组件列表。这可能代价很高,我们可能需要将此列表分成几个项目块或 虚拟化此列表。尽管这并不总是可能或容易。

不要在 render 方法中执行繁重的计算。而是在设置状态之前执行它们,并将状态设置为这些计算的结果。理想情况下,render 应该是组件的 props 和状态的直接镜像。

请注意,此规则也适用于作为渲染过程的一部分调用的其他方法:componentWillUpdatecomponentDidUpdate。尤其是在 componentDidUpdate 中,避免通过获取 DOM 测量值进行同步重排,并且不要调用 setState,因为这会触发另一个更新。

帮助协调算法提高效率

树越小,算法的速度越快。因此,将更改限制在完整树的子树中很有用。请注意,使用 shouldComponentUpdatePureComponent 通过从渲染树中剪切掉整个分支来缓解此问题,我们在下面更详细地讨论了这一点

尝试尽可能靠近 UI 应该更改的位置更改状态(在组件树中靠近)。

不要忘记 在渲染一系列事物时设置 key 属性,该属性不应为数组的索引,而应为以可预测、唯一和稳定方式标识项目的内容。这通过跳过可能没有发生更改的部分极大地帮助了算法。

更多文档

React 文档有一个 非常详细的页面 解释了整个渲染和重新渲染过程。

shouldComponentUpdatePureComponent:完全避免渲染

React 有一个优化的算法来应用更改。但最快的算法是根本不执行的算法。

React 自己的性能文档 对此主题进行了相当完整的介绍。

使用 shouldComponentUpdate 避免重新渲染

作为重新渲染过程的第一步,React 会调用组件的 shouldComponentUpdate 方法,并传入两个参数:新的 props 和新的状态。如果此方法返回 false,则 React 将跳过此组件的渲染过程,及其整个子树

class ComplexPanel extends React.Component {
  // Note: this syntax, new but supported by Babel, automatically binds the
  // method with the object instance.
  onClick = () => {
    this.setState({ detailsOpen: true });
  }

  // Return false to avoid a render
  shouldComponentUpdate(nextProps, nextState) {
    // Note: this works only if `summary` and `content` are primitive data
    // (eg: string, number) or immutable data
    // (keep reading to know more about this)
    return nextProps.summary !== this.props.summary
      || nextProps.content !== this.props.content
      || nextState.detailsOpen !== this.state.detailsOpen;
  }

  render() {
    return (
      <div>
        <ComplexSummary summary={this.props.summary} onClick={this.onClick}/>
        {this.state.detailsOpen
          ? <ComplexContent content={this.props.content} />
          : null}
      </div>
    );
  }
}

这是一种非常有效的提高应用程序速度的方法,因为它避免了所有操作:既避免了为该组件以及整个子树调用 render 方法,也避免了该子树的协调阶段。

请注意,就像 render 方法一样,shouldComponentUpdate 在每个渲染周期中只调用一次,因此它需要非常精简并尽快返回。因此它应该只执行一些廉价的比较。

PureComponent 和不变性

React 的 PureComponent 提供了 shouldComponentUpdate 的一个非常常见的实现:它将对新的 props 和状态进行浅比较以检查引用是否相等。

class ComplexPanel extends React.PureComponent {
  // Note: this syntax, new but supported by Babel, automatically binds the
  // method with the object instance.
  onClick = () => {
    // Running this repeatidly won't render more than once.
    this.setState({ detailsOpen: true });
  }

  render() {
    return (
      <div>
        <ComplexSummary summary={this.props.summary} onClick={this.onClick}/>
        {this.state.detailsOpen
          ? <ComplexContent content={this.props.content} />
          : null}
      </div>
    );
  }
}

这有一个非常重要的后果:对于非基本类型(primitive)的 props 和 state,也就是对象和数组,它们可以在不改变自身引用(reference)的情况下被修改,PureComponent 继承的 shouldComponentUpdate 会产生错误的结果,并且会在不应该跳过渲染的时候跳过渲染。

所以你只剩下以下两个选项之一:

  • Component 中实现你自己的 shouldComponentUpdate

  • 或者(**推荐**)决定使你的所有数据结构不可变。

后者建议这样做,因为:

  • 思考起来简单得多。

  • shouldComponentUpdate 和其他地方(例如 Redux 的选择器)中,检查相等性会快得多。

注意,你可以在技术上在 PureComponent 中实现你自己的 shouldComponentUpdate,但这毫无意义,因为 PureComponent 不过是在 Component 中为 shouldComponentUpdate 提供了一个默认实现。

关于不可变性

它不意味着什么

这并不意味着你需要使用像 Immutable 这样的库来强制执行不可变性。

它的含义

这意味着一旦一个结构存在,你就不能修改它。

**每次某些数据发生变化时,对象引用也必须发生变化**。这意味着需要创建一个新的对象或新的数组。这给出了一个很好的反向保证:如果对象引用发生了变化,则数据也发生了变化。

最好更进一步,获得**严格的等价性**:如果数据没有改变,则对象引用不能改变。这对你的应用程序工作不是必需的,但它对性能有很大的好处,因为它避免了无端的重新渲染。

继续阅读以了解如何继续。

保持你的 state 对象简单

如果使用的对象很复杂,更新不可变的 state 对象可能会很困难。因此,最好保持对象简单,特别是不要嵌套,这样你就无需使用像 immutability-helperupdeep 甚至 Immutable 这样的库。特别要注意 Immutable,因为它很容易通过误用其 API 造成性能问题。

如果你正在使用 Redux(另请参阅下文),此建议也适用于你的单个 reducer,即使 Redux 工具使拥有嵌套/组合 state 变得很容易。

如何更新对象

更新对象非常简单。

你不能直接更改/添加/删除内部属性

// Note that in the following examples we use the callback version
// of `setState` everywhere, because we build the new state from
// the current state.

// Please don't do this as this will likely induce bugs.
this.setState(state => {
  state.stateObject.details = details;
  return state;
});

// This is wrong too: `stateObject` is still mutated.
this.setState(({ stateObject }) => {
  stateObject.details = details;
  return { stateObject };
});

相反,**你必须为此属性创建一个新的对象**。在这个例子中,我们将使用对象展开运算符,它已经在 Firefox、Chrome 和 Babel 中实现了。

但是在这里,我们注意在不需要更新时返回相同的对象。比较发生在回调内部,因为它也依赖于 state。这样做很好,这样如果没有任何变化,浅比较就不会返回 false。

// Updating one property in the state
this.setState(({ stateObject }) => ({
  stateObject: stateObject.content === newContent
    ? stateObject
    : { ...stateObject, content: newContent },
});

// This is very similar if 2 properties need an update:
this.setState(({ stateObject1, stateObject2 }) => ({
  stateObject1: stateObject1.content === newContent
    ? stateObject1
    : { ...stateObject1, content: newContent },
  stateObject2: stateObject2.details === newDetails
    ? stateObject2
    : { ...stateObject2, details: newDetails },
});

// Or if one of the properties needs to update 2 of it's own properties:
this.setState(({ stateObject }) => ({
  stateObject: stateObject.content === newContent && stateObject.details === newDetails
    ? stateObject
    : { ...stateObject, content: newContent, details: newDetails },
});

请注意,这与返回的 state 对象无关,而是与它的属性有关。返回的对象总是合并到当前 state 中,并且 React 在每个更新周期都会创建一个新的组件的 state 对象。

如何更新数组

更新数组也很容易。

你必须避免修改数组的方法,如 push/splice/pop/shift,并且你不能直接更改项。

// Please don't do this as this will likely induce bugs.
this.setState(({ stateArray }) => {
  stateArray.push(newItem); // This is wrong
  stateArray[1] = newItem; // This is wrong too
  return { stateArray };
});

相反,在这里你同样需要**创建一个新的数组实例**。

// Adding an element is easy.
this.setState(({ stateArray }) => ({
  stateArray: [...stateArray, newElement],
}));

this.setState(({ stateArray }) => {
  // Removing an element is more involved.
  const newArray = stateArray.filter(element => element !== removeElement);
  // or
  const newArray = [...stateArray.slice(0, index), ...stateArray.slice(index + 1)];
  // or do what you want on a new clone:
  const newArray = stateArray.slice();
  return {
    // Because we want to keep the old array if removeElement isn't in the
    // filtered array, we compare the lengths.
    // We still start a render phase because we call `setState`, but thanks to
    // PureComponent's shouldComponentUpdate implementation we won't actually render.
    stateArray: newArray.length === stateArray.length ? stateArray : newArray,
  };

  // You can also return a falsy value to avoid the render cycle at all:
  return newArray.length === stateArray.length
    ? null
    : { stateArray: newArray };
});

如何更新 Maps 和 Sets

Maps 和 Sets 的过程非常相似。以下是一个简单的例子

// For a Set
this.setState(({ stateSet }) => {
  if (!stateSet.has(value)) {
    stateSet = new Set(stateSet);
    stateSet.add(value);
  }
  return { stateSet };
});

// For a Map
this.setState(({ stateMap }) => {
  if (stateMap.get(key) !== value) {
    stateMap = new Map(stateMap);
    stateMap.set(key, value);
  }
  return { stateMap };
}));

如何更新基本类型值

显然,对于像布尔值、数字或字符串这样的基本类型,它们可以使用 === 运算符进行比较,这要容易得多。

this.setState({
  stateString: "new string",
  stateNumber: 42,
  stateBool: false,
});

请注意,我们在这里没有使用 setState 的回调版本。这是因为对于基本类型,我们不需要使用以前的 state 来生成新的 state。

关于 Redux 的一些话

在使用 Redux 时,规则保持不变,只是所有这些都在你的 reducer 中发生,而不是在你的组件中发生。Redux 带来了一个函数 combineReducers,它遵循我们之前概述的所有规则,同时使拥有嵌套 state 成为可能。

shouldComponentUpdatePureComponent

强烈建议采用完整的**PureComponent + 不可变性**方案,而不是为组件编写自定义的 shouldComponentUpdate 实现。这更通用、更易于维护、更不容易出错、更快。

当然,所有规则都有例外,如果你有特定的情况需要处理,你可以自由地实现 shouldComponentUpdate 方法。

一些关于 PureComponent 的注意事项

因为 PureComponent 浅比较 props 和 state,所以你需要注意不要为其他方面相同的内容创建新的引用。一些常见的情况是

  • 在每个渲染周期中为 prop 使用一个新的实例。特别是,不要使用绑定函数或匿名函数(经典函数或箭头函数)作为 prop

    render() {
      return <MyComponent onUpdate={() => this.update()} />;
    }
    

    每次 render 方法运行时,都会创建一个新的函数,并且在 MyComponentshouldComponentUpdate 中,浅比较总是会失败,从而破坏其目的。

  • 为相同的数据使用另一个引用。一个非常常见的例子是空数组:如果每个渲染都使用一个新的 [],你将不会跳过渲染。一个解决方案是重用一个公共实例。请注意,这很容易隐藏在一些复杂的 Redux reducer 中。

  • 如果你使用 set 或 map,也可能会出现类似的问题。如果你在一个已经存在的 Set 中添加一个元素,则不需要返回一个新的 Set,因为它将是相同的。

  • 注意数组的方法,尤其是 mapfilter,因为它们总是返回一个新的数组。因此,即使输入相同(相同的输入数组,相同的函数),你也会得到一个新的输出,即使它包含相同的数据。如果你正在使用 Redux,建议使用 reselectmemoize-immutable 在某些情况下也很有用。

使用一些工具诊断性能问题

你可以在专门的页面中阅读相关内容.

打破规则:始终先测量

你通常应该遵循这些规则,因为它们在大多数情况下都能带来一致的性能。

但是你可能有一些特殊情况需要打破这些规则。在这种情况下,首先要做的是使用分析器进行**测量**,以便知道你的问题出在哪里。

然后,也只有在那之后,你才能决定通过使用一些可变 state 和/或自定义 shouldComponentUpdate 实现来打破规则。

并记住在你进行更改后再次进行测量,以检查和证明你的更改确实产生了影响。理想情况下,在请求性能补丁的审查时,你应该始终提供指向配置文件的链接。