Skip to content

React setState原理

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

  1. setState 是异步的还是同步的?
  2. setState 为何是异步的?
  3. setState 如何变为同步?

一、setState 是异步的

你可以在组件中尝试如下代码👇

jsx
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原理

组件渲染结果为1,而控制台中输出了100次0,说明每次循环,拿到的state仍然是更新前的值。

二、setState 为异步的主要是为了优化

假设 setState 是同步的,在上面的例子中调用了 100 次 setState ,即组件要被重新渲染 100 次,这无疑是对性能一个很大的负担。

因此 React 为了优化性能,将 setState 设为异步操作,将多个状态合并一起更新,减少 re-render 调用。

这是 React 的优化手段,但基于上面的需求就没办法完成了吗?不。

三、setState变为同步

针对上面这种情况,React 给出了一种解决方案:setState 接收的参数还可以是一个函数,函数接收两个参数,第一个参数 上一个 state,第二个参数 props

将上面例子中的 componentDidMount 修改为如下

jsx
componentDidMount() {
  for (let i = 0; i < 100; i++) {
    this.setState(prevState => {
      console.log(prevState.num)
      return { num: prevState.num + 1 }
    })
  }
}

React setState原理

还有一种方法,在 setTimeout 里修改 state 也是同步的。

代码如下,输出结果和上面一致。

jsx
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 可以看为👇👇👇

js
setState(stateChange) {
  Object.assign(this.state, stateChange) // 合并接受到的setState接受到的参数
  renderComponent(this) // 调用render 渲染组件
}

这也是 setState 的核心所在。

setState队列

为了合并setState,我们需要一个队列来保存每次setState 的数据,然后在一段时间后执行合并操作和更新state,并清空这个队列,然后渲染组件。

什么是队列?

队列就是一种先进先出(First In First Out,FIFO)的数据结构,与栈(后进先出)不一样。打个比方,排队上公交车,排在前面的先上,后来添加的排在后面。从队列中取出一个元素称为出队,即上公交车的动作;将一个元素加入队列的称为入队,即去排队的动作。

js
const queue = [] // stateChange 队列

/**
 * @description: 保存传过来的 state 对象以及待更新组件到队列中
 * @param {object} stateChange state修改
 * @param {component} component 需要更新的组件
 */
function enqueueSetState(stateChange, component) {
  queue.push({
    stateChange,
    component
  })
}

修改最初的 setState 方法

js
setState(stateChange) {
  enqueueSetState(stateChange, this)
  renderComponent(this) // 调用render 渲染组件
}

清空队列(合并多个state)

js
/**
 * @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 方法:

js
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 方法:

js
function flush() {
  let item, component
  // 遍历队列
  while (item = setStateQueue.shift()) {
    // ...
  }
  // 渲染每一个更新的组件
  while(component = renderQueue.shift()) {
    renderComponent(component)
  }
}

现在基本完成了大致的setState结构,我们发现 setState 目前还是同步执行的,那如何 异步?即什么时候执行 flush 方法。

我们需要合并一段时间内所有的setState,也就是在一段时间后才执行flush方法来清空队列,关键是这个“一段时间“怎么决定。

“异步”

利用 js 的事件队列机制完成,倘若还不是很了解 event loop可以查看这篇文章 说说事件循环机制

添加 defer 方法

js
function defer(fn) {
  return Promise.resolve().then(fn)
}

当然也可以使用 setTimeoutrequestAnimationFramerequestIdleCallback

修改 enqueueSetState 方法

js
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 无法合并函数,因此可以看到 我们在 flushObject.assign 操作里,遇到函数我们都是先执行,后合并的,所有函数内部可以获取到上一个 state 的值。

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

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

Released under the MIT License.