React setState 同步异步的魅力

1,077 阅读8分钟

这是我参与8月更文挑战的第13天,活动详情查看:8月更文挑战

前言

在之前的一篇文章【React setState 异步真的只是为了性能吗?】中为大家简述了 React setState 异步的一些更较深层次原因,保持一致性为以后需的架构升级启动并发更新。文章发出之后,也收到了一位学长的思考,原话是“ 除了这个还可以思考什么是 Web,从最初的顶层设计就不可能是同步的,之后的 Fiber 也是要解决 idle 的问题,最后完成资源的完美调度”。这一句话也给出了更深次的见解,在这里感恩,后续会从这些角度深层次的挖掘。昨天聊完 React setState 异步的原因,今天我们来聊聊 React setState 同步异步的魅力。

  • 什么时候同步?
  • 什么时候异步?
  • 为什么会有同步?

1. 一道说起 setState 必考的面试题

这道面试题,不仅仅经常出现在 BAT 大厂的面试中,也出现在各类文章中,如下:

import React from "react";
import "./styles.css";
export default class App extends React.Component{
  state = {
    count: 0
  }
    // count +1
  increment = () => {
    console.log('increment setState前的count', this.state.count)
    this.setState({
      count: this.state.count + 1
    });
    console.log('increment setState后的count', this.state.count)
  }

  // count +1 三次
  triple = () => {
    console.log('triple setState前的count', this.state.count)
    this.setState({
      count: this.state.count + 1
    });
    this.setState({
      count: this.state.count + 1
    });
    this.setState({
      count: this.state.count + 1
    });
    console.log('triple setState后的count', this.state.count)
  }
    // count - 1
  reduce = () => {
    setTimeout(() => {
      console.log('reduce setState前的count', this.state.count)
      this.setState({
        count: this.state.count - 1
      });
      console.log('reduce setState后的count', this.state.count)
    }, 0);
  }
  render(){
    return <div>
      <button onClick={this.increment}> +1 </button>
      <button onClick={this.triple}> +1 三次 </button>
      <button onClick={this.reduce}> -1 </button>
    </div>
  }
}

测试代码地址:codesandbox.io/s/setstate-…

从左往右依次点击三个按钮,如果你能在脑海中快速的得出结果,哪恭喜你,你对 setState 的同步和异步有了一个了解。在我们最开始学习这个 API 的时候就能清楚的知道 setState 是一个异步的方法,当我们执行完 setState 时,并不会马上去触发状态的更新,所以在 increment 函数中两次输出都是 0 。在 triple 函数中虽然执行了三次 setState,但是批量更新收集三次相同的操作,变成了一个更新操作,在加上 setState 的异步,所以 triple 输出的值,只是在第一步最后变更后的值 1。在来看看第三个函数 reduce ,如果你是一个 React 初学者你可能有一点困惑 setState 竟然是同步更新。不要怀疑他就是同步更新了。哪对于一个老手来说,可能了然于胸,哪为什么会出现有时候是同步更新,有时候又是异步更新,今天我们就来聊聊 setState 异步同步更新的魅力(原理)。

2. 异步的魅力,批量操作的艺术

不管同步异步,setState 在被调用过后, React 做了什么?如果你对 React 版本的更新的历史比较了解,在不同 React 版本 setSatate 触发之后可能会存在一些小的差异,但是整体的思路是一样的。

React15

  • 触发 setState
  • shouldComponentUpdate
  • componentWillUpdate
  • render
  • componentDidUpdate

React16.3

  • 触发 setState
  • shouldComponentUpdate
  • render
  • getSnpshotBeforeUpdate
  • componentDidUpdate

React16.4

  • 触发 setState
  • getDerivedStateFromProps
  • shouldComponentUpdate
  • render
  • getSnapshotBeforeUpdate
  • componentDidUpdate

从这个简易的流程图可以看到,当触发 setState 之后,会有一个完整的更新流程,涉及了包括 re-render(重渲染) 在内的多个步骤。re-render 本身涉及对 DOM 的操作,它会带来较大的性能开销。假如说“一次 setState 就触发一个完整的更新流程”这个结论成立,那么每一次 setState 的调用都会触发一次 re-render,我们的视图很可能没刷新几次就卡死了。

this.setState({
  count: this.state.count + 1
}); // 触发re-render
this.setState({
  count: this.state.count + 1
}); // 触发re-render
this.setState({
  count: this.state.count + 1
}); // 触发re-render
...
// 页面卡死

说到这里你可能就知道为什么 setState 需要批量操作了。一个重要的原因就是,避免频繁的重渲染。他内部的机制和 Vue 的 $nextTick 和浏览器的 Event-loop 有点类似,多个 setState 执行,就把它塞进一个队列里存储起来,等到这次操作(当前的同步操作)完成,在将在队列中存储的状态(state)做合并,所以无论你执行多少次 setState ,最后只会针对最新的 state 值走一次更新流程,这就是批量操作更新。

this.setState({
  count: this.state.count + 1
});
// 进入队列[count + 1]
this.setState({
  count: this.state.count + 1
});
// 进入队列[count + 1, count + 1]
this.setState({
  count: this.state.count + 1
});
// 进入队列[count + 1, count + 1, count + 1]
...
// 合并state[count + 1]
// 执行count + 1

看到这里你可能已经对 React 的异步更新、批量更新有一定的了解,接着往下看,好戏还在后面。

3. 合成事件

在分析同步场景之前,需要先补充一个很重要的知识点,即 React 的合成事件,同样它也是 React 面试中很容易被考察的点,本文只是抛砖引玉简述 React 合成事件,后面会专门写一篇文章来说说 React 的合成事件。 在说合成事件之前,我们先说说我们最原始的事件委托,事件委托出现的目的更多的是为了性能考虑,举个例子:

<div>
  <div onclick="geText(this)">text 1</div>
  <div onclick="geText(this)">text 2</div>
  <div onclick="geText(this)">text 3</div>
  <div onclick="geText(this)">text 4</div>
  <div onclick="geText(this)">text 5</div>
   // ... 16~9999
  <div onclick="geText(this)">text 10000</div>
</div>

假设一个大的 div 标签下面有 10000 个 小的 div 标签。现在需要添加点击事件,通过点击获取当前 div 标签中的文本。那该如何操作?最简单的操作就是为每一个 内部 div 标签添加 onclick 事件。有 10000 个 div 标签,则会添加 10000 个事件。这是一种非常不友好的方式,会对页面的性能产生影响。所以事件委托起了大作用。通过将事件绑定在 外面大的 div 标签上这样的方式来解决。当 内部 div 点击时,由事件冒泡到父级的标签去触发,并在标签的 onclick 事件中,确认是哪一个标签触发的点击事件。

无独有偶,React 的合成事件也是如此,React 给 document 挂上事件监听;DOM 事件触发后冒泡到 document;React 找到对应的组件,造出一个合成事件出来;并按组件树模拟一遍事件冒泡。这样就有一个问题,就是在一个页面中,只能有一个版本的 React。如果有多个版本,事件就乱套了。

但是在 17 版本之后,这个问题得到了解决,事件委托不在挂载到 document 上,而是挂在 DOM 容器上,也就是 ReactDom.Render 所调用的节点上。

合成事件与 setState 的触发更新有千丝万缕的关系,也只有在了解合成事件后,我们才能继续聊 同步 setState。

4. 同步背后的故事

回到之前的例子,setState 在 setTimeout 函数的“包谷”之下,有了同步这一“功能”。为什么 setTimeout 可以将 setState 的执行顺序从异步变为同步?

setState 的工作机制

从源码的角度,我们来看看 setState 到底怎么工作的,注意源码 React 版本是 React 15(明古通今)。

// 入口
ReactComponent.prototype.setState = function (partialState, callback) {
  this.updater.enqueueSetState(this, partialState);
  if (callback) {
    this.updater.enqueueCallback(this, callback, 'setState');
  }
};
enqueueSetState: function (publicInstance, partialState) {
  // 获取组件实例
  var internalInstance = getInternalInstanceReadyForUpdate(publicInstance, 'setState');
  // 这个 queue 对应的就是一个组件实例的 state 数组
  var queue = internalInstance._pendingStateQueue || (internalInstance._pendingStateQueue = []);
  // 将新的 state 放进组件的状态队列里
  queue.push(partialState);
  //  enqueueUpdate 用来处理当前的组件实例
  enqueueUpdate(internalInstance);
}
function enqueueUpdate(component) {
  ensureInjected();
  // 注意这一句是问题的关键,isBatchingUpdates标识着当前是否处于批量创建/更新组件的阶段
  if (!batchingStrategy.isBatchingUpdates) {
    // 若当前没有处于批量创建/更新组件的阶段,则立即更新组件
    batchingStrategy.batchedUpdates(enqueueUpdate, component);
    return;
  }
  // 否则,先把组件塞入 dirtyComponents 队列里,让它“再等等”
  dirtyComponents.push(component);
  if (component._updateBatchNumber == null) {
    component._updateBatchNumber = updateBatchNumber + 1;
  }
}
var ReactDefaultBatchingStrategy = {
  // 全局唯一的锁标识
  isBatchingUpdates: false,
  // 发起更新动作的方法
  batchedUpdates: function(callback, a, b, c, d, e) {
    // 缓存锁变量
    var alreadyBatchingStrategy = ReactDefaultBatchingStrategy. isBatchingUpdates
    // 把锁“锁上”
    ReactDefaultBatchingStrategy. isBatchingUpdates = true
    if (alreadyBatchingStrategy) {
      callback(a, b, c, d, e)
    } else {
      // 启动事务,将 callback 放进事务里执行
      transaction.perform(callback, null, a, b, c, d, e)
    }
  }
}

源码中 isBatchingUpdates 属性直接决定了当下是要走更新流程,还是应该排队等待;其中的 batchedUpdates 方法更是能够直接发起更新流程。由此我们可以大胆推测,batchingStrategy 或许正是 React 内部专门用于管控批量更新的对象。

isBatchingUpdates 上" 锁 "

isBatchingUpdates 默认是 false ,意味着“当前并未进行任何批量更新操作”。每当 React 调用 batchedUpdate 去执行更新动作时,会先把这个锁给“锁上”(置为 true),表明“现在正处于批量更新过程中”。当锁被“锁上”的时候,任何需要更新的组件都只能暂时进入 dirtyComponents 里排队等候下一次的批量更新,而不能随意“插队”。此处体现的“任务锁”的思想,是 React 面对大量状态仍然能够实现有序分批处理的基石。

在 onClick、onFocus 等事件中,由于合成事件封装了一层,所以可以将 isBatchingUpdates 的状态更新为 true;在 React 的生命周期函数中,同样可以将 isBatchingUpdates 的状态更新为 true。那么在 React 自己的生命周期事件和合成事件中,可以拿到 isBatchingUpdates 的控制权,将状态放进队列,控制执行节奏。而在外部的原生事件中,并没有外层的封装与拦截,无法更新 isBatchingUpdates 的状态为 true。这就造成 isBatchingUpdates 的状态只会为 false,且立即执行。所以在 addEventListener 、setTimeout、setInterval 这些原生事件中都会同步更新。

总结

道理很简单,原理却很复杂。setState 并不是单纯同步/异步的,它的表现会因调用场景的不同而不同:在 React 钩子函数及合成事件中,它表现为异步;而在 setTimeout、setInterval 等函数中,包括在 DOM 原生事件中,它都表现为同步。这种差异,本质上是由 React 事务机制和批量更新机制的工作方式来决定的。

参考