解讀Promise內部實現原理

程式碼君的自白發表於2017-12-13

前言

早前有針對 Promise 的語法寫過博文,不過僅限入門級別,淺嘗輒止食而無味。後面一直想寫 Promise 實現,礙於理解程度有限,多次下筆未能滿意。一拖再拖,時至今日。

隨著 Promise/A+規範ECMAscript規範Promise API 制定執行落地,Javascript 非同步操作的基本單位也逐漸從 callback 轉換到 promise。絕大多數JavaScript/DOM平臺新增的非同步API(FetchService worker)也都是基於Promise構建的。這其中對 Promise 理解不是僅看過 API,讀過幾篇實踐就能完全掌握的。筆者以此行文,剖析細節,伴隨讀者一起成長,砥礪前行。

本文為前端非同步程式設計解決方案實踐系列第二篇,主要分析 Promise 內部機制及實現原理。後續非同步系列還會包括GeneratorAsync/Await相關,挖坑佔位。

注:本文 Promise 遵守 Promises/A+ 規範,實現參照 then/promise

Promise 是什麼

既然要講實現原理,不免要承前啟後交代清楚 Promise 是什麼。查閱文件,如下:

A promise represents the eventual result of an asynchronous operation. --Promises/A+

A Promise is an object that is used as a placeholder for the eventual results of a deferred (and possibly asynchronous) computation. --ECMAscript

Promises/A+ 規範中表示為一個非同步操作的最終結果,ECMAscript 規範定義為延時或非同步計算最終結果的佔位符。言簡意賅,但稍微聱牙詰屈,如何表述更淺顯易懂呢?

說個故事,Promise 是一個美好的承諾,承諾本身會做出正確延時或非同步操作。承諾會解決callback處理非同步回撥可能產生的呼叫過早,呼叫過晚、呼叫次數過多過少、吞掉可能出現的錯誤或異常問題等。另外承諾只接受首次 resolve(..)reject(..) 決議,承諾本身狀態轉變後不會再變,承諾所有通過 then(..) 註冊的回撥總是依次非同步呼叫,承諾所有異常總會被捕獲丟擲。她,是一個可信任的承諾。

嚴謹來講,Promise 是一種封裝和組合未來值得易於複用機制,實現關注點分離、非同步流程控制、異常冒泡、序列/並行控制等。

注:文中提及 callback 問題詳情見<<你不知道的JavaScript(中卷)>> 2.3 、3.3章節

標準解讀

Promise A+ 規範字數不多簡明扼要,但仔細翻讀,其中仍有有幾點需要引人注意。

thenable 物件

thenable 是一個定義 then(..) 方法的物件或函式。thenable 物件的存在目的是使 Promise 的實現更具有通用性,只要其暴露出一個遵循 Promise/A+ 規範的 then(..) 方法。同時也會使遵循 Promise/A+ 規範的實現可以與那些不太規範但可用的實現能良好共存。

識別 thenable 或行為類似 Promise 物件可以根據其是否具有 then(..) 方法來判斷,這其實叫型別檢查也可叫鴨式辯型(duck typing)。對於 thenable 值鴨式型別檢測大致類似於:

if ( p !== null && 
     (
       typeof p === 'object' || 
       typeof p === 'function'
     ) &&
     typeof p.then === 'function'
) {
    // thenable
} else {
    // 非 thenable 
}
複製程式碼
then 回撥非同步執行

眾所周知,Promise 例項化時傳入的函式會立即執行,then(...) 中的回撥需要非同步延遲呼叫。至於為什麼要延遲呼叫,後文會慢慢解讀。這裡有個重要知識點,回撥函式非同步呼叫時機。

onFulfilled or onRejected must not be called until the execution context stack contains only platform code --Promise/A+

簡譯為onFulfilledonRejected 只在執行環境堆疊僅包含平臺程式碼時才可被呼叫。稍有疑惑,Promise/A+ 規範又對此句加以解釋:“實踐中要確保 onFulfilledonRejected 方法非同步執行,且應該在 then 方法被呼叫的那一輪事件迴圈之後的新執行棧中執行。這個事件佇列可以採用巨集任務 macro-task機制或微任務 micro-task機制來實現。”

雖然Promise A+未明確指出是以 microtask 還是 macrotask 形式放入佇列,但 ECMAScript 規範明確指出 Promise 必須以 Promise Job 形式加入 job queues(也就是 microtask)。Job Queue 是 ES6 中新提出的概念,建立在事件迴圈佇列之上。job queue存在也是為了滿足一些低延遲的非同步操作。

敲黑板劃重點,注意這裡 macrotask microtask 分別表示非同步任務的兩種分類。在掛起任務時,JS 引擎會將所有任務按照類別分到兩個佇列中,首先在 macrotask 的佇列(也叫 task queue)中取出第一個任務,執行完畢後取出 microtask 佇列中的所有任務順序執行;之後再取 macrotask 任務,周而復始,直至兩個佇列的任務都取完。

對於microtask執行時機,whatwg HTML規範中也有闡述,詳情可點選查閱。更多相關文章可參考附錄 event loop

再看一個示例,加深理解:

setTimeout(function () {
  console.log('setTimeout');
}, 0);

Promise.resolve().then(function () {
  console.log('promise1');
}).then(function () {
  console.log('promise2');
});
複製程式碼

列印的順序?正確答案是:promise1, promise2, setTimeout

在進一步實現 Promise 物件之前,簡單模擬非同步執行函式供後文Promise回撥使用(也可採用 asap庫等)。

var asyncFn = function () {
  if (typeof process === 'object' && process !== null && 
      typeof(process.nextTick) === 'function'
  ) {
    return process.nextTick;
  } else if (typeof(setImmediate) === 'function') {
    return setImmediate;
  }
  return setTimeout;
}();
複製程式碼
Promise 狀態

Promise 必須為以下三種狀態之一:等待態(Pending)、執行態(Fulfilled)和拒絕態(Rejected)。一旦Promiseresolvereject,不能再遷移至其他任何狀態(即狀態 immutable)。

為保持程式碼清晰,暫無異常處理。同時為表述方便,約定如下:

  • fulfilled 使用 resolved 代替
  • onFulfilled 使用 onResolved 代替

Promise 建構函式

從建構函式開始,我們一步步實現符合 Promsie A+ 規範的 Promise。大概描述下,Promise建構函式需要做什麼事情。

  1. 初始化 Promise 狀態(pending
  2. 初始化 then(..) 註冊回撥處理陣列(then 方法可被同一個 promise 呼叫多次)
  3. 立即執行傳入的 fn 函式,傳入Promise 內部 resolvereject 函式
  4. ...
function Promise (fn) {
  // 省略非 new 例項化方式處理
  // 省略 fn 非函式異常處理

  // promise 狀態變數
  // 0 - pending
  // 1 - resolved
  // 2 - rejected
  this._state = 0;
  // promise 執行結果
  this._value = null;
 
  // then(..) 註冊回撥處理陣列
  this._deferreds = [];

  // 立即執行 fn 函式
  try {
    fn(value => {
      resolve(this, value);
    },reason => {
      reject(this, reason);
    })
  } catch (err) {
    // 處理執行 fn 異常
    reject(this, err);
  }
}
複製程式碼

_state_value 變數很容易理解,_deferreds變數做什麼?規範描述:then 方法可以被同一個 promise 呼叫多次。為滿足多次呼叫 then 註冊回撥處理,內部選擇使用 _deferreds 陣列儲存處理物件。具體處理物件結構,見 then 函式章節。

最後執行 fn 函式,並呼叫 promise 內部的私有方法 resolverejectresolvereject 內部細節隨後介紹。

then 函式

Promise A+提到規範專注於提供通用的 then 方法。then 方法可以被同一個 promise 呼叫多次,每次返回新 promise 物件 。then 方法接受兩個引數onResolvedonRejected(可選)。在 promiseresolvereject 後,所有 onResolvedonRejected 函式須按照其註冊順序依次回撥,且呼叫次數不超過一次。

根據上述,then 函式執行流程大致為:

  1. 例項化空 promise 物件用來返回(保持then鏈式呼叫)
  2. 構造 then(..) 註冊回撥處理函式結構體
  3. 判斷當前 promise 狀態,pending 狀態儲存延遲處理物件 deferred ,非pending狀態執行 onResolvedonRejected 回撥
  4. ...
Promise.prototype.then = function (onResolved, onRejected) {

  var res = new Promise(function () {});
  // 使用 onResolved,onRejected 例項化處理物件 Handler
  var deferred = new Handler(onResolved, onRejected, res);

  // 當前狀態為 pendding,儲存延遲處理物件
  if (this._state === 0) {
    this._deferreds.push(deferred);
    return res;
  }

  // 當前 promise 狀態不為 pending
  // 呼叫 handleResolved 執行onResolved或onRejected回撥
  handleResolved(this, deferred);
  
  // 返回新 promise 物件,維持鏈式呼叫
  return res;
};
複製程式碼

Handler 函式封裝儲存 onResolvedonRejected 函式和新生成 promise 物件。

function Handler (onResolved, onRejected, promise) {
  this.onResolved = typeof onResolved === 'function' ? onResolved : null;
  this.onRejected = typeof onRejected === 'function' ? onRejected : null;
  this.promise = promise;
}
複製程式碼

鏈式呼叫為什麼要返回新的 promise

如我們理解,為保證 then 函式鏈式呼叫,then 需要返回 promise 例項。但為什麼返回新的 promise,而不直接返回 this 當前物件呢?看下面示例程式碼:

var promise2 = promise1.then(function (value) {
  return Promise.reject(3)
})
複製程式碼

假如 then 函式執行返回 this 呼叫物件本身,那麼 promise2 === promise1promise2 狀態也應該等於 promise1 同為 resolved。而 onResolved 回撥中返回狀態為 rejected 物件。考慮到 Promise 狀態一旦 resolvedrejected就不能再遷移,所以這裡 promise2 也沒辦法轉為回撥函式返回的 rejected 狀態,產生矛盾。

handleResolved 函式功能為根據當前 promise 狀態,非同步執行 onResolvedonRejected 回撥函式。因在 resolvereject 函式內部同樣需要相關功能,提取為單獨模組。往下翻閱檢視。

resolve 函式

Promise 例項化時立即執行傳入的 fn 函式,同時傳遞內部 resolve 函式作為引數用來改變 promise 狀態。resolve 函式簡易版邏輯大概為:判斷並改變當前 promise 狀態,儲存 resolve(..)value 值。判斷當前是否存在 then(..) 註冊回撥執行函式,若存在則依次非同步執行 onResolved 回撥。

但如文初所 thenable 章節描述,為使 Promise 的實現更具有通用性,當 value 為存在 then(..) 方法的 thenable 物件,需要做 Promise Resolution Procedure 處理,規範描述為 [[Resolve]](promise, x)。(x 即 為後面 value 引數)。

具體處理邏輯流程如下:

  • 如果 promisex 指向同一物件,以 TypeError 為據因拒絕執行 promise

  • 如果 xPromise ,則使 promise 接受 x 的狀態

  • 如果 x 為物件或函式

    1. x.then 賦值給 then
    2. 如果取 x.then 的值時丟擲錯誤 e ,則以 e 為據因拒絕 promise
    3. 如果 then 是函式,將 x 作為函式的作用域 this 呼叫之。
    4. 如果 x 不為物件或者函式,以 x 為引數執行 promise

原文參考Promise A+規範 Promise Resolution Procedure

function resolve (promise, value) {
  // 非 pending 狀態不可變
  if (promise._state !== 0) return;
  
  // promise 和 value 指向同一物件
  // 對應 Promise A+ 規範 2.3.1
  if (value === promise) {
    return reject( promise, new TypeError('A promise cannot be resolved with itself.') );
  }
  
  // 如果 value 為 Promise,則使 promise 接受 value 的狀態
  // 對應 Promise A+ 規範 2.3.2
  if (value && value instanceof Promise && value.then === promise.then) {
    var deferreds = promise._deferreds
    
    if (value._state === 0) {
      // value 為 pending 狀態
      // 將 promise._deferreds 傳遞 value._deferreds
      // 偷個懶,使用 ES6 展開運算子
      // 對應 Promise A+ 規範 2.3.2.1
      value._deferreds.push(...deferreds)
    } else if (deferreds.length !== 0) {
      // value 為 非pending 狀態
      // 使用 value 作為當前 promise,執行 then 註冊回撥處理
      // 對應 Promise A+ 規範 2.3.2.2、2.3.2.3
      for (var i = 0; i < deferreds.length; i++) {
        handleResolved(value, deferreds[i]);
      }
      // 清空 then 註冊回撥處理陣列
      value._deferreds = [];
    }
    return;
  }

  // value 是物件或函式
  // 對應 Promise A+ 規範 2.3.3
  if (value && (typeof value === 'object' || typeof value === 'function')) {
    try {
      // 對應 Promise A+ 規範 2.3.3.1
      var then = obj.then;
    } catch (err) {
      // 對應 Promise A+ 規範 2.3.3.2
      return reject(promise, err);
    }

    // 如果 then 是函式,將 value 作為函式的作用域 this 呼叫之
    // 對應 Promise A+ 規範 2.3.3.3
    if (typeof then === 'function') {
      try {
        // 執行 then 函式
        then.call(value, function (value) {
          resolve(promise, value);
        }, function (reason) {
          reject(promise, reason);
        })
      } catch (err) {
        reject(promise, err);
      }
      return;
    }
  }
  
  // 改變 promise 內部狀態為 `resolved`
  // 對應 Promise A+ 規範 2.3.3.4、2.3.4
  promise._state = 1;
  promise._value = value;

  // promise 存在 then 註冊回撥函式
  if (promise._deferreds.length !== 0) {
    for (var i = 0; i < promise._deferreds.length; i++) {
      handleResolved(promise, promise._deferreds[i]);
    }
    // 清空 then 註冊回撥處理陣列
    promise._deferreds = [];
  }
}
複製程式碼

resolve 函式邏輯較為複雜,主要集中在處理 valuex)值多種可能性。如果 valuePromise 且狀態為pending時,須使 promise 接受 value 的狀態。在 value 狀態為 pending 時,簡單將 promisedeferreds 回撥處理陣列賦予 value deferreds變數。非 pending 狀態,使用 value 內部值回撥 promise 註冊的 deferreds

如果 valuethenable 物件,以 value 作為函式的作用域 this 呼叫之,同時回撥呼叫內部 resolve(..)reject(..)函式。

其他情形則以 value 為引數執行 promise,呼叫 onResolvedonRejected 處理函式。

事實上,Promise A+規範 定義的 Promise Resolution Procedure 處理流程是用來處理 then(..) 註冊的 onResolvedonRejected 呼叫返回值 與 then 新生成 promise 之間關係。不過考慮到 fn 函式內部呼叫 resolve(..)產生值 與當前 promise 值仍然存在相同關係,邏輯一致,寫進相同模組。

reject 函式

Promise 內部私有方法 reject 相較於 resolve 邏輯簡單很多。如下所示:

function reject (promise, reason) {
  // 非 pending 狀態不可變
  if (promise._state !== 0) return;

  // 改變 promise 內部狀態為 `rejected`
  promise._state = 2;
  promise._value = reason;

  // 判斷是否存在 then(..) 註冊回撥處理
  if (promise._deferreds.length !== 0) {
    // 非同步執行回撥函式
    for (var i = 0; i < promise._deferreds.length; i++) {
      handleResolved(promise, promise._deferreds[i]);
    }
    promise._deferreds = [];
  }
}
複製程式碼

handleResolved 函式

瞭解完 Promise 建構函式、then 函式、以及內部 resolvereject 函式實現,你會發現其中所有的回撥執行我們都統一呼叫 handleResolved函式,那 handleResolved 到底做了哪些事情,實現又有什麼注意點?

handleResolved 函式具體會根據 promise 當前狀態判斷呼叫 onResolvedonRejected,處理 then(..) 註冊回撥為空情形,以及維護鏈式 then(..) 函式後續呼叫。具體實現如下:

function handleResolved (promise, deferred) {
  // 非同步執行註冊回撥
  asyncFn(function () {
    var cb = promise._state === 1 ? 
            deferred.onResolved : deferred.onRejected;

    // 傳遞註冊回撥函式為空情況
    if (cb === null) {
      if (promise._state === 1) {
        resolve(deferred.promise, promise._value);
      } else {
        reject(deferred.promise, promise._value);
      }
      return;
    }

    // 執行註冊回撥操作
    try {
      var res = cb(promise._value);
    } catch (err) {
      reject(deferred.promise, err);
    }

    // 處理鏈式 then(..) 註冊處理函式呼叫
    resolve(deferred.promise, res);
  });
}
複製程式碼

具體處理註冊回撥函式 cb 為空情形,如下面示例。判斷當前回撥 cb 為空時,使用 deferred.promise 作為當前 promise 結合 value 呼叫後續處理函式繼續往後執行,實現值穿透空處理函式往後傳遞。

Promise.resolve(233)
  .then()
  .then(function (value) {
    console.log(value)
  })
複製程式碼

關於 then 鏈式呼叫,簡單再說下。實現 then 函式的鏈式呼叫,只需要在 Promise.prototype.then(..) 處理函式中返回新的 promise 例項即可。但除此之外,還需要依次呼叫 then 註冊的回撥處理函式。如 handleResolved 函式最後一句 resolve(deferred.promise, res) 所示。

then 註冊回撥函式為什麼非同步執行

這裡回答開篇所提到的一個問題,then 註冊的 onResolvedonRejected 函式為什麼要採用非同步執行?再來看一段例項程式碼。

var a = 1;

promise1.then(function (value) {
  a = 2;
})

console.log(a)
複製程式碼

promise1 內部執行同步或非同步操作未知。假如未規定 then 註冊回撥為非同步執行,則這裡列印 a 可能存在兩種值。promise1 內部同步操時 a === 2,相反執行非同步操作時 a === 1。為遮蔽依賴外部的不確定性,規範指定 onFulfilledonRejected 方法非同步執行。

promise 內部錯誤或異常

如果 promiserejected,則會呼叫拒絕回撥並傳入拒由。比如在 Promise 的建立過程中(fn執行時)出現異常,那這個異常會被捕捉並呼叫 onRejected

但還存在一處細節,如果 Promise 完成後呼叫 onResolved 檢視結果時出現異常錯誤會怎麼樣呢?注意此時 onRejected 不會被觸發執行,因為 onResolved 內部異常並不會改變當前 promise 狀態(仍為resolved),而是改變 then 中返回新的 promise 狀態為 rejected。異常未丟失但也未呼叫錯誤處理函式。

如何處理?Ecmascript規範有定義Promise.prototype.catch方法,假如你對 onResolved 處理過程沒有信心或存在異常 case 情況,最好還是在 then 函式後呼叫 catch 方法做異常捕獲兜底處理。

Promise 相關方法實現

查閱 Promise 相關文件或書籍,你還會發現 Promise 相關有用的API:Promise.racePromise.allPromise.resolvePromise.reject。這裡對 Promise.race 方法實現做個展示,剩餘可自行參考實現。

Promise.race = function (values) {
  return new Promise(function (resolve, reject) {
    values.forEach(function(value) {
      Promise.resolve(value).then(resolve, reject);
    });
  });
};
複製程式碼

結語

寫到這裡,核心的 Promise 實現也逐漸完成,Promise 內部細節也在文中或程式碼中一一描述。限於筆者本身能力有限,對於 promise 內部實現暫未達到庖丁解牛程度,有些地方一筆帶過,可能讀者心生疑惑。針對不解的地方,建議多讀兩遍或參考書籍理解。

如果讀完拙文能多少有點收穫,也算達到筆者初衷,大家一起成長。最後筆者也非完人,文中不免語句不順或詞不達意,望理解。如果對於本文有任何疑問或錯誤,歡迎斧正,在此先行謝過。

附錄

參考文件

  1. ECMA262 Promise

  2. Promises/A+ Specification

  3. [譯] Promises/A+ 規範

  4. then/promise

  5. 寫一個符合 Promises/A+ 規範並可配合 ES7 async/await 使用的 Promise

  6. 剖析Promise內部結構,一步一步實現一個完整的、能通過所有Test case的Promise類

  7. 剖析 Promise之基礎篇

參考書籍

  1. 《你不知道的JavaScript(中卷)》

  2. 《深入理解ES6》

  3. 《JavaScript框架設計(第2版)》

  4. 《ES6標準入門(第3版)》

event loop

  1. Tasks, microtasks, queues and schedules

  2. [譯]Tasks, microtasks, queues and schedules

  3. Difference between microtask and macrotask within an event loop context

  4. 從event loop規範探究javaScript非同步及瀏覽器更新渲染時機

  5. 深入探究 eventloop 與瀏覽器渲染的時序問題

解讀Promise內部實現原理

打個廣告,歡迎關注筆者公眾號

相關文章