编写高效的 React 代码¶
在本文中,我们将讨论可以使用的各种组件类型,并讨论一些使 React 应用程序更快的技巧。
TLDR 提示¶
优先使用 props 和状态不变性,并使用
PureComponent
组件作为默认选项按照惯例,只有在内部数据发生更改时,对象引用才应更改。
注意不要将函数的新实例用作组件的 props(将它们用作 DOM 元素的 props 则没问题)。
注意,如果内部数据没有更改,则不要更新引用。
始终在优化之前进行测量,以对性能产生真正的影响。并且始终在优化之后进行测量,以证明您的更改确实产生了影响。
React 如何渲染普通组件¶
什么是普通组件?¶
首先,让我们讨论一下 React 如何渲染不使用 shouldComponentUpdate
的普通组件。这里我们所说的普通组件是指:
扩展
Component
的类
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 元素渲染到指定的容器中。
后续重新渲染¶
有几种方法可以触发重新渲染
我们再次使用相同的组件调用
ReactDOM.render
。
ReactDOM.render(
<Application content='Good Bye, Cruel World!'/>,
document.getElementById('root')
);
一个组件的状态发生变化,通过使用
setState
。如果应用程序使用 Redux,那么这也是 Redux 连接的组件触发更新的方式。一个组件的 props 发生变化。但请注意,这不可能自行发生,这始终是其父组件中情况 1 或情况 2 的结果。因此,在本节中我们将忽略这种情况。
当其中一个发生时,就像初始渲染一样,React 将调用该组件的 render
方法,然后递归调用每个子组件的 render
方法,但这次与上一次渲染相比,props 可能发生了变化。
这些递归调用会生成一个新的渲染树。这就是 React 使用称为虚拟 diffing 或 协调 的算法来查找应用于 DOM 的最小更新集的地方。这很好,因为对 DOM 的更新越少,浏览器执行应用程序重排和重绘所需的工作就越少。
性能问题的主要来源¶
从这个解释中,我们可以了解到性能问题的主要来源可能是:
过于频繁地触发渲染过程,
代价高昂的渲染方法,
协调算法本身。根据 React 作者的说法,该算法为 O(n),这意味着处理持续时间会随着我们比较的树中元素的数量线性增加。因此,树越大,处理时间越长。
让我们更深入地了解每个问题。
不要过于频繁地渲染¶
调用 setState
以更改本地状态后,将发生重新渲染。
状态中的所有内容都应在 render
中使用。状态中在 render
中未使用的任何内容都不应该在状态中,而应该在实例变量中。这样,如果您更改某些不希望反映在 UI 中的内部状态,则不会触发更新。
如果您从事件处理程序中调用 setState
,则可能过于频繁地调用它。这通常不是问题,因为 React 足够智能,可以合并接近的 setState 调用,并且每帧只触发一次重新渲染。但是,如果您的 render
非常昂贵(请参阅下文),这可能会导致问题,您可能需要使用 setTimeout
或其他类似技术来限制渲染次数。
使 render
方法尽可能精简¶
渲染列表时,我们通常会将此列表映射到组件列表。这可能代价很高,我们可能需要将此列表分成几个项目块或 虚拟化此列表。尽管这并不总是可能或容易。
不要在 render
方法中执行繁重的计算。而是在设置状态之前执行它们,并将状态设置为这些计算的结果。理想情况下,render
应该是组件的 props 和状态的直接镜像。
请注意,此规则也适用于作为渲染过程的一部分调用的其他方法:componentWillUpdate
和 componentDidUpdate
。尤其是在 componentDidUpdate
中,避免通过获取 DOM 测量值进行同步重排,并且不要调用 setState
,因为这会触发另一个更新。
帮助协调算法提高效率¶
树越小,算法的速度越快。因此,将更改限制在完整树的子树中很有用。请注意,使用 shouldComponentUpdate
或 PureComponent
通过从渲染树中剪切掉整个分支来缓解此问题,我们在下面更详细地讨论了这一点。
尝试尽可能靠近 UI 应该更改的位置更改状态(在组件树中靠近)。
不要忘记 在渲染一系列事物时设置 key
属性,该属性不应为数组的索引,而应为以可预测、唯一和稳定方式标识项目的内容。这通过跳过可能没有发生更改的部分极大地帮助了算法。
更多文档¶
React 文档有一个 非常详细的页面 解释了整个渲染和重新渲染过程。
shouldComponentUpdate
和 PureComponent
:完全避免渲染¶
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-helper、updeep 甚至 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 成为可能。
shouldComponentUpdate
或 PureComponent
?¶
强烈建议采用完整的**PureComponent + 不可变性**方案,而不是为组件编写自定义的 shouldComponentUpdate
实现。这更通用、更易于维护、更不容易出错、更快。
当然,所有规则都有例外,如果你有特定的情况需要处理,你可以自由地实现 shouldComponentUpdate
方法。
一些关于 PureComponent
的注意事项¶
因为 PureComponent
浅比较 props 和 state,所以你需要注意不要为其他方面相同的内容创建新的引用。一些常见的情况是
在每个渲染周期中为 prop 使用一个新的实例。特别是,不要使用绑定函数或匿名函数(经典函数或箭头函数)作为 prop
render() { return <MyComponent onUpdate={() => this.update()} />; }
每次
render
方法运行时,都会创建一个新的函数,并且在MyComponent
的shouldComponentUpdate
中,浅比较总是会失败,从而破坏其目的。为相同的数据使用另一个引用。一个非常常见的例子是空数组:如果每个渲染都使用一个新的
[]
,你将不会跳过渲染。一个解决方案是重用一个公共实例。请注意,这很容易隐藏在一些复杂的 Redux reducer 中。如果你使用 set 或 map,也可能会出现类似的问题。如果你在一个已经存在的
Set
中添加一个元素,则不需要返回一个新的Set
,因为它将是相同的。注意数组的方法,尤其是
map
或filter
,因为它们总是返回一个新的数组。因此,即使输入相同(相同的输入数组,相同的函数),你也会得到一个新的输出,即使它包含相同的数据。如果你正在使用 Redux,建议使用 reselect。 memoize-immutable 在某些情况下也很有用。
使用一些工具诊断性能问题¶
打破规则:始终先测量¶
你通常应该遵循这些规则,因为它们在大多数情况下都能带来一致的性能。
但是你可能有一些特殊情况需要打破这些规则。在这种情况下,首先要做的是使用分析器进行**测量**,以便知道你的问题出在哪里。
然后,也只有在那之后,你才能决定通过使用一些可变 state 和/或自定义 shouldComponentUpdate
实现来打破规则。
并记住在你进行更改后再次进行测量,以检查和证明你的更改确实产生了影响。理想情况下,在请求性能补丁的审查时,你应该始终提供指向配置文件的链接。