组件性能优化

网页性能最大因素是浏览器的重绘(reflow)和重排版(repaint)。React背后的 Virtual Dom就是尽可能地减少浏览器的重绘和重排版。

从react的渲染过程来看,如何防止不必要的渲染可能是最需要去解决的问题。然而针对这个问题,react官方提供了一个便捷的方法来解决,那就是pureRender。

1.纯函数

要理解pureRender的pure,还要从函数式编程的基本概念“纯函数”讲起来。“纯函数”构成三大原则:

  • 给定相同的输入,它总是返回相同的输出;
  • 过程没有副作用
  • 没有额外的状态依赖。

第一个原则给定相同的输入,它总是返回相同的输出。

我们来看我们常用的slice和splice方法,两者有相似的功能,都可以用来作数据截取。那么两者执行结果是一致的么?

1
2
3
4
5
6
7
8
9
const stars = ['earth', 'mars', 'mercury', 'venus']

stars.slice(0,2); // => ['earth', 'mars']
stars.slice(0,2); // => ['earth', 'mars']
stars.slice(0,2); // => ['earth', 'mars']

stars.splice(0,2); // => ['earth', 'mars']
stars.splice(0,2); // => ['mercury', 'venus']
stars.splice(0,2); // => []

我们清晰的看到slice方法在参数一样的情况下输出完全一样,而splice方法则会改变原数组。

第二原则过程没有副作用,说的就是在纯函数中我们不能改变外部状态。而在javascript中改变外部状态的情况比比皆是,就比如方法的参数是对象或数组,那么它本身就可能被方法执行过程中改变。

基于此,我们提出了Immutable的概念,让参数中的引用重新复制。用lodash的深拷贝。

第三个条件“没有额外的状态依赖”,就是指方法中的状态都只在方法的生命周期内存活,我们不能在方法内部使用共享变量,因为这会给方法带来不可知因素。

react在设计时带有函数式编程的基因,因为react组件本身就是纯函数。react的createElement方法保证了组件的纯净,即传入指定的props得到的一定的Virtual Dom,整个过程都是可预测的。

我们可以通过拆分组件为子组件,进而对组件做更细粒度的控制。保持纯净的状态,使组件或者方法更加专注,体积更少,更独立,更具有复用性和测试性。

2. PrueRender

pureRender是react组件开发中一个重要的概念。pureRender中的pure指的就是组件满足纯函数的条件,即组件渲染是被相同的props和state渲染进而得到相同的结果。这个概念和给定相同的输入,返回相同的输出一致。

2.1 PrueRender的本质

怎么实现这个过程,shouldComponentUpdate这个生命周期方法,让当前传入的props和state与之前的作浅比较,如果返回false,那么组件就不会执行render方法。

shouldComponentUpdate作性能优化,要作充分比较,只能通过深比较,但代价太昂贵了。

然而,PureRender 对 object 只作了引用比较,并没有作值比较。对于实现来说,这是一个取舍问题。PureRender 源代码中只对新旧 props 作了浅比较。以下是 shallowEqual 的示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function shallowEqual(obj, newObj) {
if (obj === newObj) {
return true;
}
const objKeys = Object.keys(obj);
const newObjKeys = Object.keys(newObj);
if (objKeys.length !== newObjKeys.length) {
return false;
}
// 关键代码,只需关注 props 中每一个是否相等,无需深入判断
return objKeys.every(key => {
return newObj[key] === obj[key];
});
}

2.2 PureRender运用

利用 createClass 构建组件时,可以使用官方的插件,其名为 react-addons-pure-render-mixin。 此外,用 ES6 classes 语法一样可以使用这个插件,比如:

1
2
3
4
5
6
7
8
9
10
import React, { Component } from 'react';
import PureRenderMixin from 'react-addons-pure-render-mixin';
class App extends Component {
constructor(props) {
super(props);
this.shouldComponentUpdate = PureRenderMixin.shouldComponentUpdate.bind(this);
}
render() {
return foo;
} }

当然,我们也可以用 decorator 来实现,其中 pure-render-decorator 库已经帮我们 实现了所需要的功能。在组件化开发过程中,要尽可能地满足 Pure,这样才能保证对相应的变更 作出最少的渲染。

2.3 PureRender优化

在使用 React 写组件的过程中,PureRender 可能是最重要也是最常见的性能优化方法。试想在数据可变的情况下,深比较的成本是相当昂贵的。但事实上,浅比较可以覆盖的场景并不是那么多。如果说 props 或 state 中有以下几种类型的情况,那么无论如何,它都会触发 PureRender 为true。

  • 直接为 props 设置对象或数组

我们知道,每次调用 React 组件其实都会重新创建组件。就算传入的数组或对象的值没有改变,它们引用的地址也会发生改变。比如,下面为 Account 组件设置一个 style prop:

1
<Account style={{ color: 'black' }} />

这样设置 prop,则每次渲染时 style 都是新对象。对于这样的赋值操作,我们只需要提前赋值成常量,不直接使用字面量即可。再比如,我们为 style prop 设置一个默认值也是一样的道理:

1
<Account style={this.props.style || {}} />

此时,我们只需要将默认值保存成同一份引用,就可以避免这个问题:

1
const defaultStyle = {};

同样,像在 props 中为对象或数据计算新值会使 PureRender 无效:

1
<Item items={this.props.items.filter(item => item.val > 30)} />

我们可以马上想到始终让对象或数组保持在内存中就可以增加命中率。但保持对象引用不符合函数式编程的原则,这为函数带来了副作用, Immutable.js 可以优雅地解决这类
问题。

  • 设置 props 方法并通过事件绑定在元素上
1
2
3
4
5
6
7
8
9
10
11
12
13
import React, { Component } from 'react';
class MyInput extends Component {
constructor(props){
super(props);
this.handleChange = this.handleChange.bind(this);
}
handleChange(e) {
this.props.update(e.target.value);
}
render() {
return <input onChange={this.handleChange} />;
}
}

我们不用每次都绑定事件,因此把绑定移到构造器内。如果绑定方法需要传递参数,那么可以考虑通过抽象子组件或改变现有数据结构解决。

  • 设置子组件

对于设置了子组件的 React 组件,在调用 shouldComponentUpdate 时,均返回 true。为什么呢?
下面以 NameItem 组件为例来介绍:

1
2
3
4
5
6
7
8
9
import React, { Component } from 'react';
class NameItem extends Component {
render() {
return (
<Item>
<span>Arcthur</span>
<Item/>)
}
}

上面的子组件 JSX 部分翻译过来,其实是:

1
2
3
<Item
children={React.createElement('span', {}, 'Arcthur')}
/>

显然,Item 组件不论什么情况下都会重新渲染。那么,怎么避免 Item 组件的重复渲染呢?
很简单,我们给 NameItem 设置 PureRender,也就是说提到父级来判断:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import React, { Component } from 'react';
import PureRenderMixin from 'react-addons-pure-render-mixin';
class NameItem extends Component {
constructor(props) {
super(props);
this.shouldComponentUpdate = PureRenderMixin.shouldComponentUpdate.bind(this);
}
render() {
return (
<Item>
<span>Arcthur</span>
</Item>);
}
}

如果 NameItem 再加兄弟组件,Item 组件不得不被影响到,解决方法同样是将 Item 抽象的
NameItem 提出。

3. Immutable

在传递数据时,可以直接使用 Immutable Data 来进一步提升组件的渲染性能。
JavaScript 中的对象一般是可变的(mutable),因为使用了引用赋值,新的对象简单地引用了原始对象,改变新的对象将影响到原始对象。比如:

1
2
3
foo = { a: 1 };
bar = foo;
bar.a = 2;

我们给 bar.a 赋值后,会发现此时 foo.a 也改成了 2。虽然这样做可以节约内存,但当应用复杂后,这就造成了非常大的隐患,可变性带来的优点变得得不偿失。为了解决这个问题,一般的做法是使用浅拷贝(shallowCopy)或深拷贝(deepCopy)来避免被修改,但这样做又造成了 CPU和内存的浪费。

这时 Immutable 的出现很好地解决这些问题。

3.1 Immutable Data

Immutable Data 就是一旦创建,就不能再更改的数据。对 Immutable 对象进行修改、添加或删除操作,都会返回一个新的 Immutable 对象。Immutable 实现的原理是持久化的数据结构(persistent data structure),也就是使用旧数据创建新数据时,要保证旧数据同时可用且不变。同时为了避免深拷贝把所有节点都复制一遍带来的性能损耗,Immutable 使用了结构共享(structural sharing),即如果对象树中一个节点发生变化,只修改这个节点和受它影响的父节点,其他节点则进行共享。

Facebook 工程师 Lee Byron 花费三年时间打造 Immutable.js 库,与 React 同期出现,但没有被默认放到 React 工具集里(React 提供了简化的 Helper)。它内部实现了一套完整的持久化数据结构,还有很多易用的数据类型,比如 Collection、List、Map、Set、Record、Seq。有非常全面的 map、filter、groupBy、reduce、find 等函数式操作方法。同时,API 也尽量与 JavaScript 的 Object或 Array 类似。

其中有 3 种最重要的数据结构说明一下。

  • Map:键值对集合,对应于 Object,ES6 也有专门的 Map 对象。
  • List:有序可重复的列表,对应于 Array。
  • ArraySet:无序且不可重复的列表。

3.2 Immutable 的优点

Immutable 的优点有如下几点。

  • 降低了“可变”带来的复杂度。可变数据耦合了 time 和 value 的概念,造成了数据很难被回溯。
  • 节省内存。Immutable 使用结构共享尽量复用内存。没有被引用的对象会被垃圾回收。
  • 撤销/重做,复制/粘贴,甚至时间旅行这些功能做起来都是小菜一碟。因为每次数据都是不一样的,那么只要把这些数据放到一个数组里存储起来,想回退到哪里,就拿出对应的数据,这很容易开发出撤销及重做这两种功能。
  • 并发安全。传统的并发非常难做,因为要处理各种数据不一致的问题,所以“聪明人”发明了各种锁来解决。但使用了 Immutable 之后,数据天生是不可变的,并发锁就不再需要了。然而现在并没有用,因为 JavaScript 还是单线程运行的。
  • 拥抱函数式编程。Immutable 本身就是函数式编程中的概念。只要输入一致,输出必然一致,这样开发的组件更易于调试和组装。

3.3 使用 Immutable 的缺点

容易与原生对象混淆是使用 Immutable 的过程中遇到的最大的问题。虽然 Immutable 尽量把 API 设计的原生对象类似,但还是很难区分到底是 Immutable 对象还是原生对象。
Immutable 中的 Map 和 List 虽然对应的是 JavaScript 的 Object 和 Array,但操作完全不同,比如取值时要用 map.get(‘key’) 而不是 map.key,要用 array.get(0) 而不是 array[0]。另外,Immutable 每次修改都会返回新对象,很容易忘记赋值。当使用第三方库的时候,一般需要使用原生对象,同样容易忘记转换对象。下面给出一些办
法来避免类似问题的发生:

  • 使用 FlowType 或 TypeScript 静态类型检查工具;
  • 约定变量命名规则,如所有 Immutable 类型对象以 $$ 开头;
  • 使用 Immutable.fromJS 而不是 Immutable.Map 或 Immutable.List 来创建对象,这样可以
    避免 Immutable 对象和原生对象间的混用。

3.4 Immutable.is

两个 Immutable 对象可以使用 === 来比较,这样是直接比较内存地址,其性能最好。但是
即使两个对象的值是一样的,也会返回 false:

1
2
3
let map1 = Immutable.Map({a:1, b:1, c:1});
let map2 = Immutable.Map({a:1, b:1, c:1});
map1 === map2; // => false

为了直接比较对象的值,Immutable 提供了 Immutable.is 来作“值比较”:

1
Immutable.is(map1, map2); // => true

Immutable.is 比较的是两个对象的 hashCode 或 valueOf(对于 JavaScript 对象)。由于Immutable 内部使用了 trie 数据结构来存储,只要两个对象的 hashCode 相等,值就是一样的。这样的算法避免了深度遍历比较,因此性能非常好。另外,还有 mori、cortex 等库。因为它们与 Immutable.is 类似,所以这里就不再一一介
绍了。

3.5 Immutable 与 cursor

这里的 cursor 和数据库中的游标是完全不同的概念。由于 Immutable 数据一般嵌套非常深,
所以为了便于访问深层数据,cursor 提供了可以直接访问这个深层数据的引用:

1
2
3
4
5
6
7
8
9
10
11
import Immutable from 'immutable';
import Cursor from 'immutable/contrib/cursor';
let data = Immutable.fromJS({ a: { b: { c: 1 } } });
// 让 cursor 指向 { c: 1 }
let cursor = Cursor.from(data, ['a', 'b'], newData => {
// 当 cursor 或其子 cursor 执行更新时调用
console.log(newData);
});
cursor.get('c'); // 1
cursor = cursor.update('c', x => x + 1);
cursor.get('c'); // 2

3.6 Immutable 与 PureRender

前面已经介绍过,React 做性能优化时最常用的就是 shouldComponentUpdate 方法,但它默认返回 true,即始终会执行 render 方法,然后做 Virtual DOM 比较,并得出是否需要做真实 DOM的更新,这里往往会带来很多没必要的渲染。

当然,我们也可以在 shouldComponentUpdate 中使用深拷贝和深比较来避免无必要的 render,但深拷贝和深比较一般都是非常昂贵的选择。

Immutable.js 则提供了简洁、高效的判断数据是否变化的方法,只需 === 和 is 比较就能知
道是否需要执行 render,而这个操作几乎零成本,所以可以极大提高性能。修改后的
shouldComponentUpdate 是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import React, { Component } from 'react';
import { is } from 'immutable';
class App extends Component {
shouldComponentUpdate(nextProps, nextState) {
const thisProps = this.props || {};
const thisState = this.state || {};
if (Object.keys(thisProps).length !== Object.keys(nextProps).length ||
Object.keys(thisState).length !== Object.keys(nextState).length) {
return true;
}
for (const key in nextProps) {
if (nextProps.hasOwnProperty(key) && !is(thisProps[key], nextProps[key])) {
return true;
}
}
for (const key in nextState) {
if (nextState.hasOwnProperty(key) && !is(thisState[key], nextState[key])) {
return true;
}
}
return false;
}
}

3.7 Immutable 与 setState

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import React, { Component } from 'react';
import '_' from 'lodash';
class App extends Component {
constructor(props) {
super(props);
this.state = {
data: { times: 0 },
}
}
handleAdd() {
let data = _.cloneDeep(this.state.data);
data.times = data.times + 1;
this.setState({ data: data });
// 如果上面不做 cloneDeep,下面打印的结果会是加 1 后的值
console.log(this.state.data.times);
}
}

但在使用 Immutable 后,操作变得很简单:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import React, { Component } from 'react';
class App extends Component {
constructor(props) {
super(props);
this.state = {
data: Map({ times: 0 }),
}
}
handleAdd() {
this.setState(({ data }) => ({
data: data.update('times', v => v + 1),
}));
// 这时的 times 并不会改变
console.log(this.state.data.get('times'));
}
}

Immutable 可以给应用带来极大的性能提升,但是否使用还要看项目情况。由于侵入性较强,新项目引入比较容易,老项目迁移需要谨慎地评估迁移成本。对于一些提供给外部使用的公共组件,最好不要把 Immutable 对象直接暴露在对外的接口中。

4. Key

写动态子组件的时候,如果没有给动态子项添加 key prop,则会报一个警告:
Warning: Each child in an array or iterator should have a unique “key” prop. Check the render method
of ‘App’. See https://fb.me/react-warning-keys for more information.
这个警告指的是,如果每一个子组件是一个数组或迭代器的话,那么必须有一个唯一的 key prop。这个 key prop 究竟是做什么的呢?

我们想象一下,假如需要渲染一个有 5000 项的成绩排名榜单,而且每隔几秒就会更新一次排名,其中大部分排名只是位置变了,还有少部分的是完全更新了,少部分则是清出榜单了。此时 key 就发挥作用了,它是用来标识当前项的唯一性的 props。现在尝试来描述这一场景,我们有一份学生的成绩数组:

[{
sid: ‘600211’,
name: ‘Cam’,
}, {
sid: ‘600243’,
name: ‘Arcthur’,
}, {
sid: ‘600225’,
name: ‘Echo’,
}]

其中,sid 是学号,name 是名字。那么,我们来实现成绩排名的榜单:

1
2
3
4
5
6
7
8
9
10
import React from 'react';
function Rank({ list }) {
return (
<ul>
{list.map((entry, index) => (
<li key={index}>{entry.name}</li>
))}
</ul>
);
}

我们把 key 设成了序号,这么做的确不会报警告了,但这是非常低效的做法。我们在生产环境下常常犯这样的错误,这个 key 是每次用来做 Virtual DOM diff 的,每一位同学都用序号来更新的问题是它没有和同学的唯一信息相匹配,相当于用了一个随机键,那么不论有没有相同的项,更新都会重新渲染。
正确的做法也很简单,只需要把 key 的内容换成 sid 就可以了:

1
2
3
4
5
6
7
8
9
10
import React from 'react';
function Rank({ list }) {
return (
<ul>
{list.map((entry, index) => (
<li key={entry.sid}>{entry.name}</li>
))}
</ul>
);
}

当 key 相同时,React 会怎么渲染呢?答案是只渲染第一个相同 key 的项,且会报一个警告:

Warning: flattenChildren(…): Encountered two children with the same key, .$a. Child keys must be unique;
when two children share a key, only the first child will be used.

因此,对 key 有一个原则,那就是独一无二,且能不用遍历或随机值就不用,除非列表内容也并不是唯一的表示,且没有可以相匹配的属性。关于 key,我们还需要知道的一种情况是,有两个子组件需要渲染的时候,我们没法给它们设 key。这时需要用到 React 插件 createFragment 来解决:

1
2
3
4
5
6
7
8
9
10
11
12
13
import React from 'react';
import createFragment from 'react-addons-create-fragment';
function Rank({ first, second }) {
const children = createFragment({
first: first,
second: second,
});
return (
<ul>
{children}
</ul>
);
}

上述代码中,first 和 second 两个 prop 的 key 就是我们设置对象的 key。

5. react-addons-perf

做了这么多工作,怎么才能量化以上所做的性能优化的效果呢?这里介绍一个性能检测工具
来帮助我们找到应用的性能瓶颈之所在。
react-addons-perf 是官方提供的插件。通过 Perf.start() 和 Perf.stop() 两个 API 设置开始和结束的状态来作分析。它会把各组件渲染的各个阶段的时间统计出来,然后打印出一张表格。

react-addons-perf 可以打印组件渲染的各个阶段。

  • Perf.printInclusive(measurements):所有阶段的时间。
  • Perf.printExclusive(measurements):不包含挂载组件的时间,即初始化 props、state,
    调用 componentWillMount 和 componentDidMount 方法的时间等。
  • Perf.printWasted(measurements):监测渲染的内容保持不变的组件(可以查看哪些组件
    没有被 shouldComponentUpdate 命中)。

无论是 PureRender 还是 key 值,整个 React 组件的优化逻辑都是针对 Virtual DOM 的更新优
化。如果需要用到更复杂的方法,可以深度探究 Virtual DOM 的运行原理。