从一道Promise执行顺序的题目看Promise实现

以前在网上看到一道Promise执行顺序的题目——打印如下程序的输出:javascript

new Promise(resolve => {
    console.log(1);
    resolve(3);
}).then(num => {
    console.log(num)
});
console.log(2)复制代码

这道题的输出是123,为何不是132呢?由于我一直理解Promise是没有异步功能,它只是帮忙解决异步回调的问题,实质上是和回调是同样的,因此若是按照这个想法,resolve以后应该会马上then。但实际上并非。难道用了setTimeout?html

若是在promise里面再加一个promise:java

new Promise(resolve => {
    console.log(1);
    resolve(3);
    Promise.resolve().then(()=> console.log(4))
}).then(num => {
    console.log(num)
});
console.log(2)复制代码

执行顺序是1243,第二个Promise的顺序会比第一个的早,因此直观来看也是比较奇怪,这是为何呢?node

Promise的实现有不少库,有jQuery的deferred,还有不少提供polyfill的,如es6-promiselie等,它们的实现都基于Promise/A+标准,这也是ES6的Promise采用的。git

为了回答上面题目的执行顺序问题,必须得理解Promise是怎么实现的,因此得看那些库是怎么实现的,特别是我错误地认为不存在的Promise的异步是怎么实现的,由于最后一行的console.log(2)它并非最后执行的,那么一定有某些相似于setTimeout的异步机制让上面同步的代码在异步执行,因此它才能在代码执行完了以后才执行。es6

固然咱们不仅是为了解答一道题,主要仍是借此了解Promise的内部机制。读者若是有时间有兴趣能够自行分析,而后再回过头来比较一下本文的分析。或者你能够跟着下面的思路,操起鼠标和键盘和我一块儿干。github

这里使用lie的库,相对于es6-promise来讲代码更容易看懂,先npm install一下:npm

npm install lie复制代码

让代码在浏览器端运行,准备如下html:数组

<!DOCType html> <html> <head> <meta charset="utf-8"> </head> <body> <script src="node_modules/lie/dist/lie.js"></script> <script src="index.js"></script> </body> </html>复制代码

其中index.js的内容为:promise

console.log(Promise);
new Promise(resolve => {
    console.log(1);
    resolve(3);
    Promise.resolve().then(()=> console.log(4))
}).then(num => {
    console.log(num)
});
console.log(2);复制代码

把Promise打印一下,确认已经把原生的那个覆盖了,对好比下:

由于原生的Promise咱们是打不了断点的,因此才须要借助一个第三方的库。

咱们在第4行的resolve(3)那里打个断点进去看一下resolve是怎么执行的,层层进去,最后的函数是这个:

咱们发现,这个函数好像没干啥,它就是设置了下self的state状态为FULFILLED(完成),而且把结果outcome设置为调resolve传进来的值,这里是3,若是resolve传来是一个Promise的话就会进入到上图187行的Promise链处理,这里咱们不考虑这种状况。这里的self是指向一个Promise对象:

它主要有3个属性——outcome、queue、state,其中outcome是resolve传进来的结果,state是Promise的状态,在第83行的代码能够查到Promise的状态总共有3种:

var REJECTED = ['REJECTED'];
var FULFILLED = ['FULFILLED'];
var PENDING = ['PENDING'];复制代码

Rejected失败,fulfilled成功,pending还在处理中,在紧接着89行的Promise的构造函数能够看到,state初始化的状态为pending:

function Promise(resolver) {
  if (typeof resolver !== 'function') {
    throw new TypeError('resolver must be a function');
  }
  this.state = PENDING;
  this.queue = [];
  this.outcome = void 0;
  if (resolver !== INTERNAL) {
    safelyResolveThenable(this, resolver);
  }
}复制代码

而且在右边的调用栈能够看到,resolver是由Promise的构造函数触发执行的,即当你new Promise的时候就会执行传参的函数,以下图所示:

传进来的函数支持两个参数,分别是resolve和reject回调:

let resolver = function(resolve, reject) {
    if (success) resolve();
    else reject();
};

new Promise(resolver);复制代码

这两个函数是Promise内部定义,可是要在你的函数里调一下它的函数,告诉它何时成功了,何时失败了,这样它才能继续下一步的操做。因此这两个函数参数是传进来的,它们是Promise的回调函数。Promise是怎么定义和传递这两个函数的呢?仍是在刚刚那个断点的位置,可是咱们改变一下右边调用栈显示的位置:

上图执行的thenable函数就是咱们传给它的resolver,而后传递onSuccess和onError,分别是咱们在resolver里面写的resolve和reject这两个参数。若是咱们调了它的resolve即onSuccess函数,它就会调236行的handlers.resolve就到了咱们第一次打断点的那张图,这里再放一次:

而后去设置当前Promise对象的state,outcome等属性。这里没有进入到193行的while循环里,由于queue是空的。这个地方下文会继续提到。

接着,咱们在then那里打个断点进去看一下:

then又作了些什么工做呢?以下图所示:

then能够传两个参数,分别为成功回调和失败回调。咱们给它传了一个成功回调,即上图划线的地方。而且因为在resolver里面已经把state置成fulfilled完成态了,因此它会执行unwrap函数,并传递成功回调、以及resolve给的结果outcome(还有一个参数promise,主要是用于返回,造成then链)。

unwrap函数是这样实现的:

在167行执行then里传给Promise的成功回调,并传递结果outcome。

这段代码是包在一个immediate函数里的,这里就是解决Promise异步问题的关键了。而且咱们在node_modules目录里面,也发现了lie使用了immediate库,它能够实现一个nextTick的功能,即在当前代码逻辑单元同步执行完了以后马上执行,至关于setTimeout 0,可是它又不是直接用setTimeout 0实现的。

咱们重点来看一下它是怎么实现一个nextTick的功能的。immediate里面会调一个scheduleDrain(drain是排水的意思):

function immediate(task) {
  // 这个判断先忽略
  if (queue.push(task) === 1 && !draining) {
    scheduleDrain();
  }
}复制代码

实现逻辑在这个scheduleDrain,它是这么实现的:

var Mutation = global.MutationObserver || global.WebKitMutationObserver;
var scheduleDrain = null;
{
  // 浏览器环境,IE11以上支持
  if (Mutation) {
      // ...
  } 
  // Node.js环境
  else if (!global.setImmediate && typeof global.MessageChannel !== 'undefined')

  }
  // 低浏览器版本解决方案
  else if ('document' in global && 'onreadystatechange' in global.document.createElement('script')) {

  }
  // 最后实在没办法了,用最次的setTimeout
  else {
    scheduleDrain = function () {
      setTimeout(nextTick, 0);
    };
  }
}复制代码

它会有一个兼容性判断,优先使用MutationObserver,而后是使用script标签的方式,这种到IE6都支持,最后啥都不行就用setTimeout 0.

咱们主要看一下Mutation的方式是怎么实现的,MDN上有介绍这个MutationObserver的用法,能够用它来监听DOM结点的变化,如增删、属性变化等。Immediate是这么实现的:

if (Mutation) {
    var called = 0;
    var observer = new Mutation(nextTick);
    var element = global.document.createTextNode('');
    // 监听节点的data属性的变化
    observer.observe(element, {
      characterData: true
    });
    scheduleDrain = function () {
      // 让data属性发生变化,在0/1之间不断切换,
      // 进而触发observer执行nextTick函数
      element.data = (called = ++called % 2);
    };
  }复制代码

使用nextTick回调注册一个observer观察者,而后建立一个DOM节点element,成为observer的观察对象,观察它的data属性。当须要执行nextTick函数的时候,就调一下scheduleDrain改变data属性,就会触发观察者的回调nextTick。它是异步执行的,在当前代码单元执行完以后马上之行,但又是在setTimeout 0以前执行的,也就是说,如下代码,第一行的5是最后输出的:

setTimeout(()=> console.log(5), 0);
new Promise(resolve => {
    console.log(1);
    resolve(3);
    // Promise.resolve().then(()=> console.log(4))
}).then(num => {
    console.log(num)
});
console.log(2);复制代码

这个时候,咱们就能够回答为何上面代码的输出顺序是123,而不是132了。第一点能够确定的是1是最早输出的,由于new一个Promise以后,传给它的resolver同步执行,因此1最早打印。执行了resolve(3)以后,就会把当前Promiser对象的state改为完成态,并记录结果outcome。而后跳出来执行then,把传给then的成功回调给immediate在nextTick执行,而nextTick是使用Mutation异步执行的,因此3会在2以后输出。

若是在promise里面再写一个promsie的话,因为里面的promise的then要比外面的promise的then先执行,也就是说它的nextTick更先注册,因此4是在3以前输出。

这样基本上就解释了Promise的执行顺序的问题。可是咱们还没说它的nextTick是怎么实现的,上面代码在执行immediate的时候把成功回调push到一个全局的数组queue里面,而nextTick是把这些回调按顺序执行,以下代码所示:

function nextTick() {
  draining = true;
  var i, oldQueue;
  var len = queue.length;
  while (len) {
    oldQueue = queue;
    // 把queue清空
    queue = [];
    i = -1;
    // 执行当前全部回调
    while (++i < len) {
      oldQueue[i]();
    }
    len = queue.length;
  }
  draining = false;
}复制代码

它会先把排水的变量draining设置成true,而后处理完成以后再设置成false,咱们再回顾一下刚刚执行immediate的判断:

function immediate(task) {
  if (queue.push(task) === 1 && !draining) {
    scheduleDrain();
  }
}复制代码

因为JS是单线程的,因此我以为这个draining的变量判断好像没有太大的必要。另一个判断,当queue为空时,push一个变量进来,这个时候queue只有1个元素,返回值就为1。因此若是以前已经push过了,那么这里就不用再触发nextTick,由于第一次的push会把全部queue回调元素都执行的,只要保证后面的操做有被push到这个queue里面就行了。因此这个判断是一个优化。

另外,es6-promise的核心代码是同样的,只是它把immediate函数改为asap(as soon as possible),它也是优先使用Mutation.


还有一个问题,上面说的resolver的代码是同步,可是咱们常常用Promise是用在异步的状况,resolve是异步调的,不是像上面同步调的,如:

let resolver = function(resolve) {
    setTimeout(() => {
        // 异步调用resolve
        resolve();
    }, 2000);
    // resolver执行完了还没执行resolve
};
new Promise(resolver).then(num => console.log(num));复制代码

这个时候,同步执行完resolver,但还没执行resolve,因此在执行then的时候这个Promise的state仍是pending的,就会走到134的代码(刚刚执行的是132行的unwrap):

它会建立一个QueueItem而后放到当前Promise对象的queue属性里面(注意这里的queue和上面说的immediate里全局的queue是两个不一样的变量)。而后异步执行结束调用resolve,这个时候queue不为空了:

就会执行queue队列里面的成功回调。由于then是能够then屡次的,因此成功回调可能会有多个。它也是调用immediate,在nextTick的时候执行的。


也就是说若是是同步resolve的,是经过MutationObserver/Setimeout 0之类的方式在当前的代码单元执行完以后马上执行成功回调;而若是是异步resolve的,是先把成功回调放到当前Promise对象的一个队列里面,等到异步结束了执行resolve的时候再用一样的方式在nextTick调用成功回调。


咱们还没说失败的回调,但大致是类似的。

相关文章
相关标签/搜索