從一道Promise執行順序的題目看Promise實現

人人網FED發表於2018-03-10

之前在網上看到一道Promise執行順序的題目——列印以下程式的輸出:

new Promise(resolve => {
    console.log(1);
    resolve(3);
}).then(num => {
    console.log(num)
});
console.log(2)複製程式碼

這道題的輸出是123,為什麼不是132呢?因為我一直理解Promise是沒有非同步功能,它只是幫忙解決非同步回撥的問題,實質上是和回撥是一樣的,所以如果按照這個想法,resolve之後應該會立刻then。但實際上並不是。難道用了setTimeout?

如果在promise裡面再加一個promise:

new Promise(resolve => {
    console.log(1);
    resolve(3);
    Promise.resolve().then(()=> console.log(4))
}).then(num => {
    console.log(num)
});
console.log(2)複製程式碼

執行順序是1243,第二個Promise的順序會比第一個的早,所以直觀來看也是比較奇怪,這是為什麼呢?

Promise的實現有很多庫,有jQuery的deferred,還有很多提供polyfill的,如es6-promiselie等,它們的實現都基於Promise/A+標準,這也是ES6的Promise採用的。

為了回答上面題目的執行順序問題,必須得理解Promise是怎麼實現的,所以得看那些庫是怎麼實現的,特別是我錯誤地認為不存在的Promise的非同步是怎麼實現的,因為最後一行的console.log(2)它並不是最後執行的,那麼必定有某些類似於setTimeout的非同步機制讓上面同步的程式碼在非同步執行,所以它才能在程式碼執行完了之後才執行。

當然我們不只是為了解答一道題,主要還是藉此瞭解Promise的內部機制。讀者如果有時間有興趣可以自行分析,然後再回過頭來比較一下本文的分析。或者你可以跟著下面的思路,操起滑鼠和鍵盤和我一起幹。

這裡使用lie的庫,相對於es6-promise來說程式碼更容易看懂,先npm install一下:

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的內容為:

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呼叫成功回撥。


我們還沒說失敗的回撥,但大體是相似的。



相關文章