从一道面试题再看setState源码

首先,我们先上一道变体繁多的面试题,这里不贴代码,截图效果会好一些,感兴趣的点击这里查看DEMO代码

一、变体繁多的面试

问题是:从左到右依次点击按钮,控制台会输出什么?建议在脑海里先跑一遍,接着看看和下图的结果是否一致?

嗯哼,结果如何呢?是让你大跌眼镜还是符合预期?若是前者,没关系,跟着我一起探个究竟,若是后者,先恭喜你,也邀请你一起来探讨!

二、在 setState 调用之后,发生了什么?

你可能会想到React 15生命周期中的更新阶段,我们假设 ”一次setState就触发一个完整的更新流程“,那么每执行

this.setState({ count: this.state.count + 1 });

便会完整的执行下图的更新流程,其中re-render步骤 本身涉及了DOM操作,带来了较大的性能开销,进而导致多刷新几次视图就卡死了

三、异步 - 批量更新的艺术

为了避免频繁的re-render,在React管控下的setState被设计为异步的。它的实现类似于Vue$nextTick 和 浏览器的Event-Loop,在实际的React运行中:每来一个setState就把它塞进一个队列里,直到同步的代码执行完毕,便把state结果合并,最后针对最新的state执行一次上图的更新流程,即“批量更新”

到这里,我们回过头来看看上面面试题的前三个按钮点击后的执行流程:

由于异步的原因 state本身不会立即发生改变,因此点击增加前和后的count依然是初始化的0

四、“同步现象”背后的故事

机灵的同学有疑问了,不是说在React管控下的setState一定是异步的吗?那么点击同步减少里的逻辑:

setTimeout(() => {
      console.log("点击同步减少前 setState的count", this.state.count);
      this.setState({ count: this.state.count - 1 });
      console.log("点击同步减少后 setState的count", this.state.count);
}, 0);

setTimeout 又是如何将setState的执行顺序从异步改变为同步呢?接下来,我们从源码里去求证这个谜底

1、setState工作流

React中对于功能的拆分比较细致,在源码setState 涉及比较多的方法,为了更好的解析,这里把主流程提取出来如下:

接下来,我们沿着这个流程,逐个在源码中找出对应的函数

enqueueSetState 主要做了两件事

  • 将新的state塞进组件的状态队列里
  • 调用 enqueueUpdate处理将要更新的实例对象
enqueueSetState: function (publicInstance, partialState) {
  // 获取对应的组件实例
  var internalInstance = getInternalInstanceReadyForUpdate(publicInstance, 'setState');
  
  // 获取一个组件实例的 state 数组
  var queue = internalInstance._pendingStateQueue || (internalInstance._pendingStateQueue = []);
  queue.push(partialState);

  //  处理当前的组件实例
  enqueueUpdate(internalInstance);
}

enqueueUpdate

  • batchingStrategyReact内部专门用于管控批量更新的对象,其isBatchingUpdates属性决定了当下是走更新流程还是排队等待,batchedUpdates 方法可以直接发起更新流程
  • batchingStrategy 类比“锁管理器”,则isBatchingUpdatesReact全局唯一的任务“锁”,它初始值为false 意味着当前并未进行任何批量更新操作
  • React调用batchedUpdates 执行更新动作时,会先把“锁”给关上(置为true)表明现在正处于批量更新过程中
  • 关上“锁”后,任何需要更新的组件依次入队等候下一次的批量更新
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;
  }
}
  • batchedUpdates 函数里调用了React的事务机制Transaction

2、React 的事务机制 Transaction

官方对于 Transaction 的描述以及React源码中针对 Transaction 的注释如下:

Transaction 是创建一个黑盒,该黑盒能够封装任何的方法。因此,那些需要在函数运行前、后运行的方法可以通过此方法封装,实例化Transaction只需提供相关的方法即可

                     wrappers (injected at creation time)
                                     +        +
                                     |        |
                   +-----------------|--------|--------------+
                   |                 v        |              |
                   |      +---------------+   |              |
                   |   +--|    wrapper1   |---|----+         |
                   |   |  +---------------+   v    |         |
                   |   |          +-------------+  |         |
                   |   |     +----|   wrapper2  |--------+   |
                   |   |     |    +-------------+  |     |   |
                   |   |     |                     |     |   |
                   |   v     v                     v     v   | wrapper
                   | +---+ +---+   +---------+   +---+ +---+ | invariants
perform(anyMethod) | |   | |   |   |         |   |   | |   | | maintained
+----------------->|-|---|-|---|-->|anyMethod|---|---|-|---|-|-------->
                   | |   | |   |   |         |   |   | |   | |
                   | |   | |   |   |         |   |   | |   | |
                   | |   | |   |   |         |   |   | |   | |
                   | +---+ +---+   +---------+   +---+ +---+ |
                   |  initialize                    close    |
                   +-----------------------------------------+

事务机制

  • 一个 wrapper 包含 一组 initialize 、close 方法
  • Transaction 类似于一个“壳子”,它将目标函数用 wrapper 封装起来,使用 Transaction类暴露的 perform 方法去执行它
  • 在 anyMethod 执行前,perform会先执行所有 wrapper 的 initialize方法,执行后,再执行所有 wrapper 的 close 方法

更新策略

结合 事务机制 的理解,看下源码里的一个批量更新策略事务ReactDefaultBatchingStrategy,它有两个 wrapper,分别是 RESET_BATCHED_UPDATESFLUSH_BATCHED_UPDATES

var RESET_BATCHED_UPDATES = {
  initialize: emptyFunction,
  close: function () {
    ReactDefaultBatchingStrategy.isBatchingUpdates = false;
  }
};

var FLUSH_BATCHED_UPDATES = {
  initialize: emptyFunction,
  close: ReactUpdates.flushBatchedUpdates.bind(ReactUpdates)
};

var TRANSACTION_WRAPPERS = [FLUSH_BATCHED_UPDATES, RESET_BATCHED_UPDATES];
  • 当 anyMethod 执行完之后
  • FLUSH_BATCHED_UPDATES 执行 flushBatchedUpdates消费dirtyComponents,调用updateComponent执行所有生命周期,实现组件的更新
  • RESET_BATCHED_UPDATESisBatchingUpdates 置为 false,表明批量更新完成

五、“同步现象”的本质

接着,在源码中全局搜索batchedUpdates,调用它的地方很多,但与更新流程相关的,只有下面两处:

1、首次渲染组件会执行的_renderNewRootComponent 函数

开发者很有可能在声明周期函数中调用setState,因此需要通过开启batchedUpdates来确保所有的更新都能进入dirtyComponents,进而确保初始渲染流程中的所有setState生效

// ReactMount.js
_renderNewRootComponent: function( nextElement, container, shouldReuseMarkup, context ) {

  // 实例化组件
  var componentInstance = instantiateReactComponent(nextElement);

  // 初始渲染直接调用 batchedUpdates 进行同步渲染
  ReactUpdates.batchedUpdates(
    batchedMountComponentIntoNode,
    componentInstance,
    container,
    shouldReuseMarkup,
    context
  );
  ...
}

2、React 事件系统的 dispatchEvent 函数

开发者在组件上绑定了事件之后,事件中很有可能会触发setState,为了确保每一次setState生效,同样在此处也手动开启batchedUpdates

// ReactEventListener.js
dispatchEvent: function (topLevelType, nativeEvent) {
  ...
  try {
    // 处理事件
    ReactUpdates.batchedUpdates(handleTopLevelImpl, bookKeeping);
  } finally {
    TopLevelCallbackBookKeeping.release(bookKeeping);
  }
}

3、setState 并不具备同步特性,只是在特定的情境下,它会从React的异步管控中“逃脱”掉

通过上面两处batchedUpdates的调用,可以证明:在 React 的生命周期函数 以及 合成事件 执行前,isBatchingUpdates属性已经被悄悄修改为true,此时我们所做的setState操作自然不会立即生效。当函数执行完成后,事务的close函数再将isBatchingUpdates置为 false

我们再次回到我们的面试题最后两个按钮的逻辑上:

  • handleReduce 函数在 isBatchingUpdates 的约束下,setState 只能是异步的
  • 而 isBatchingUpdates 对 setTimeout 里面的执行逻辑却完全没有约束能力
  • 原因是:isBatchingUpdates 是在同步代码执行,而 setTimeout 的逻辑是异步执行的
  • 也就是当 setState 调用真正执行的时候,isBatchingUpdates已经被置为false,使得当前场景下 setState 具备了立刻发起同步更新的能力

六、总结

setState并不是单纯 同步/异步,它的表现会因调用场景不同而不同:

  • 在 React 钩子函数及合成事件中,它表现为异步
  • 而在 setTimeoutsetInterval 等函数中,包括在 DOM 原生事件中,它都表现为同步

造成这种差异的本质是:React 事务机制 和 批量更新机制 的 工作方式