86
上篇:https://segmentfault.com/a/11...

Part one - 同步 or 异步

1. 先说现象吧:

在React中,如果是由React引发的事件处理(比如通过onClick引发的合成事件处理)和组件生命周期函数内(比如componentDidMount),调用this.setState不会同步更新this.state,除此之外的setState调用会同步执行this.state。
所谓“除此之外”,指的是绕过React通过addEventListener直接添加的事件处理函数,还有通过setTimeout/setInterval产生的异步调用。

如果我们按照教科书般的方式来使用React,基本上不会触及所谓的“除此之外”情况。

2. 再说为什么会这样:(其实只要解开了这个疑问,才能明白setState的异步队列是如何实现的)

在React的setState函数实现中,会根据一个变量isBatchingUpdates判断是直接更新this.state还是放到队列中回头再说,而isBatchingUpdates默认是false,也就表示setState会同步更新this.state,但是,有一个函数batchedUpdates,这个函数会把isBatchingUpdates修改为true,而当React在调用事件处理函数和自身生命周期之前就会调用这个batchedUpdates,造成的后果,就是由React控制的事件处理过程和生命周期中的同步代码调用setState不会同步更新this.state

注意:同步代码调用,在合成事件和生命周期内的异步调用setState(比如ajax和setTimeout内),也是会同步更新this.setState。

Demo请看上篇的Part Four

所以按照正常React用法都是会经过batchingUpdate方法的。这是由于React有一套自定义的事件系统和生命周期流程控制,使用原生事件监听和settimeout这种方式会跳出React这个体系,所以会直接更新this.state。

我们在看代码是如何实现的,需要了解这样一个东西“事务”,React内部的工具方法实现了一个可供使用的事务。

Part two - React中的事务

React中的事务借用了计算机专业术语的单词Transaction 。对比数据库的事务性质,两者之间有共同点却又不是一回事,简答来说 把需要执行的方法用一个容器封装起来,在容器内执行方法的前后,分别执行init方法和close方,其次来说,一个容器可以包裹另一个容器,这点又类似于洋葱模型。

React的合成事件系统和生命周期就使用了React内部实现的事务,为其函数附加了前后两个类似npm脚本pre和post两个钩子的事件。

这是一个npm srcipt的例子:

"prebuild": "echo I run before the build script",
"build": "cross-env NODE_ENV=production webpack",
"postbuild": "echo I run after the build script"

//用户执行npm run build就会实际执行
npm run prebuild && npm run build && npm run postbuild

//因此可以在两个钩子里做一些准备工作和清理工作。

有过有兴趣,我们来看一下如何简单使用事务:https://codesandbox.io/s/6xl5yrjvzz

var MyTransaction = function () {
  //...
};

Object.assign(MyTransaction.prototype, Transaction.Mixin, {
  getTransactionWrappers: function () {
    return [
      {
        initialize: function () {
          console.log("before method perform");
        },
        close: function () {
          console.log("after method perform");
        }
      }
    ];
  }
});

var transaction = new MyTransaction();

transaction.reinitializeTransaction()

var testMethod = function () {
  console.log("test");
};

transaction.perform(testMethod);

Part three - 哪有什么岁月静好,不过是有人为你负重前行

所以,我们可以得到启发,React的事件系统和生命周期事务前后的钩子对isBatchingUpdates做了修改,其实就是在事务的前置pre内调用了batchedUpdates方法修改了变量为true,然后在后置钩子又置为false,然后发起真正的更新检测,而事务中异步方法运行时候,由于JavaScript的异步机制,异步方法(setTimeout等)其中的setState运行时候,同步的代码已经走完,后置钩子已经把isBatchingUpdates设为false,所以此时的setState会直接进入非批量更新模式,表现在我们看来成为了同步SetState。

尝试在描述一下:整个React的每个生命周期和合成事件都处在一个大的事务当中。原生绑定事件和setTimeout异步的函数没有进入React的事务当中,或者是当他们执行时,刚刚的事务已经结束了,后置钩子触发了,close了。(大家可以想一想分别是哪一种情况)。

React“坐”在顶部调用堆栈框架并知道所有React事件处理程序何时运行,setState在React管理的合成事件或者生命周期中调用,它会启用批量更新事务,进入了批量更新模式,所有的setState的改变都会暂存到一个队列,延迟到事务结束再合并更新。如果setState在React的批量更新事务外部或者之后调用,则会立即刷新。

懂得了事务,再回看,就明白,其实setState从来都是同步运行,不过是React利用事务工具方法模拟了setState异步的假象。

延迟队列如何实现,其实有悟性的同学已经可以大概猜到。我们再来捋一捋,看源码是否能验证我们的结论描述和现象。

Part four - 源码验证

可以对照这张图先来看下setState的流程代码,代码仓库在我的github的react-source仓库,目录已被我精简,剩下关键的源代码文件夹。

图片描述

首先,我们搜索setState =看下setState何处被赋值,找到了这里

// src/isomorphic/modern/class/ReactComponent.js
/*
 * React组件继承自React.Component,而setState是React.Component的方法,
 * 因此对于组件来讲setState属于其原型方法,首先看setState的定义:   
 */
ReactComponent.prototype.setState = function(partialState, callback) {
    // 忽略掉入参验证和开发抛错
   //调用setState实际是调用了enqueueSetState
   // 调用队列的入队方法,把当前组件的示例和state存进入
  this.updater.enqueueSetState(this, partialState);
  if (callback) {
    // 如果有回调,把回调存进setState队列的后置钩子
    this.updater.enqueueCallback(this, callback, 'setState');
  }
};

会发现调用setState实际是调用this.updater.enqueueSetState,此时我们不得不看一看updater及其enqueueSetState方法是什么东西,我们在当前文件搜索:

function ReactComponent(props, context, updater) {
  this.props = props;
  this.context = context;
  this.refs = emptyObject;
  // updater有默认值,真实运行时会注入,其实也算依赖注入
  this.updater = updater || ReactNoopUpdateQueue;
}

ReactNoopUpdateQueue是一个这样的对象,提供了基本的无效方法,真正的updater只有在React被真正加载前才会被注入进来,运行时注入,严格来说是依赖注入,是React源码的风格之一。

// src/isomorphic/modern/class/ReactNoopUpdateQueue.js
var ReactNoopUpdateQueue={
    isMounted: function(publicInstance) {
    return false;
  },
  enqueueCallback: function(publicInstance, callback) { },
  enqueueForceUpdate: function(publicInstance) { },
  enqueueReplaceState: function(publicInstance, completeState) { },
  enqueueSetState: function(publicInstance, partialState) { },
}

真实的enqueueSetState在这个文件内,方法把将要修改的state存入组件实例的internalInstance数组中,这里就是state的延迟更新队列了。然后立马调用了一个全局的ReactUpdates.enqueueUpdate(internalInstance)方法。

// src/renderers/shared/reconciler/ReactUpdateQueue.js

  // 这个是setState真正调用的函数
  enqueueSetState: function(publicInstance, partialState) {
       // 忽略基本的容错和抛错
    // 存入组件实例,准备更新
    var internalInstance = publicInstance;
    // 更新队列合并操作 更新 internalInstance._pendingStateQueue
    var queue = internalInstance._pendingStateQueue ||(internalInstance._pendingStateQueue = []);
    queue.push(partialState);

    enqueueUpdate(internalInstance);
    // 发生了什么?猜一下?   ReactUpdates.js
  },
题外话:每个函数的健壮入参判断和运行环境判断和完善的抛错机制,对来源不信任甚至是内部互调,是React对开发者友好的一个重要原因。

我们来猜下ReactUpdates.enqueueUpdate干了什么?根据上面的流程图我猜想应当是判断流程。

function enqueueUpdate(component) {
  ensureInjected(); //环境判断:是否有调度事务方法同时有批量更新策略方法
  //关键的判断条件,是否是批量更新
  //可是isBatchingUpdates这个值谁来维护呢?
  if (!batchingStrategy.isBatchingUpdates) {        // 见词知意 如果不在批量更新策略中
    // 如果不是批量更新,猜想一下,应该会立即更新吧?
    // 唉?batchingStrategy到底在做什么呢
    batchingStrategy.batchedUpdates(enqueueUpdate, component);  // 调用事务
    // 对队列中的更新执行 batchedUpdates 方法
    return;
  }
  // 如果是批量更新,那就把组件放入脏组件队列,也就是待更新组件队列
  dirtyComponents.push(component);
}

需要看ReactDefaultBatchingStrategy.js 看 batchedUpdates 方法,这个js文件就有意思了,一上来就是我们之前提到的事务。

避免枯燥,我用人话阐述一下这个js的内容,也可以直接看ReactDefaultBatchingStrategy.js

var ReactDefaultBatchingStrategy={
    isBatchingUpdates:false,
    batchedUpdates:function(){},
}

文件底部声明了 ReactDefaultBatchingStrategy对象,内部isBatchingUpdates初始值为false,这个就是我们心心念念判断是否在批量更新策略的重要变量。
这个isBatchingUpdates变量搜索整个项目,发现它只被两处改变:

  1. 对象自身的另一个batchedUpdates方法固定赋值为true,标识着开启批量更新策略。
  2. 一个事务的close钩子,设为false,标识着结束批量更新策略。恰好,这个事务被batchedUpdates调用。

实质上,isBatchingUpdates仅仅也就是被batchedUpdates方法维护着,batchedUpdates调用时开启批量更新,同时入参callback被事务包裹调用,callback调用完成时候事务close钩子触发,关闭批量更新模式。事务的close钩子函数有两个,另一个之前会调用ReactUpdates.flushBatchedUpdates方法,也就是真正的把积攒的setState队列进行更新计算。

问题来了,callback是啥,batchedUpdates方法在setState之前,或者说除了setState还会被谁调用,导致isBatchingUpdates变为true,我猜想是生命周期函数和合成事件,只有这样,整个维护批量更新策略的机制就形成了闭环,验证了我们之前的结论。

我们搜索batchedUpdates(,果不其然,在src/renderers/dom/client/ReactEventListener.jssrc/renderers/dom/client/ReactMount.js中找到了ReactUpdates.batchedUpdates的调用。

合成事件和生命周期的装载发生时,调用了batchedUpdates方法,使得内部的同步代码都可以运行在批量更新策略的事务环境中,结束后,便使用事务的后置钩子启动merge更新,重置常量。

另外我在ReactDOM.js发现了对React顶层API对batchedUpdates方法的引用,可以让 Promise 这些异步也能进入 batch update:

unstable_batchedUpdates: ReactUpdates.batchedUpdates,

另一个彩蛋,虽然React不提倡使用这个API,以后版本也可能移除,但是现在我们可以这样在React中这样使用:

React.unstable_batchedUpdates(function(){
    this.setState({...})
    this.setState({...})
    //...在此函数内也可以使用批量更新策略
})

解决了setTimeout和AJAX异步方法、原生事件内的setState批量更新策略失效的问题,让批量更新在任何场景都会发生。

Part five - 总结

图片描述

  1. this.setState首先会把state推入pendingState队列中。
  2. 然后将组建标记为dirtyComponent。
  3. React中有事务的概念,最常见的就是更新事务,如果不在事务中,则会开启一次新的更新事务,更新事务执行的操作就是把组件标记为dirty。
  4. 判断是否处于batch update。
  5. 是的话,保存组建于dirtyComponent中,在事务的时候才会通过 ReactUpdates.flushBatchedUpdates 方法将所有的临时 state merge 并计算出最新的 props 及 state,然后将其批量执行,最后再关闭结束事务。
  6. 不是的话,直接开启一次新的更新事务,在标记为dirty之后,直接开始更新组件。因此当setState执行完毕后,组件就更新完毕了,所以会造成定时器同步更新的情况。

边城到此莫若
1.3k 声望42 粉丝