[译]React 性能工程

这是 React 性能工程系列的第一部分, 第二部分见深入了解 React 性能调试

本文是为了复杂的 react 项目. 如果你只是在做一些小型项目, 当还没有面临性能问题时, 请不要考虑优化, 只要构建就好了!

然而, 当开始写一些 DNA 设计工具啦, 凝胶图像分析软件啦, 富文本编辑器或是功能完备的表格系统时, 免不了会遇见性能瓶颈, 并需要尝试寻找解决之道, 本文就是尝试分享我们在此类问题上的一点经验.

本文中, 我将介绍 react 性能分析的基本工具, 一些导致性能瓶颈的常见问题, 调试上的一些关键点.

React 性能基础

用3句话概述浏览器性能: 理想下浏览器渲染是60帧/秒, 即 16.7ms 一帧. 当应用较慢, 通常用户事件, 数据处理会有较长的延迟. 多数情况并不会有复杂的数据处理, 大部分事件会浪费在重新渲染上.

使用 React 可以不做额外工作下立即一些性能优化.

因为 React 托管了所有 DOM 操作, 多数情况下 DOM 的解析与布局问题都可以被避免. 屏幕背后, React 维护了一套虚拟DOM 的机制, 使文档在最小的改变下让文档变回被期待的样子.

由于 React组件在 JavaScript 里存储状态, 不建议访问直接操作 DOM. 一个比较常见的例子是在不恰当的时机访问了一个 DOM, 然后导致了强制布局同步 (e.g. someNode.style.left浏览器会强制渲染一个 frame). 代替这种做法:

1
someNode.style.left = parseInt(someNode.style.left) + 10 + "px";

我们声明了 <SomeComponent style={ {left: this.state.left} } />, 然后通过更新 state 而非读取 DOM 的方式:

1
this.setState({left: this.state.left + 10}).

这些性能优化点并不局限于 React, 建议在做其他事情前先解决此类问题.

简单的 React 应用这些就足够, 在复杂的应用中, 虚拟 DOM 的对比可能成为昂贵的开销. 幸运的是 React 提供了一些性能检测工具来发现并防范问题.

调试带来的性能问题

小心, 一些调试本身就会带来间接成本. 不要在开发环境下调试.

ELEMENTS 窗口

(Chrome dev tools 上的) Elements 面板可以方便直观的看到什么元素被重新渲染了, 当属性变化或一个DOM 节点 被更新/新增/替换时会有一个闪烁的效果. 但是这种检测本身会影响到性能, 如果要准确计算 FPS 时, 请切换到 Consoles 面板.

PROPTYPES

React 开发中, PropType 校验 发生在组件被渲染时.利用 Chrome dev tools 上的Profiler面板可以观察到 React 组件花费了大量时间在校验(validate)的方法上

虽然开发工具的警告在调试阶段很有用, 但是生产环境下缺造成了额外的开销. 有时我会切换到生产模式来忽略这个延迟

通过 React 性能控件检测性能问题

在深入通常修复的方法前, 有一个重要的原则: 只花时间优化真的测试出有性能影响的问题. 构建中只应该在关键的性能瓶颈上投入时间.

检测性能瓶颈还可以用之前说的调试工具. 但通常会比较麻烦, 因为基于 react 的代码通常会耗时的方法通常会分散在不同地方.(e.g: 一个对比完的 render 运行很快, 但可能对应的 vdom 对比却很慢) 很难找到真正造成性能瓶颈的代码是什么.

幸运的是 React 包中本身就包含了一些可以在非生产环境使用的性能工具(doc) 可以在 <=0.13版本中通过 react/addons 找到 React.addons.Perf, 或是0.14之后的版本找到 react-addons-perf

使用

在 console 执行 Perf.start() 就可以使用 Perf 记录之后操作的性能. 然后调用Perf.stop(). 然后可以调用以下的方法之一进行性能观测.

PERF.PRINTWASTED()

Perf.printWasted() 大多数场合都很好用. 这个方法会告诉你渲染树结构花费的时间, VDOM 的对比不包含改变 DOM 的开销. 这里列出的组件可以尝试用 PureRenderMixin 或是之前提到的其他技术优化.

PERF.PRINTINCLUSIVE() / PERF.PRINTEXCLUSIVE()

Perf.printInclusive()Perf.printExclusive() 会打印出组件渲染的次数. 这对方法我不是太经常使用. 渲染的性能瓶颈解决方法通常是”不渲染” 或 “更快的渲染”. 总之这对方法可以突出组件在计算生命周期上的开销. 通常解决了Perf.printWasted()问题之后可以进一步优化这里的开销. Chrome DevTool Profiles 面板会更直接的看到每个函数的开销.

PERF.PRINTDOM()

Perf.printDOM() 会打印出渲染 React 树后所有的 DOM 操作. 通常这里的输出都很长很难理解/浏览.(比如会列出所有属性变化或新插入的 DOM 元素)

我偶尔会用这个方法寻找怪异且高开销的浏览器渲染

用 ShouldComponentUpdate 阻止渲染

React 维护了一套虚拟 DOM 以解决高昂的 DOM 操作成本, 但有时维护虚拟 DOM 也会有很高的成本. 想象一个大规模复杂的渲染树, 当更新了其中一个节点的 props 时, react 需要遍历所有的子节点进行对比. 好在 React 提供了一个优化机制: [shouldComponentUpdate](https://facebook.github.io/react/docs/component-specs.html#updating-shouldcomponentupdate). 当这个方法返回 false , 组件将不再进行重新渲染. 我们只需知道何时/如何返回 false

最简单的做法是让你的组件保持”纯(pure)” – 组件的渲染只依赖 props 和 state(也就是DOM,cookie 之类的变化不会影响到组件). “pure rendering” 技术经常被人提起, 但是做起来却并不简单. 需要将外部的状态隔离在一小部分组件中并保持剩余组件 pure. (译注: 参考这篇容器组件与展示组件)

做到这点后, 可以让组件继承 [React.PureComponent](https://facebook.github.io/react/docs/react-api.html#react.purecomponent) 其中用到了一种被称为”浅比较“的技术.

(译注: 上一段原作者采用的是(PureRenderMixin)[https://facebook.github.io/react/docs/pure-render-mixin.html]的方式, 我替换成了新版 React建议的 PureComponent)

纯组件中如果 props/state 没有改变, 组件就不会重渲染. 为了让这种模式下组件行为正确要确保如下两点:

  1. render只依赖 props 和 state, 没有来自外部的 state

  2. props 和 state 是不可变的.浅比较只是简单对比 props 和 state 对象是否相同. 可以利用 Object.assign_.extend复制对象的内容, 也可以使用[ImmutableJS](https://facebook.github.io/immutable-js/)

一个小误解

你可能误认为一旦用了纯组件就会提升性能. 实际上这会阻止子组件 prop 的类型校验. 在开发模式下看起来会快很多, 但生产环境本身就会跳过类型校验

一个大误解

即使严格遵守了纯组件的规范, 也不会立即从中获益. 如前所述 React 只是做了浅比较. 很多场合下prop 的变化都不是一个浅比较能处理的.

想快速实现一个深比较, 可以使用 _.isEqual. 它会先进行一次浅比较, 在遍历对比具体的每一项.

你也可以自己去写 shouldComponentUpdate 来满足个性化需要, 但是我们只在简单的地方会用到. 这个地方没维护好的话可能导致组件行为异常.

浅比较的性能优化

通常使用以下最佳实践可以阻止创建对象的开销, 大部分情况下这对渲染性能有好处

.bind 与 行内匿名函数

Function.bind 虽然方便给组件暴露上下文, 但每一个.bind 都会创建一个新函数.

1
2
3
4
5
6
7
8
9
> console.log.bind(null, 'hi') === console.log.bind(null, 'hi')
false
> function(){console.log(‘hi');} === function(){console.log(‘hi');}
false

// 每次都会是个新函数
render() {
return <MyComponent onClick={() => this.setState(...)} />
}

这样每次 render 时, props 都会发生改变

可以通过 eslint 的 react/jsx-no-bind规则禁止使用.bind

简单的方法是在子组件中创建一个函数来传递需要的参数, 比如

1
2
3
4
5
const TodoItem = React.createClass({
deleteItem() {
this.props.deleteItem(this.props.index);
},
});

这种暴露自己方法给子组件并被调用传回参数的行为看起来比较奇怪. 为适应更复杂的场景, 通常我们会实现一个 IntermediateBinder 进行上下文绑定. 以 id 参数举例, 将 id 作为一个参数绑定成它自己的一个方法, 并传给子组件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
const React = require('react/addons');

const IntermediateBinder = React.createClass({
displayName: 'IntermediateBinder',
propTypes: {
boundArg: React.PropTypes.any.isRequired,
children: React.PropTypes.func.isRequired,
},
_rebindFns(props, bindAll) {
const newFns = {};
for (const name in props) {
const value = props[name];
if (name !== 'boundArg' && name !== 'children') {
if (bindAll || value !== this.props[name]) {
newFns[name] = value.bind(null, props.boundArg);
} else {
newFns[name] = this._boundFns[name];
}
}
}
this._boundFns = newFns;
},
componentWillMount() {
this._rebindFns(this.props, true);
},
componentWillReceiveProps(nextProps) {
this._rebindFns(nextProps, this.props.boundArg !== nextProps.boundArg);
},
render() {
return this.props.children(this._boundFns);
},
});

module.exports = IntermediateBinder;

然后被这样调用:

1
2
3
4
5
<IntermediateBinder
deleteItem={this.deleteItem}
boundArg={item.id}>
{(boundProps) => <TodoItem deleteItem={boundProps.deleteItem} />}
</IntermediateBinder>

(我们讨论过另一种可能是自定义一个 bind 函数, 这个函数会存储元数据, 并自身实现一个方法检测函数是否真的改变. 但这种做法不太符合我们口味)

数组 / 对象 构造

一个很容易理解但通常容易被忽视的地方, 数组类型的值会破坏纯组件

1
2
> ['important', 'starred'] === ['important', 'starred']
false

如果一个对象不会再变化, 可以存储在一个常量或组件的静态变量中

1
const TAGS = ['important', 'starred'];

子组件

定义组件与子组件内容边界有利于性能优化 – 封装良好的组件接口便于日后性能更新. 可以通过重构中间状态的组件来抽离出纯组件.

1
2
3
4
5
6
7
<div>
<ComplexForm props={this.props.complexFormProps} />
<ul>
<li prop={this.props.items[0]}>item A</li>
...1000 items...
</ul>
</div>

这个例子中 complexFormProps 和 items 来自相同的 store, 在 ComplexForm中输入会改变 complexFormProps 的值. 每一次 complexFormProps 的改变都会带来整个 <ul>的重新渲染. 对每个 li 进行 diff 比较的成本十分昂贵. 将

    封装成一个子组件, 这样只有 this.props.items 变化才会更新

1
2
3
4
<div>
<ComplexForm props={this.props.complexFormProps} />
<CustomList items={this.props.items} />
</div>

缓存计算开销

这看起来违背了”单一 state 源”的规则. 但 props 计算开销是昂贵的, 可以在组件中做缓存. 代替直接的做法, 在 render 函数中进行 doExpensiveComputation(this.prop.someProp), 记录渲染的结果, 并在下一次当 prop 没变时直接输出缓存.

linkState

译注: linkState 在 React15+ 已经不再推荐(https://facebook.github.io/react/docs/two-way-binding-helpers.html). 所以这段不翻了.