React 深入理解 setState

react.jpg

为什么使用 setState

回到最早的案例, 当点击一个改变文本的按钮时, 修改界面显示的内容

tIyydK.png

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'

export default class App extends Component {
constructor(props) {
super(props)
this.state = {
message: 'Hello World'
}
}

render() {
return (
<div>
<h2>{this.state.message}</h2>
<button onClick={e => this.changeText()}>改变文本</button>
</div>
)
}

changeText() {
// ...
}
}

关键是changeText中应该如何实现?

我们是否可以通过直接修改state中的message来修改界面呢?

1
2
3
changeText() {
this.state.message = 'Hello React'
}
  • 点击不会有任何反应, 为什么呢?
  • 因为我们修改了state之后, 希望 React 根据最新的state来重新渲染界面, 但是这种方式的修改, React 并不知道数据发生了变化
  • React 并没有实现类似于 Vue2 中的Object.defineProperty或者 Vue3 中的Proxy的方式来监听数据的变化
  • 我们必须通过setState来告知 React 数据已经发生了变化

在组件中并没有实现setState的方法, 为什么可以直接调用呢?

原因很简单, setState方法是从Component中继承过来的

1
2
3
4
5
6
7
8
9
10
Component.prototype.setState = function(partialState, callback) {
invariant(
typeof partialState === 'object' ||
typeof partialState === 'function' ||
partialState == null,
'setState(...): takes an object of state variables to update or a ' +
'function which returns an object of state variables.',
);
this.updater.enqueueSetState(this, partialState, callback, 'setState');
};

NB869s.png

所以, 我们可以通过调用setState来修改数据

  • 当我们调用setState时, 会重新执行render函数, 根据最新的state来创建ReactElement对象
  • 再根据最新的ReactElement对象, 对 DOM 进行修改
1
2
3
4
5
changeText() {
this.setState({
message: 'Hello React'
})
}

setState 异步更新

1
2
3
4
5
6
changeText() {
this.setState({
message: 'Hello React'
})
console.log(this.state.message) // Hello World
}

最终打印结果是“Hello World”, 可见setState是异步的操作, 我们并不能在执行完setState之后立刻拿到最新的state

为什么setState设计为异步呢?

  • setState设计为异步其实之前在 GitHub 上也有很多的讨论
  • React 核心成员(Redux 作者) Dan Abramov 也有对应的回复

简单总结:

  • setState设计为异步, 可以显著的提升性能
    • 如果每次调用setState都进行一次更新, 那么意味着render函数会被频繁调用, 界面重新渲染, 效率非常低下
    • 比较好的办法应该是获取到多个更新, 之后进行批量更新
  • 如果同步更新state, 但是还没有执行render函数, 那么stateprops不能保持同步
    • stateprops不能保持一致性, 会导致在开发中引发很多的问题

那么如何可以获取更新后的state呢?

  • setState接受两个参数: 第二个参数是一个回调函数, 这个回调函数会在更行后执行
  • 格式为: setState(partialState, callback)
1
2
3
4
5
changeText() {
this.setState({
message: 'Hello React'
}, () => console.log(this.state.message)) // Hello React
}

当然, 我们也可以在生命周期函数中获取更新后的state

1
2
3
componentDidUpdate(prevProps, prevState, snapshot) {
console.log(this.state.message) // Hello React
}

setState 一定是异步吗?

验证一: 在setTimeout中的更新

1
2
3
4
5
6
7
8
changeText() {
setTimeout(() => {
this.setState({
message: 'Hello React'
})
console.log(this.state.message) // Hello React
}, 0)
}

验证二: 原生 DOM 事件

1
2
3
4
5
6
7
8
9
componentDidMount() {
const btnEl = document.getElementById('btn')
btnEl.addEventListener('click', e => {
this.setState({
message: 'Hello React'
})
console.log(this.state.message) // Hello React
})
}

分成两种情况

  • 在组件生命周期React 合成事件中, setState是异步的
  • setTimeout原生 DOM 事件中, setState是同步的

React 中其实是通过一个函数来确定的: enqueueSetState部分实现

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
enqueueSetState(inst, payload, callback) {
const fiber = getInstance(inst);

// 会根据 React 上下文计算一个当前时间
const currentTime = requestCurrentTimeForUpdate();
const suspenseConfig = requestCurrentSuspenseConfig();

// 这个函数会返回当前是同步还是异步更新(准确的说是优先级)
const expirationTime = computeExpirationForFiber(
currentTime,
fiber,
suspenseConfig,
);

const update = createUpdate(expirationTime, suspenseConfig);
update.payload = payload;
if (callback !== undefined && callback !== null) {
if (__DEV__) {
warnOnInvalidCallback(callback, 'setState');
}
update.callback = callback;
}

enqueueUpdate(fiber, update);
scheduleWork(fiber, expirationTime);
}

NBdBfU.png

computeExpirationForFiber函数的部分实现

  • Sync优先级最高, 即创建就更新

NB6aJ1.png

setState 的合并

数据的合并

假如我们有这样的数据

1
2
3
4
this.state = {
name: 'coderlion',
message: 'Hello World'
}

我们需要更新message

  • 通过setState去修改message是不会对name产生影响的
1
2
3
4
5
changeText() {
this.setState({
message: 'Hello React'
})
}

为什么不会产生影响呢? 源码中其实是有对原对象新对象进行合并的

  • 事实上就是使用Object.assign(target, ...sources)来完成的

NB6qFs.png

多个 setState 合并

比如我们还是有一个counter属性, 记录当前的数字

1
2
3
4
5
6
7
8
9
10
11
12
13
increment() {
this.setState({
counter: this.state.counter + 1
})

this.setState({
counter: this.state.counter + 1
})

this.setState({
counter: this.state.counter + 1
})
}

上面代码执行完之后counter会变成几呢? 答案是 1

为什么呢? 因为它会对多个state进行合并

其实在源码的processUpdateQueue中有一个do...while循环, 就是从队列中取出多个state进行合并的

NBcG1P.png

如何可以做到让counter最终变成 3 呢?

1
2
3
4
5
6
7
increment() {
this.setState((state, props) => ({ counter: state.counter + 1 }))

this.setState((state, props) => ({ counter: state.counter + 1 }))

this.setState((state, props) => ({ counter: state.counter + 1 }))
}

为什么传入一个函数就可以变成 3 呢?

原因是多个state进行合并时, 每次遍历都会执行一次函数

NBgNgx.png

React 更新机制

我们在前面已经学习 React 的渲染流程

NBWN2n.png

那么 React 的更新流程是什么呢?

NBfOfJ.png

React 在propsstate发生改变时, 会调用 React 的render方法, 创建出一颗不同的树

React 需要基于这两棵不同的树之间的差别来判断如何有效的更新 UI

  • 如果一棵树参考另外一棵树进行完全比较更新, 那么即使是最先进的算法, 该算法的时间复杂度为 O(n^3), 其中n是树中元素的数量, 具体参照《A Survey on Tree Edit Distance and Related Problems》
  • 如果在 React 中使用了该算法, 那么展示 1000 个元素所需要执行的计算量将在十亿的量级范围
  • 这个开销太过昂贵了, React 的更新性能会变得非常低效

于是, React 对于这个算法进行了优化, 将其优化成了 O(n), 如何优化的呢?

  • 同层节点之间相互比较, 不会垮节点比较
  • 不同类型的节点, 产生不同的树结构
  • 开发中, 可以通过key来指定哪些节点在不同的渲染下保持稳定

Diffing 算法

对比不同类型的元素

当节点为不同的与安素, React 会拆卸原有的树, 并且建立起新的树

  • 当一个元素从<a>变成<img>, 从<article>变成<comment>, 或从<button>变成<div>都会触发一个完整的重建流程
  • 当卸载一棵树时, 对应的 DOM 节点也会被销毁, 组件实例将执行componentWillUnmount()方法
  • 当建立一棵新的树时, 对应的 DOM 节点会被创建及插入到 DOM 中, 组件实例将执行componentWillMount()方法, 紧接着执行componentDidMount()方法

比如下面的代码更改

  • React 会销毁Counter组件并重新装载一个新的组件, 而不会对Counter进行复用
1
2
3
4
5
6
7
<div>
<Counter />
</div>

<span>
<Counter />
</span>

对比同类型的元素

当对比两个相同类型的 React 元素时, React 会保留 DOM 节点, 仅比对更新有改变的属性

比如下面的代码更改

  • 通过比对这两个元素, React 知道只需要修改 DOM 元素上的className属性
1
2
3
<div className="before" title="stuff" />

<div className="after" title="stuff" />

比如下面的代码更改

  • 当更新style属性时, React 仅更新有所改变的属性
  • 通过比对这两个元素, React 知道只需要修改 DOM 元素上的color样式, 无需修改fontWeight
1
2
3
<div style={{ color: 'red', fontWeight: 'bold' }} />

<div style={{ color: 'green', fontWeight: 'bold' }} />

如果是同类型的组件元素

  • 组件会保持不变, React 会更新该组件的props, 并且调用componentWillReceiveProps()componentWillUpdate()方法
  • 下一步, 调用render()方法, diff 算法将在之前的结果以及新的结果中进行递归

对子节点进行递归

在默认条件下, 当递归 DOM 节点的子元素时, React 会同时遍历两个子元素的列表, 当产生差异时, 生成一个mutation

在末尾插入一条数据的情况

1
2
3
4
5
6
7
8
9
10
<ul>
<li>first</li>
<li>second</li>
</ul>

<ul>
<li>first</li>
<li>second</li>
<li>third</li>
</ul>
  • 前面两个比较是完全相同的, 所以不会产生mutation
  • 最后一个比较, 产生一个mutation, 将其插入到新的 DOM 树中即可

但是如果我们是在中间插入一条数据

1
2
3
4
5
6
7
8
9
10
<ul>
<li>星际穿越</li>
<li>盗梦空间</li>
</ul>

<ul>
<li>大话西游</li>
<li>星际穿越</li>
<li>盗梦空间</li>
</ul>
  • React 会对每一个子元素产生一个mutation, 而不是保持<li>星际穿越</li><li>盗梦空间</li>的不变
  • 这种低效的比较方式会带来一定的性能问题

keys 的优化

我们在前面遍历列表时, 总是会提示一个警告, 让我们加入一个key属性

tvdTAA.png

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
import React, { Component } from 'react'

export default class App extends Component {
constructor(props) {
super(props)

this.state = {
movies: ['星际穿越', '盗梦空间']
}
}

render() {
return (
<div>
<h2>电影列表</h2>
<ul>
{
this.state.movies.map((item, index) => {
return <li>{item}</li>
})
}
</ul>
<button onClick={e => this.insertMovie()}>插入数据</button>
</div>
)
}

insertMovie() {
// ...
}
}

方式一: 在最后位置插入数据

  • 这种情况, 有无key意义并不大
1
2
3
4
5
6
insertMovie() {
const newMovies = [...this.state.movies, '大话西游']
this.setState({
movies: newMovies
})
}

方式二: 在前面插入数据

  • 这种做法, 在没有key的情况下, 所有的li都需要进行修改
1
2
3
4
5
6
insertMovie() {
const newMovies = ['大话西游', ...this.state.movies]
this.setState({
movies: newMovies
})
}

当子元素(这里的li)拥有key时, React 使用key来匹配原有树上的子元素以及最新树上的子元素

  • 在下面这种场景下, key111222的元素仅仅进行位移, 不需要进行任何的修改
  • key333的元素插入到最前面的位置即可
1
2
3
4
5
6
7
8
9
10
<ul>
<li key="111">星际穿越</li>
<li key="222">盗梦空间</li>
</ul>

<ul>
<li key="333">Connecticut</li>
<li key="111">星际穿越</li>
<li key="222">盗梦空间</li>
</ul>

key的注意事项

  • key应该是唯一
  • key不要使用随机数(随机数在下一次render时, 会重新生成一个数字)
  • 使用index作为key, 对性能是没有优化的

SCU 的优化

render 函数被调用

我们使用之前的一个嵌套案例

  • App中, 我们增加了一个计数器的代码
  • 当点击+1时, 会重新调用Apprender函数
  • 而当Apprender函数被调用时, 所有的子组件的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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
import React, { Component } from 'react'

function Header() {
console.log('Header Render 被调用')
return <h2>Header</h2>
}

class Main extends Component {
render() {
console.log('Main Render 被调用')
return (
<div>
<Banner />
<ProductList />
</div>
)
}
}

function Banner() {
console.log('Banner Render 被调用')
return <div>Banner</div>
}

function ProductList() {
console.log('ProductList Render 被调用')
return (
<ul>
<li>商品1</li>
<li>商品2</li>
<li>商品3</li>
<li>商品4</li>
<li>商品5</li>
</ul>
)
}

function Footer() {
console.log('Footer Render 被调用')
return <h2>Footer</h2>
}

export default class App extends Component {
constructor(props) {
super(props)

this.state = {
counter: 0
}
}

render() {
console.log('App Render 被调用')

return (
<div>
<h2>当前计数: {this.state.counter}</h2>
<button onClick={e => this.increment()}>+1</button>
<Header />
<Main />
<Footer />
</div>
)
}

increment() {
this.setState({
counter: this.state.counter + 1
})
}
}

NKTpdO.png

那么, 我们可以思考一下, 在以后的开发中, 我们只要是修改了App中的数据, 所有的组件都需要重新render, 进行 diff 算法, 性能必然是很低的

  • 事实上, 很多的组件没有必须要重新render
  • 它们调用render应该有一个前提, 就是依赖的数据(state、props)发生改变时, 再调用自己的render方法

如何来控制render方法是否被调用呢?

  • 通过shouldComponentUpdate方法即可

shouldComponentUpdate

React 给我们提供了一个生命周期方法shouldComponentUpdate(很多时候, 我们简称为SCU), 这个方法接受参数, 并且需要有返回值

  • 该方法有两个参数
    1. nextProps修改之后, 最新的props属性
    2. nextState修改之后, 最新的state属性
  • 该方法返回值是一个Boolean类型
    • 返回值为true, 那么就需要调用render方法
    • 返回值为false, 那么就不需要调用render方法
    • 默认返回true, 也就是只要state发生改变, 就会调用render方法
1
2
3
shouldComponentUpdate(nextProps, nextState) {
return true
}

我们可以控制它返回的内容, 来决定是否需要重新渲染

比如我们在App中增加一个message属性

  • JSX 中并没有依赖这个message, 那么它的改变不应该引起重新渲染
  • 但是因为render监听到state的改变, 就会重新render, 所以最后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
33
34
35
36
37
export default class App extends Component {
constructor(props) {
super(props)

this.state = {
counter: 0,
message: 'Hello World'
}
}

render() {
console.log('App Render 被调用')

return (
<div>
<h2>当前计数: {this.state.counter}</h2>
<button onClick={e => this.increment()}>+1</button>
<button onClick={e => this.changeText()}>改变文本</button>
<Header />
<Main />
<Footer />
</div>
)
}

increment() {
this.setState({
counter: this.state.counter + 1
})
}

changeText() {
this.setState({
message: 'Hello React'
})
}
}

这个时候, 我们可以通过实现shouldComponentUpdate来决定要不要重新调用render方法

  • 这个时候, 我们改变counter时, 会重新渲染
  • 如果, 我们改变的是message, 那么默认返回的是false, 那么就不会重新渲染
1
2
3
4
5
6
7
shouldComponentUpdate(nextProps, nextState) {
if (nextState.counter !== this.state.counter) {
return true
}

return false
}

但是我们的代码依然没有优化到最好, 因为当counter改变时, 所有的子组件依然重新渲染了

  • 所以, 事实上我们应该实现所有的子组件的shouldComponentUpdate

比如Main组件, 可以进行如下实现

  • shouldComponentUpdate默认返回一个false
  • 在特定情况下, 需要更新时, 我们在上面添加对应的条件即可
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Main extends Component {

shouldComponentUpdate(nextProps, nextState) {
return false
}

render() {
console.log('Main Render 被调用')
return (
<div>
<Banner />
<ProductList />
</div>
)
}
}

PureComponent 和 memo

如果所有的类, 我们都需要手动来实现shouldComponentUpdate, 那么会给我们开发者增加非常多的工作量

我们来设想一下shouldComponentUpdate中的各种判断的目的是什么?

  • props或者state中的数据是否发生了改变, 来决定shouldComponentUpdate返回true或者false

事实上 React 已经考虑到了这一点, 所以 React 已经默认帮我们实现好了, 如何实现呢?

  • class继承自PureComponent

比如我们修改Main组件的代码

1
2
3
4
5
6
7
8
9
10
11
class Main extends PureComponent {
render() {
console.log('Main Render 被调用')
return (
<div>
<Banner />
<ProductList />
</div>
)
}
}

PureComponent的原理是什么呢?

  • propsstatef进行浅层比较

查看PureComponent相关的源码

  • PureComponent的原型上增加一个isPureReactComponenttrue的属性

NBqkh6.png

NBq7vD.png

这个方法中, 调用!shallowEqual(oldProps, newProps) || !shallowEqual(oldState, newState), 这个shallowEqual就是进行浅层比较

NBLEan.png

那么, 如果是一个函数式组件呢?

我们需要使用一个高阶组件memo

  • 我们将之前的HeaderBannerProductList都通过memo函数进行一层包裹
  • Footer没有使用memo函数进行包裹
  • 最终的效果是, 当counter发生改变时, HeaderBannerProductList的函数不会重新执行, 而Footer的函数会被重新执行
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
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
import React, { Component, PureComponent, memo } from 'react'

const MemoHeader = memo(function() {
console.log('Header Render 被调用')
return <h2>Header</h2>
})

class Main extends PureComponent {
render() {
console.log('Main Render 被调用')
return (
<div>
<MemoBanner />
<MemoProductList />
</div>
)
}
}

const MemoBanner = memo(function() {
console.log('Banner Render 被调用')
return <div>Banner</div>
})

const MemoProductList = memo(function() {
console.log('ProductList Render 被调用')
return (
<ul>
<li>商品1</li>
<li>商品2</li>
<li>商品3</li>
<li>商品4</li>
<li>商品5</li>
</ul>
)
})

function Footer() {
console.log('Footer Render 被调用')
return <h2>Footer</h2>
}

export default class App extends Component {
constructor(props) {
super(props)

this.state = {
counter: 0,
message: 'Hello World'
}
}

render() {
console.log('App Render 被调用')

return (
<div>
<h2>当前计数: {this.state.counter}</h2>
<button onClick={e => this.increment()}>+1</button>
<button onClick={e => this.changeText()}>改变文本</button>
<MemoHeader />
<Main />
<Footer />
</div>
)
}

increment() {
this.setState({
counter: this.state.counter + 1
})
}

shouldComponentUpdate(nextProps, nextState) {
if (nextState.counter !== this.state.counter) {
return true
}

return false
}

changeText() {
this.setState({
message: 'Hello React'
})
}
}

memo的原理是什么呢?

NBLxeJ.png

最终返回一个对象, 这个对象中有一个compare函数

不可变数据的力量

我们通过一个案例来演练我们之前说的不可变数据的重要性

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
import React, { PureComponent } from 'react'

export default class App extends PureComponent {
constructor(props) {
super(props)

this.state = {
friends: [
{ name: 'lilei', age: 20, height: 1.76 },
{ name: 'lucy', age: 18, height: 1.65 },
{ name: 'tom', age: 30, height: 1.78 }
]
}
}

render() {
return (
<div>
<h2>朋友列表</h2>
<ul>
{
this.state.friends.map((item, index) => {
return (
<li key={item.name}>
<span>{`姓名:${item.name} 年龄: ${item.age}`}</span>
<button onClick={e => this.incrementAge(index)}>年龄+1</button>
</li>
)
})
}
</ul>
<button onClick={e => this.insertFriend()}>添加新数据</button>
</div>
)
}

insertFriend() {

}

incrementAge(index) {

}
}

我们来思考一下inertFriend应该如何实现?

1
2
3
4
5
6
insertFriend() {
this.state.friends.push({name: 'lion', age: 18, height: 1.88});
this.setState({
friends: this.state.friends
})
}
  • 这种方式会造成界面不会发生刷新, 添加新的数据
  • 原因是继承自PureComponent, 会进行浅层比较, 浅层比较过程中两个friends是相同的对象
1
2
3
4
5
insertFriend() {
this.setState({
friends: [...this.state.friends, {name: 'lion', age: 18, height: 1.88}]
})
}
  • [...this.state.friends, {name: 'lion', age: 18, height: 1.88}]会生成一个新的数组引用
  • 在进行浅层比较时, 两个引用的是不同的数组, 所以它们是不相同的

我们再来思考一下incrementAge应该如何实现?

1
2
3
4
5
6
incrementAge(index) {
this.state.friends[index].age += 1
this.setState({
friends: this.state.friends
})
}

和上面第一种方式类似

1
2
3
4
5
6
7
incrementAge(index) {
const newFriends = [...this.state.friends]
newFriends[index].age += 1
this.setState({
friends: newFriends
})
}

和上面第二种方式类似

所以, 在真实开发中, 我们要尽量保证stateprops中的数据不可变性, 这样我们才能合理和安全的使用PureComponentmemo

-------------本文结束感谢阅读-------------