setState是同步还是异步?原理是什么?

3,767 阅读8分钟

本文已参与「新人创作礼」活动,一起开启掘金创作之路。详情

setState异步更新

我们都知道,React通过this.state来访问state,通过this.setState()方法来更新state。当this.setState()方法被调用的时候,React会重新调用render方法来重新渲染UI

那么setState任何时候都是异步的吗?

首先如果直接在setState后面获取state的值是获取不到的。在React内部机制能检测到的地方, setState就是异步的;在React检测不到的地方,例如 原生事件addEventListener,setInterval,setTimeoutsetState就是同步更新的

✔setState是同步还是异步呢?

setState并不是单纯的异步或同步,这其实与调用时的环境相关

  • 合成事件生命周期钩子(除componentDidUpdate) 中,setState是"异步"的;
  • 原生事件setTimeout 中,setState是同步的,可以马上获取更新后的值;
  • 批量更新:多个顺序的setState不是同步地一个一个执行滴,会一个一个加入队列,然后最后一起执行。在 合成事件 和 生命周期钩子 中,setState更新队列时,存储的是 合并状态(Object.assign)。因此前面设置的 key 值会被后面所覆盖,最终只会执行一次更新。
  • 函数式: setState第一个参数为函数形式时,在这个函数中可以回调拿到最新的state对象,然后函数return出的对象讲被设置成newState。this.setState((state, props) => newState)

所谓异步?

setState 的“异步”并不是说内部由异步代码实现,其实本身执行的过程和代码都是同步的,只是合成事件和钩子函数的调用顺序在更新之前,导致在合成事件和钩子函数中没法立马拿到更新后的值,形成了所谓的“异步”,当然可以通过第二个参数setState(partialState, callback)中的callback拿到更新后的结果

批量更新:

setState 的批量更新优化也是建立在“异步”(合成事件、钩子函数)之上的,在原生事件和setTimeout 中不会批量更新。① 在“异步”中如果对同一个值进行多次setStatesetState的批量更新策略会对其进行覆盖,取最后一次的执行,② 如果是同时setState多个不同的值,在更新时会对其进行合并批量更新。

为什么要setState"异步化"批量处理呢?

  • 做成异步设计是为了性能优化,减少渲染次数
  • 保持内部一致性。如果将 state 改为同步更新,那尽管 state 的更新是同步的,但是 props不是
  • 启用并发更新,完成异步渲染。

setState原理

image-20220219202402927.png

setState 并非真异步,只是看上去像异步。在源码中,通过 isBatchingUpdates 来判断

setState调用流程:

①调用this.setState(newState) -> ②将新状态newState存入pending队列 -> ③判断是否处于batch UpdateisBatchingUpdates是否为true) -> ④isBatchingUpdates=true,保存组件于dirtyComponents中,走异步更新流程,合并操作,延迟更新;

isBatchingUpdates=false,走同步过程。遍历所有的dirtyComponents,调用updateComponent,更新pending state or props

setState批量更新的过程

react生命周期和合成事件执行前后都有相应的钩子,分别是pre钩子和post钩子

  1. pre钩子会调用batchedUpdate方法将isBatchingUpdates变量置为true,也就是将状态标记为现在正处于更新阶段了。开启批量更新。

    setState的更新会被存入队列中,待同步代码执行完后,再执行队列中的state更新。 isBatchingUpdates若为 true,则把当前组件(即调用了 setState的组件)放入 dirtyComponents 数组中;否则 batchUpdate 所有队列中的更新

  1. post钩子会将isBatchingUpdates置为false

为什么直接修改this.state无效

  • setState本质是通过一个队列机制实现state更新的。 执行setState时,会将需要更新的state合并后放入状态队列,而不会立刻更新state,队列机制可以批量更新state
  • 如果不通过setState而直接修改this.state,那么这个state不会放入状态队列中,下次调用setState时对状态队列进行合并时,会忽略之前直接被修改的state,这样我们就无法合并了,而且实际也没有把你想要的state更新上去

setState之后发生的事情

setState调用后,React会去diff state,若state变化然后会去diff DOM判断是否更新UI。如果每次setState都去走这些流程可能就会有性能问题。

所有短时间内多次的setState时,React会将state的改变压入栈中,在合适的时机,批量更新state和视图,达到提高性能的效果。

setState循环调用风险

  • 当调用setState时,实际上会执行enqueueSetState方法,并对partialState以及_pending-StateQueue更新队列进行合并操作,最终通过enqueueUpdate执行state更新
  • performUpdateIfNecessary方法会获取_pendingElement,_pendingStateQueue_pending-ForceUpdate,并调用receiveComponentupdateComponent方法进行组件更新
  • 如果在shouldComponentUpdate或者componentWillUpdate方法中调用setState,此时this._pending-StateQueue != null,就会造成循环调用,使得浏览器内存占满后崩溃

判断state输出

看第一个例子:setState的同步异步:

class Test extends React.Component {
  state  = {
      val: 0
  };
​
  componentDidMount() {
    this.setState({ val: this.state.val + 1 });
    console.log(this.state.val); // 0
    setTimeout(() => {
      this.setState({ val: this.state.val + 1 });
      console.log("setTimeout: " + this.state.val);  // 2
    }, 0);
  }
​
 
  render() {
    return null;
  }
};

输出结果0 2。过程解析:

① 直接在componentDidMount生命周期中的setState是异步的,此时的val+1并不会立即生效。所以下面第一个的log输出不会拿到最新的值,还是拿到的之前的值,输出0

② 在setTimeout中的setState是同步的,此时的val可以拿到最新的值,也就是①中最新的val值为1,此时再调用setState同步给val+1,同步得到val值为2,所以第二次的log输出就是2

再看一个例子:异步中setState的批量更新:

  componentDidMount() {
    this.setState({val: this.state.val + 1});
    console.log(this.state.val);    // 输出0
​
    this.setState({val: this.state.val + 1});
    console.log(this.state.val);    // 输出0
​
    setTimeout(() => {
      this.setState({val: this.state.val + 1});
      console.log(this.state.val);  // 输出2
​
      this.setState({val: this.state.val + 1});
      console.log(this.state.val);  // 输出3
    }, 0);
  }

输出结果:0 0 2 3。过程解析:

①前两次的输出都是直接在生命周期中的输出,所以是异步过程,拿不到最新的值,结果输出都是0;

②前两次的setState都是设置的val这同一个key值,更新操作会被覆盖,只执行最后一次,所以相当于只执行了一次val+1

③在setTimeout中时已经取到了最新的val值为1,此时调用setState为同步了,执行完setTimeout中的第一个val+1后val=2,这时候的第三个log输出即为2。

setTimeout中的第二次setState同理,为同步过程,直接在val+1=3后的log输出就直接是最新值3

再看关于setState的第二个参数取值

  componentDidMount() {
    this.setState({
      count: this.state.count + 1
    }, () => {
      console.log(this.state.count)  // 1
    })
    this.setState({
      count: this.state.count + 1
    }, () => {
      console.log(this.state.count)  // 1
    })
  }

输出结果是1 1,你猜对了吗?看一下过程解析:

① 在生命周期中的setState是异步的,此时设置同一个state的key值,操作会被覆盖,相当于只执行了一次count+1,所以实际上的count的值为1

② 我们知道setState的第二个参数是在更新完成后的回调,可以拿到最新的state。

③ 但是setState时这些回调都是会先把操作函数注入到队列中,等state的批量更新完成后再挨个执行这些回调。实际上执行这两个回调的时机是批量更新后依次执行的,此时的count是1,所以两个都是输出1。

④ 所以可以理解为两次的setState是合并在一起覆盖后只剩下一个。它们各自的回调是合并在一起执行的,所以都输出1

如果我们使用preState

  componentDidMount() {
    this.setState(
      preState => ({
        count: preState.count + 1
      }), () => {
        console.log(this.state.count) // 2
      })
    this.setState(
      preState => ({
        count: preState.count + 1
      }), () => {
        console.log(this.state.count) // 2
      })
  }

输出结果为2 2。我们知道setState的第一个参数可以直接是一个对象表示newState,也可以是一个回调函数,拿到上一次的state然后经过操作再return一个newState对象。所以这个执行流程就是:

① 第一个setState时,拿到上一次的count=0后执行+1操作,count变成了1

②第二个setState时,回调参数的preState中的count就是1,此时的+1操作就变成了2

③ 两次的setState的第二个回调参数同时依次执行,输出结果都是最新的count为2

其原因还是由于批量更新。① 如果setState第一个参数是对象,就存储其合并状态(Object.assign)。因此前面设置的 key 值会被后面所覆盖,多次修改同个key值的结果是最终只会执行一次更新;②当第一个参数是函数时,上面两个setState的操作会存两个函数在队列中。会执行了第一个函数后改变合并状态(Object.assign)中的这个key的值(count为1),然后再执行第二个函数时从最新状态获取后再count+1即为2,return出的对象再set合并状态(Object.assign)中的key值count的值为2了。

异步过程的总结

  • 通过setState去更新this.state,不要直接操作this.state,请把它当成不可变的
  • 调用setState更新this.state不是马上生效的,它是异步的,所以不要天真以为执行完setStatethis.state就是最新的值了
  • 多个顺序执行的setState不是同步地一个一个执行滴,会一个一个加入队列,然后最后一起执行,即批处理