React setState原理
在看这篇文章之前看一些问题,也是在面试中经常被问到的几个问题。然后带着疑问慢慢往下看查找答案。
setState
是异步的还是同步的?setState
为何是异步的?setState
如何变为同步?
一、setState
是异步的
你可以在组件中尝试如下代码👇
import React, { Component } from 'react'
class SetStateDemo01 extends Component {
constructor(props) {
super(props)
this.state = {
num: 0
}
}
componentDidMount() {
for (let i = 0; i < 100; i++) {
this.setState({ num: this.state.num + 1 })
console.log(this.state.num) // 输出 ?
}
}
render() {
return (
<div>
<p>{this.state.num}</p>
</div>
)
}
}
export default SetStateDemo01
我们定义了一个 SetStateDemo01
组件,在组件挂载后,会循环100次,每次都让 this.state.num
增加1。🔍查看结果
组件渲染结果为1,而控制台中输出了100次0,说明每次循环,拿到的state仍然是更新前的值。
二、setState
为异步的主要是为了优化
假设 setState
是同步的,在上面的例子中调用了 100 次 setState
,即组件要被重新渲染 100 次,这无疑是对性能一个很大的负担。
因此 React
为了优化性能,将 setState
设为异步操作,将多个状态合并一起更新,减少 re-render
调用。
这是 React
的优化手段,但基于上面的需求就没办法完成了吗?不。
三、setState变为同步
针对上面这种情况,React
给出了一种解决方案:setState
接收的参数还可以是一个函数,函数接收两个参数,第一个参数 上一个 state
,第二个参数 props
。
将上面例子中的 componentDidMount
修改为如下
componentDidMount() {
for (let i = 0; i < 100; i++) {
this.setState(prevState => {
console.log(prevState.num)
return { num: prevState.num + 1 }
})
}
}
还有一种方法,在 setTimeout
里修改 state
也是同步的。
代码如下,输出结果和上面一致。
componentDidMount() {
for (let i = 0; i < 100; i++) {
setTimeout(() => {
console.log(this.state.num)
this.setState({ num: this.state.num + 1 })
}, 0)
}
}
原理解析
通过上面三个问题,你应该有对 setState
有大致的了解了,下面开始解析 setState
如何实现。
合并setState
说起合并,大家肯定想到一个方法 Object.assign
,因此 setState
可以看为👇👇👇
setState(stateChange) {
Object.assign(this.state, stateChange) // 合并接受到的setState接受到的参数
renderComponent(this) // 调用render 渲染组件
}
这也是 setState
的核心所在。
setState
队列
为了合并setState
,我们需要一个队列来保存每次setState
的数据,然后在一段时间后执行合并操作和更新state
,并清空这个队列,然后渲染组件。
什么是队列?
队列就是一种先进先出(First In First Out,FIFO)的数据结构,与栈(后进先出)不一样。打个比方,排队上公交车,排在前面的先上,后来添加的排在后面。从队列中取出一个元素称为出队,即上公交车的动作;将一个元素加入队列的称为入队,即去排队的动作。
const queue = [] // stateChange 队列
/**
* @description: 保存传过来的 state 对象以及待更新组件到队列中
* @param {object} stateChange state修改
* @param {component} component 需要更新的组件
*/
function enqueueSetState(stateChange, component) {
queue.push({
stateChange,
component
})
}
修改最初的 setState
方法
setState(stateChange) {
enqueueSetState(stateChange, this)
renderComponent(this) // 调用render 渲染组件
}
清空队列(合并多个state)
/**
* @description: 清除队列(合并state)
*/
function flush() {
let item
// 遍历队列
while (item = setStateQueue.shift()) {
const {
stateChange,
component
} = item
// 如果没有prevState,则将当前的state作为初始的prevState
if (!component.prevState) {
component.prevState = Object.assign({}, component.state)
}
// 如果stateChange是一个function,也就是setState的第二种形式
if (typeof stateChange === 'function') {
Object.assign(component.state, stateChange(component.prevState, component.props)) // 意思是先执行函数,然后把返回结果合并到state中
} else {
// 如果stateChange是一个对象,则直接合并到setState中
Object.assign(component.state, stateChange)
}
component.prevState = component.state
}
}
这里实现了 state
的更新,但没有渲染组件。因为渲染组件不能再队列中进行,因为同一个组件可能多次添加到队列中,因此我们需要另一个队列保存所有组件,不同之处是这个队列内没有重复的组件。
修改 enqueueSetState
方法:
const queue = [] // stateChange 队列
const renderQueue = [] // 组件队列
/**
* @description: 保存传过来的 state 对象以及待更新组件到队列中
* @param {object} stateChange state修改
* @param {component} component 需要更新的组件
*/
function enqueueSetState(stateChange, component) {
queue.push({
stateChange,
component
})
// 如果renderQueue里没有当前组件,则添加到队列中
if (!renderQueue.some(item => item === component)) {
renderQueue.push(component);
}
}
修改 flush
方法:
function flush() {
let item, component
// 遍历队列
while (item = setStateQueue.shift()) {
// ...
}
// 渲染每一个更新的组件
while(component = renderQueue.shift()) {
renderComponent(component)
}
}
现在基本完成了大致的setState
结构,我们发现 setState
目前还是同步执行的,那如何 异步?即什么时候执行 flush
方法。
我们需要合并一段时间内所有的setState,也就是在一段时间后才执行flush方法来清空队列,关键是这个“一段时间“怎么决定。
“异步”
利用 js 的事件队列机制完成,倘若还不是很了解 event loop可以查看这篇文章 说说事件循环机制
添加 defer
方法
function defer(fn) {
return Promise.resolve().then(fn)
}
当然也可以使用 setTimeout
、requestAnimationFrame 或 requestIdleCallback
修改 enqueueSetState
方法
function enqueueSetState(stateChange, component) {
// 如果queue的长度是0,也就是在上次flush执行之后第一次往队列里添加
if (queue.length === 0) {
defer(flush);
}
queue.push({
stateChange,
component
})
// 如果renderQueue里没有当前组件,则添加到队列中
if (!renderQueue.some(item => item === component)) {
renderQueue.push(component);
}
}
大致流程是这样:
- 执行
setState
,内部执行enqueueSetState
将当前传入的对象或数组推入队列。 enqueueSetState
执行之前会判断是否队列为空,为空代表第一次往队列添加。这时设置一个 微任务(执行清空队列flush
)。- 然后继续执行所以同步代码(其中可能包括多个
setState
,因此循环第一、二步)。 - 所有同步代码执行完毕,取下一个微任务,即第二步推入的微任务
flush
。 - 然后
flush
内部会合并所有队列中的state
对象,然后更新对应组件。这就是setState
的核心原理。
微任务是 event loop里面的一个概念。
FAQ?
为什么setState
传入函数就可以同步了?
因为 Object.assign
无法合并函数,因此可以看到 我们在 flush
的 Object.assign
操作里,遇到函数我们都是先执行,后合并的,所有函数内部可以获取到上一个 state
的值。
为什么 setTimeout
也能达到同样的效果?
这个和 event loop 有关,其实 setState
懂了,这个也会明白的。