setState 是同步还是异步的?

994 阅读5分钟

setState 对于大多数人来说并不陌生,可以说是 react 中高频出现的,但是有些时候能够立即拿到结果,有些时候却不能。那么 setState 到底是同步还是异步的呢?

从一个例子开始

在钩子函数和 React 合成事件中

  constructor(props) {
    super(props);
    this.state = {
      number: 1,
    };
  }
  componentDidMount() {
    this.setState({ number: 17 });
    console.log(this.state.number); // 1
    this.setState({ number: 27 });
    console.log(this.state.number); // 1
  }

我们发现 2 次打印结果都是 1,从表象上看,像是异步的操作, 但其实只是异步的表现形式,每次调用 setState 都会触发更新,出于性能考虑,React 会把多个 setState() 调用合并成一个调用,减少重新 render 的次数。

在异步函数和原生事件中

setTimeout(() => {
  this.setState({ number: 17 });
  console.log(this.state.number); // 17
}, 0);

这时的打印结果又是 17 了,看起来像是同步的操作。

setState 在面试题中也频繁出现:

state = {
  number: 1,
};
increment = () => {
  console.log("increment setState前的number", this.state.number);
  this.setState({
    number: this.state.number + 1,
  });
  console.log("increment setState后的number", this.state.number);
};
triple = () => {
  console.log("triple setState前的number", this.state.number);
  this.setState({
    number: this.state.number + 1,
  });
  this.setState({
    number: this.state.number + 1,
  });
  this.setState({
    number: this.state.number + 1,
  });
  console.log("triple setState后的number", this.state.number);
};
reduce = () => {
  setTimeout(() => {
    console.log("reduce setState前的number", this.state.number);
    this.setState({
      number: this.state.number - 1,
    });
    console.log("reduce setState后的number", this.state.number);
  }, 0);
};
render(){
    return <div>
      <button onClick={this.increment}>点我增加</button>
      <button onClick={this.triple}>点我增加三倍</button>
      <button onClick={this.reduce}>点我减少</button>
    </div>
  }

这 3 个按钮依次点击的打印结果是什么呢?


打印结果依次为:

increment setState前的number 1
increment setState后的number 1
triple setState前的number 2
triple setState后的number 2
reduce setState前的number 3
reduce setState后的number 2

你答对了吗?(如果答错的话,面试就挂啦

那么 setState 到底是同步还是异步的呢?接下来我们来看一下调用 setState 之后发生了什么。

调用 setState 之后发生了什么

在代码中调用 setState 函数之后,React 会将传入的参数对象与组件当前的状态合并,然后触发所谓的调和过程(Reconciliation)。经过调和过程,React 会以相对高效的方式根据新的状态构建 React 元素树并且着手重新渲染整个 UI 界面。在 React 得到元素树之后,React 会自动计算出新的树与老树的节点差异,然后根据差异对界面进行最小化重渲染。在差异计算算法中,React 能够相对精确地知道哪些位置发生了改变以及应该如何改变,这就保证了按需更新,而不是全部重新渲染。

setState 工作流为:

setState.png

isBatchingUpdates 是一个 react 全局唯一的变量,初始值是 false,意味着“当前并未进行任何批量更新操作”,每当 React 去执行更新动作时,会将 isBatchingUpdates 置为 true,表示现在正处于批量更新过程中。置为 true 时,任何需要更新的组件都只能暂时进入 dirtyComponents 里排队等候下一次的批量更新。 isBatchingUpdates 这个变量,在 React 的生命周期函数以及合成事件执行前,已经被 React 悄悄修改为了 true,这时我们所做的 setState 操作自然不会立即生效。当函数执行完毕后,事务的 close 方法会再把 isBatchingUpdates 改为 false。 因为 isBatchingUpdates 是在同步代码中变化的,而 setTimeout 的逻辑是异步执行的。当 this.setState 调用真正发生的时候,isBatchingUpdates 早已经被重置为了 false,这就使得当前场景下的 setState 具备了立刻发起同步更新的能力。

简单的模拟实现 setState

了解了 setState 的实现方式之后,我们来简单的模拟实现一个 setState,总结一下,主要是要实现 2 个功能:

  • 异步更新 state,将短时间内的多个 setState 合并成一个
  • 为了解决异步更新导致的问题,增加另一种形式的 setState:接受一个函数作为参数,在函数中可以得到前一个状态并返回下一个状态

调用 setState 之后首先执行了 enqueueSetState 方法

setState( stateChange ) {
    enqueueSetState( stateChange, this );
}

合并 setState

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

const queue = [];
const renderQueue = [];
function enqueueSetState(stateChange, component) {
  // 将新的 state 放进组件的状态队列里
  queue.push({
    stateChange,
    component,
  });
  // 如果renderQueue里没有当前组件,则添加到队列中
  if (!renderQueue.some((item) => item === component)) {
    renderQueue.push(component);
  }
  // 根据 this 拿到对应的组件实例
  var internalInstance = getInternalInstanceReadyForUpdate(
    component,
    "setState"
  );
  // 用 enqueueUpdate 来处理将要更新的实例对象
  enqueueUpdate(internalInstance);
}

然后我们来实现 enqueueUpdate 方法

enqueueUpdate(component) {
  if (!batchingStrategy.isBatchingUpdates) {
    // 若当前没有处于批量创建/更新组件的阶段,则立即更新组件
    batchingStrategy.batchedUpdates(enqueueUpdate, component);
    return;
  }
  // 否则,先把组件塞入 dirtyComponents 队列里,让它“再等等”
  dirtyComponents.push(component);
}

setState 的回调

setState( stateChange,callback ) {
    enqueueSetState( stateChange,this );
    if(callback) {
        this.enqueueCallback(this, callback, 'setState')
    }
}

如何同步的获取到更新后的数据?

那么我们如何同步的获取到更新后的数据呢?在官方文档中有所说明:

setState() 并不总是立即更新组件。它会批量推迟更新。这使得在调用 setState() 后立即读取 this.state 成为了隐患。为了消除隐患,请使用 componentDidUpdate 或者 setState 的回调函数(setState(updater, callback)),这两种方式都可以保证在应用更新后触发。

this.setState({ number: 17 }, () => {
  console.log(this.state.number); // 17
});

总结

setState 本身并不是异步,只是因为 react 的性能优化机制体现为异步。在 react 的生命周期函数或者合成事件下为异步,在 DOM 原生事件下以及 setTimeOut 为同步。

这里所说的同步异步,并不是真正的同步异步,它还是同步执行的。

这里的异步指的是多个 state 会合并到一起进行批量更新。

希望初学者不要被误导。