React setState原理

React setState原理

Apr 05, 2024 ·
8 Min Read

在看这篇文章之前看一些问题,也是在面试中经常被问到的几个问题。然后带着疑问慢慢往下看查找答案。

  1. setState 是异步的还是同步的?
  2. setState 为何是异步的?
  3. 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。🔍查看结果

React setState原理
React setState原理

组件渲染结果为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 }
})
}
}
React setState原理
React setState原理

还有一种方法,在 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);
}
}

大致流程是这样:

  1. 执行 setState,内部执行 enqueueSetState 将当前传入的对象或数组推入队列。
  2. enqueueSetState 执行之前会判断是否队列为空,为空代表第一次往队列添加。这时设置一个 微任务(执行清空队列flush)。
  3. 然后继续执行所以同步代码(其中可能包括多个 setState,因此循环第一、二步)。
  4. 所有同步代码执行完毕,取下一个微任务,即第二步推入的微任务 flush。
  5. 然后 flush 内部会合并所有队列中的 state对象,然后更新对应组件。这就是 setState 的核心原理。

微任务是 event loop里面的一个概念。

FAQ?

为什么setState传入函数就可以同步了?

因为 Object.assign 无法合并函数,因此可以看到 我们在 flush 的 Object.assign 操作里,遇到函数我们都是先执行,后合并的,所有函数内部可以获取到上一个 state 的值。

为什么 setTimeout 也能达到同样的效果?

这个和 event loop 有关,其实 setState懂了,这个也会明白的。

Last edited Feb 15