本文将从 render 函数的角度总结 React App 的优化技巧。
需要提醒的是,文中将涉及 React 16.8.2 版本的内容(也即 Hooks),因此请至少了解 useState 以保证食用效果。
当我们讨论 React App 的性能问题时,组件的渲染速度是一个重要问题。在进入到具体优化建议之前,我们先要理解以下 3 点:
- 当我们在说「render」时,我们在说什么?
- 什么时候会执行「render」?
- 在「render」过程中会发生什么?
解读 render 函数
这部分涉及 reconciliation 和 diffing 的概念,当然官方文档在这里。
当我们在说「render」时,我们在说什么?
这个问题其实写过 React 的人都会知道,这里再简单说下:
在 class 组件中,我们指的是 render 方法:1
2
3
4
5class Foo extends React.Component{
render() {
return <h1> Foo </h1>;
}
}
在函数式组件中,我们指的是函数组件本身:1
2
3function Foo() {
return<h1> Foo </h1>;
}
什么时候会执行「render」?
render 函数会在两种场景下被调用:
状态更新时
a. 继承自 React.Component 的 class 组件更新状态时
1 | import React from "react"; |
可以看到,代码中的逻辑是我们点击就会更新 count,到 10 以后,就会维持在 10。增加一个 console.log,这样我们就可以知道 render 是否被调用了。从执行结果可以知道,即使 count 到了 10 以上,render 仍然会被调用。
总结: 继承了 React.Component 的 class 组件,即使状态没变化,只要调用了setState 依然会触发 render。
b. 函数式组件更新状态时
我们用函数实现相同的组件,当然因为要有状态,我们用上了 useState hook: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
29import React, { useState } from "react";
import ReactDOM from "react-dom";
class App extends React.Component {
render() {
return <Foo />;
}
}
function Foo() {
const [count, setCount] = useState(0);
function increment() {
const newCount = count < 10 ? count + 1 : count;
setCount(newCount);
}
console.log("Foo render");
return (
<div>
<h1> {count} </h1>
<button onClick={increment}>Increment</button>
</div>
);
}
const rootElement = document.getElementById("root");
ReactDOM.render(<App />, rootElement);
我们可以注意到,当状态值不再改变之后,render 的调用就停止了。
总结: 对函数式组件来说,状态值改变时才会触发 render 函数的调用。
父容器重新渲染时
1 | import React from "react"; |
只要点击了 App 组件内的 Change name 按钮,就会重新 render。而且可以注意到,不管 Foo 具体实现是什么,Foo 都会被重新渲染。
总结: 无论组件是继承自 React.Component 的 class 组件还是函数式组件,一旦父容器重新 render,组件的 render 都会再次被调用。
在「render」过程中会发生什么?
只要 render 函数被调用,就会有两个步骤按顺序执行。这两个步骤非常重要,理解了它们才好知道如何去优化 React App。
Diffing 【对比】
在此步骤中,React 将新调用的 render 函数返回的树与旧版本的VDOM树进行比较,这一步是 React 决定如何更新 DOM 的必要步骤。虽然 React 使用高度优化的算法执行此步骤,但仍然有一定的性能开销。
Reconciliation 【更新】
基于 diffing 的结果,React 更新 DOM 树。这一步因为需要卸载和挂载 DOM 节点同样存在许多性能开销。
开始我们的 Tips
谨慎分配 state 以避免不必要的 render 调用
我们以下面为例,其中 App 会渲染两个组件:
CounterLabel
,接收 count 值和一个 inc 父组件 App 中状态 count 的方法。List
,接收 item 的列表。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
35
36
37
38
39import React, { useState } from "react";
import ReactDOM from "react-dom";
const ITEMS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12];
function App() {
const [count, setCount] = useState(0);
const [items, setItems] = useState(ITEMS);
return (
<div className="App">
<CounterLabel count={count} increment={() => setCount(count + 1)} />
<List items={items} />
</div>
);
}
function CounterLabel({ count, increment }) {
return (
<div>
<h1>{count} </h1>
<button onClick={increment}> Increment </button>
<div/>
);
}
function List({ items }) {
console.log("List render");
return (
<ul>
{items.map((item, index) => (
<li key={index}>{item} </li>
))}
</ul>
);
}
const rootElement = document.getElementById("root");
ReactDOM.render(<App />, rootElement);
执行上面代码可知,只要父组件 App 中的状态被更新,CounterLabel
和 List
就都会更新。
当然,CounterLabel
重新渲染是正常的,因为 count 发生了变化,自然要重新渲染;但是对于 List
而言,就完全是不必要的更新了,因为它的渲染与 count 无关。
尽管 React 并不会在 reconciliation 阶段真的更新 DOM,毕竟完全没变化,但是仍然会执行 diffing 阶段来对前后的树进行对比,这仍然存在性能开销。
还记得 render 执行过程中的 diffing 和 reconciliation 阶段吗?前面讲过的东西在这里碰到了。
因此,为了避免不必要的 diffing 开销,我们应当考虑将特定的状态值放到更低的层级或组件中(与 React 中所说的「提升」概念刚好相反)。在这个例子中,我们可以通过将 count 放到 CounterLabel
组件中管理来解决这个问题。
合并状态更新
因为每次状态更新都会触发新的 render 调用,那么更少的状态更新也就可以更少的调用 render 了。
我们知道,React class 组件有 componentDidUpdate(prevProps, prevState)
的钩子,可以用来检测 props 或 state 有没有发生变化。尽管有时有必要在 props 发生变化时再触发 state 更新,但我们总可以避免在一次 state 变化后再进行一次 state 更新这种操作: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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53import React from "react";
import ReactDOM from "react-dom";
function getRange(limit) {
let range = [];
for (let i = 0; i < limit; i++) {
range.push(i);
}
return range;
}
class App extends React.Component {
state = {
numbers: getRange(7),
limit: 7
};
handleLimitChange = e => {
const limit = e.target.value;
const limitChanged = limit !== this.state.limit;
if (limitChanged) {
this.setState({ limit });
}
};
componentDidUpdate(prevProps, prevState) {
const limitChanged = prevState.limit !== this.state.limit;
if (limitChanged) {
this.setState({ numbers: getRange(this.state.limit) });
}
}
render() {
return (
<div>
<input
onChange={this.handleLimitChange}
placeholder="limit"
value={this.state.limit}
/>
{this.state.numbers.map((number, idx) => (
<p key={idx}>{number} </p>
))}
</div>
);
}
}
const rootElement = document.getElementById("root");
ReactDOM.render(<App />, rootElement);
这里渲染了一个范围数字序列,即范围为 0 到 limit。只要用户改变了 limit 值,我们就会在 componentDidUpdate 中进行检测,并设定新的数字列表。
毫无疑问,上面的代码是可以满足需求的,但是,我们仍然可以进行优化。
上面的代码中,每次 limit 发生改变,我们都会触发两次状态更新:第一次是为了修改 limit,第二次是为了修改展示的数字列表。这样一来,每次 limit 的变化会带来两次 render 开销:
1 | // 初始状态 |
我们的代码逻辑带来了下面的问题:
- 我们触发了比实际需要更多的状态更新;
- 我们出现了「不连续」的渲染结果,即数字列表与 limit 不匹配。
为了改进,我们应避免在不同的状态更新中改变数字列表。事实上,我们可以在一次状态更新中搞定: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
35
36
37
38
39
40
41
42
43
44
45import React from "react";
import ReactDOM from "react-dom";
function getRange(limit) {
let range = [];
for (let i = 0; i < limit; i++) {
range.push(i);
}
return range;
}
class App extends React.Component {
state = {
numbers: [1, 2, 3, 4, 5, 6],
limit: 7
};
handleLimitChange = e => {
const limit = e.target.value;
const limitChanged = limit !== this.state.limit;
if (limitChanged) {
this.setState({ limit, numbers: getRange(limit) });
}
};
render() {
return (
<div>
<input
onChange={this.handleLimitChange}
placeholder="limit"
value={this.state.limit}
/>
{this.state.numbers.map((number, idx) => (
<p key={idx}>{number} </p>
))}
</div>
);
}
}
const rootElement = document.getElementById("root");
ReactDOM.render(<App />, rootElement);
使用 PureComponent 和 React.memo 以避免不必要的 render 调用
我们在之前的例子中看到将特定状态值放到更低的层级来避免不必要渲染的方法,不过这并不总是有用。
我们来看下下面的例子: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
33import React, { useState } from "react";
import ReactDOM from "react-dom";
function App() {
const [isFooVisible, setFooVisibility] = useState(false);
return (
<div className="App">
{isFooVisible ? (
<Foo hideFoo={() => setFooVisibility(false)} />
) : (
<button onClick={() => setFooVisibility(true)}>Show Foo </button>
)}
<Bar name="Bar" />
</div>
);
}
function Foo({ hideFoo }) {
return (
<>
<h1>Foo</h1>
<button onClick={hideFoo}>Hide Foo</button>
</>
);
}
function Bar({ name }) {
return <h1>{name}</h1>;
}
const rootElement = document.getElementById("root");
ReactDOM.render(<App />, rootElement);
可以看到,只要父组件 App 的状态值 isFooVisible 发生变化,Foo 和 Bar 就都会被重新渲染。
这里因为为了决定 Foo 是否要被渲染出来,我们需要将 isFooVisible 放在 App中维护,因此也就不能将状态拆出放到更低的层级。
不过,在 isFooVisible 发生变化时重新渲染 Bar 仍然是不必要的,因为 Bar 并不依赖 isFooVisible。我们只希望 Bar 在传入属性 name 变化时重新渲染。
那我们该怎么搞呢?两种方法。
其一,对 Bar 做记忆化(memoize):1
2
3const Bar = React.memo(function Bar({name}) {
return <h1>{name}</h1>;
});
这就能保证 Bar 只在 name 发生变化时才重新渲染。
此外,另一个方法就是让 Bar 继承 React.PureComponent 而非 React.Component:1
2
3
4
5class Bar extends React.PureComponent {
render() {
return <h1>{name}</h1>;
}
}
是不是很熟悉?我们经常提到使用 React.PureComponent 能带来一定的性能提升,避免不必要的 render。
总结: 避免组件不必要的渲染的方法有:React.memo 包裹的函数式组件,继承自 React.PureComponent 的 class 组件 。
为什么不让每个组件都继承 PureComponent 或者用 memo 包呢?
如果这条建议可以让我们避免不必要的重新渲染,那我们为什么不把每个 class 组件变成 PureComponent、把每个函数式组件用 React.memo 包起来?为什么有了更好的方法还要保留 React.Component 呢?为什么函数式组件不默认记忆化呢?
毫无疑问,这些方法并不总是万灵药。
嵌套对象的问题
我们先来考虑下 PureComponent 和 React.memo 的组件到底做了什么?
每次更新的时候(包括状态更新或上层组件重新渲染),它们就会在新 props、state 和旧 props、state 之间对 key 和 value 进行浅比较。浅比较是个严格相等的检查,如果检测到差异,render 就会执行:
1 | // 基本类型的比较 |
尽管基本类型(如字符串、数字、布尔)的比较可以工作的很好,但对象这类复杂的情况可能就会带来意想不到的行为:
1 | shallowCompare({ name: {first: 'John', last: 'Schilling'}}, |
上述两个 name 对应的对象的引用是不同的。
我们重新看下之前的例子,然后修改我们传入 Bar 的 props: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
35
36
37
38
39import React, { useState } from "react";
import ReactDOM from "react-dom";
const Bar = React.memo(function Bar({ name: { first, last } }) {
console.log("Bar render");
return (
<h1>
{first} {last}
</h1>
);
});
function Foo({ hideFoo }) {
return (
<>
<h1>Foo</h1>
<button onClick={hideFoo}>Hide Foo</button>
</>
);
}
function App() {
const [isFooVisible, setFooVisibility] = useState(false);
return (
<div className="App">
{isFooVisible ? (
<Foo hideFoo={() => setFooVisibility(false)} />
) : (
<button onClick={() => setFooVisibility(true)}>Show Foo</button>
)}
<Bar name={{ first: "John", last: "Schilling" }} />
</div>
);
}
const rootElement = document.getElementById("root");
ReactDOM.render(<App />, rootElement);
尽管 Bar 做了记忆化且 props 值并没有发生变动,每次父组件重新渲染时它仍然会重新渲染。这是因为尽管每次比较的两个对象拥有相同的值,引用并不同。
函数 props 的问题
我们也可以把函数作为 props 向组件传递,当然,在 JavaScript 中函数也会传递引用,因此浅比较也是基于其传递的引用。
因此,如果我们传递的是箭头函数(匿名函数),组件仍然会在父组件重新渲染时重新渲染。
更好的 props 写法
前面的问题的一种解决方法是改写我们的 props。
我们不传递对象作为 props,而是将对象拆分成基本类型:1
<Bar firstName="John" lastName="Schilling" />
而对于传递箭头函数的场景,我们可以代以只唯一声明过一次的函数,从而总可以拿到相同的引用,如下所示:1
2
3
4
5
6
7
8
9
10class App extends React.Component{
constructor(props) {
this.doSomethingMethod = this.doSomethingMethod.bind(this);
}
doSomethingMethod () { // do something}
render() {
return <Bar onSomething={this.doSomethingMethod} />
}
}
控制更新
还是那句话,任何方法总有其适用范围。
第三条建议虽然处理了不必要的更新问题,但我们也不总能使用它。
而第四条,在某些情况下我们并不能拆分对象,如果我们传递了某种嵌套确实复杂的数据结构,那我们也很难将其拆分开来。
不仅如此,我们也不总能传递只声明了一次的函数。比如在我们的例子中,如果 App 是个函数式组件,恐怕就不能做到这一点了(在 class 组件中,我们可以用 bind 或者类内箭头函数来保证 this 的指向及唯一声明,而在函数式组件中则可能会有些问题)。
幸运的是, 无论是 class 组件还是函数式组件,我们都有办法控制浅比较的逻辑 。
在 class 组件中,我们可以使用生命周期钩子 shouldComponentUpdate(prevProps, prevState) 来返回一个布尔值,当返回值为 true 时才会触发 render。常用Immutable.js库的is()比较。 |
而如果我们使用 React.memo,我们可以传递一个比较函数作为第二个参数。
注意!React.memo 的第二参数(比较函数)和
shouldComponentUpdate
的逻辑是相反的,只有当返回值为 false 的时候才会触发 render。参考文档。
1 | const Bar = React.memo( |
尽管这条建议是可行的,但我们仍要注意比较函数的性能开销。如果 props 对象过深,反而会消耗不少的性能。
PureComponent Vs Component
它们几乎完全相同,但是PureComponent通过prop和state的浅比较来实现shouldComponentUpdate,某些情况下可以用PureComponent提升性能
- 所谓
浅比较
(shallowEqual),即react源码中的一个函数,然后根据下面的方法进行是不是PureComponent
的判断,帮我们做了本来应该我们在shouldComponentUpdate
中做的事情1
2
3if (this._compositeType === CompositeTypes.PureClass) {
shouldUpdate = !shallowEqual(prevProps, nextProps) || ! shallowEqual(inst.state, nextState);
}
而本来我们做的事情如下,这里判断了state
有没有发生变化(prop同理),从而决定要不要重新渲染,这里的函数在一个继承了Component
的组件中,而这里this.state.person
是一个对象,你会发现,在这个对象的引用没有发生变化的时候是不会重新render
的(即下面提到的第三点),所以我们可以用shouldComponentUpdate
进行优化,这个方法如果返回false
,表示不需要重新进行渲染,返回true
则重新渲染,默认返回true
1
2
3shouldComponentUpdate(nextProps, nextState) {
return (nextState.person !== this.state.person);
}
上面提到的某些情况下可以使用
PureComponent
来提升性能,那具体是哪些情况可以,哪些情况不可以呢,实践出真知如下显示的是一个
IndexPage
组件,设置了一个state
是isShow
,通过一个按钮点击可以改变它的值,
结果是:初始化的时候输出的是constructor
,render
,而第一次点击按钮,会输出一次render,即重新渲染了一次,界面也会从显示false
变成显示true
,
但是当这个组件是继承自PureComponent
的时候,再点击的时,不会再输出render
,即不会再重新渲染了,而当这个组件是继承自Component
时,还是会输出render
,还是会重新渲染,这时候就是PureComponent
内部做了优化的体现
同理也适用于
string
,number
等基本数据类型,因为基本数据类型,值改变了就算改变了1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25import React, { PureComponent } from 'react';
class IndexPage extends PureComponent{
constructor() {
super();
this.state = {
isShow: false
};
console.log('constructor');
}
changeState = () => {
this.setState({
isShow: true
})
};
render() {
console.log('render');
return (
<div>
<button onClick={this.changeState}>点击</button>
<div>{this.state.isShow.toString()}</div>
</div>
);
}
}当这个
this.state.arr
是一个数组时,且这个组件是继承自PureComponent
时,初始化依旧是输出constructor
和render
,但是当点击按钮时,界面上没有变化,也没有输出render
,证明没有渲染,但是我们可以从下面的注释中看到,每点击一次按钮,我们想要修改的arr
的值已经改变,而这个值将去修改this.state.arr
,但是因为在PureComponent
中浅比较
这个数组的引用没有变化所以没有渲染,this.state.arr
也没有更新,因为在this.setState()
以后,值是在render
的时候更新的,这里涉及到this.setState()
的知识但是当这个组件是继承自
Component
的时候,初始化依旧是输出constructor
和render
,但是当点击按钮时,界面上出现了变化,即我们打印处理的arr
的值输出,而且每点击一次按钮都会输出一次render
,证明已经重新渲染,this.state.arr
的值已经更新,所以我们能在界面上看到这个变化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
35
36import React, { PureComponent } from 'react';
class IndexPage extends PureComponent{
constructor() {
super();
this.state = {
arr:['1']
};
console.log('constructor');
}
changeState = () => {
let { arr } = this.state;
arr.push('2');
console.log(arr);
// ["1", "2"]
// ["1", "2", "2"]
// ["1", "2", "2", "2"]
// ....
this.setState({
arr
})
};
render() {
console.log('render');
return (
<div>
<button onClick={this.changeState}>点击</button>
<div>
{this.state.arr.map((item) => {
return item;
})}
</div>
</div>
);
}
}
7.下面的例子用扩展运算符
产生新数组,使this.state.arr
的引用发生了变化,所以初始化的时候输出constructor
和render
后,每次点击按钮都会输出render
,界面也会变化,不管该组件是继承自Component
还是PureComponent
的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
30import React, { PureComponent } from 'react';
class IndexPage extends PureComponent{
constructor() {
super();
this.state = {
arr:['1']
};
console.log('constructor');
}
changeState = () => {
let { arr } = this.state;
this.setState({
arr: [...arr, '2']
})
};
render() {
console.log('render');
return (
<div>
<button onClick={this.changeState}>点击</button>
<div>
{this.state.arr.map((item) => {
return item;
})}
</div>
</div>
);
}
}
8.上面的情况同样适用于对象
的情况
PureComponent不仅会影响本身,而且会影响子组件,所以PureComponent最佳情况是展示组件
1.我们让IndexPage
组件里面包含一个子组件Example
来展示PureComponent
是如何影响子组件的
2.父组件继承PureComponent
,子组件继承Component
时:下面的结果初始化时输出为constructor
,IndexPage render
,example render
,但是当我们点击按钮时,界面没有变化,因为这个this.state.person
对象的引用没有改变,只是改变了它里面的属性值所以尽管子组件是继承Component
的也没有办法渲染,因为父组件是PureComponent
,父组件根本没有渲染,所以子组件也不会渲染
3.父组件继承PureComponent
,子组件继承PureComponent
时:因为渲染在父组件的时候就没有进行,相当于被拦截了,所以子组件是PureComponent
还是Component
根本不会影响结果,界面依旧没有变化
4.父组件继承Component
,子组件继承PureComponent
时:结果和我们预期的一样,即初始化是会输出constructor
,IndexPage render
,example render
,但是点击的时候只会出现IndexPage render
,因为父组件是Component
,所以父组件会渲染,但是
当父组件把值传给子组件的时候,因为子组件是PureComponent
,所以它会对prop
进行浅比较,发现这个person
对象的引用没有发生变化,所以不会重新渲染,而界面显示是由子组件显示的,所以界面也不会变化
5.父组件继承Component
,子组件继承Component
时:初始化是会输出constructor
,IndexPage render
,example render
,当我们第一次点击按钮以后,界面发生变化,后面就不再改变,因为我们一直把它设置为sxt2,但是每点击一次都会输出IndexPage render
,example render
,因为每次不管父组件还是子组件都会渲染
6.所以正如下面第四条说的,如果state
和prop
一直变化的话,还是建议使用Component
,并且PureComponent
的最好作为展示组件
//父组件
import React, { PureComponent, Component } from 'react';
import Example from "../components/Example";
class IndexPage extends PureComponent{
constructor() {
super();
this.state = {
person: {
name: 'sxt'
}
};
console.log('constructor');
}
changeState = () => {
let { person } = this.state;
person.name = 'sxt2';
this.setState({
person
})
};
render() {
console.log('IndexPage render');
const { person } = this.state;
return (
<div>
<button onClick={this.changeState}>点击</button>
<Example person={person} />
</div>
);
}
}
//子组件
import React, { Component } from 'react';
class Example extends Component {
render() {
console.log('example render');
const { person } = this.props;
return(
<div>
{person.name}
</div>
);
}
}
三.若是数组和对象等引用类型,则要引用不同,才会渲染
四.如果prop和state每次都会变,那么PureComponent的效率还不如Component,因为你知道的,进行浅比较也是需要时间
五.若有shouldComponentUpdate,则执行它,若没有这个方法会判断是不是PureComponent,若是,进行浅比较
1.继承自Component
的组件,若是shouldComponentUpdate
返回false
,就不会渲染了,继承自PureComponent
的组件不用我们手动去判断prop
和state
,所以在PureComponent
中使用shouldComponentUpdate
会有如下警告:
IndexPage has a method called shouldComponentUpdate(). shouldComponentUpdate should not be used when extending React.PureComponent. Please extend React.Component if shouldComponentUpdate is used.
也是比较好理解的,就是不要在PureComponent
中使用shouldComponentUpdate
,因为根本没有必要.