前言
早前有針對 Promise
的語法寫過博文,不過僅限入門級別,淺嘗輒止食而無味。後面一直想寫 Promise
實現,礙於理解程度有限,多次下筆未能滿意。一拖再拖,時至今日。
隨著 Promise/A+規範、ECMAscript規範 對 Promise
API 制定執行落地,Javascript 非同步操作的基本單位也逐漸從 callback
轉換到 promise
。絕大多數JavaScript/DOM
平臺新增的非同步API(Fetch
、Service worker
)也都是基於Promise
構建的。這其中對 Promise
理解不是僅看過 API,讀過幾篇實踐就能完全掌握的。筆者以此行文,剖析細節,伴隨讀者一起成長,砥礪前行。
本文為前端非同步程式設計解決方案實踐系列第二篇,主要分析 Promise
內部機制及實現原理。後續非同步系列還會包括Generator
、Async/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+
簡譯為onFulfilled
或 onRejected
只在執行環境堆疊僅包含平臺程式碼時才可被呼叫。稍有疑惑,Promise/A+ 規範又對此句加以解釋:“實踐中要確保 onFulfilled
和 onRejected
方法非同步執行,且應該在 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
)。一旦Promise
被resolve
或reject
,不能再遷移至其他任何狀態(即狀態 immutable
)。
為保持程式碼清晰,暫無異常處理。同時為表述方便,約定如下:
- fulfilled 使用 resolved 代替
- onFulfilled 使用 onResolved 代替
Promise 建構函式
從建構函式開始,我們一步步實現符合 Promsie A+
規範的 Promise
。大概描述下,Promise
建構函式需要做什麼事情。
- 初始化
Promise
狀態(pending
) - 初始化
then(..)
註冊回撥處理陣列(then
方法可被同一個promise
呼叫多次) - 立即執行傳入的
fn
函式,傳入Promise
內部resolve
、reject
函式 - ...
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
內部的私有方法 resolve
和 reject
。resolve
和 reject
內部細節隨後介紹。
then 函式
Promise A+
提到規範專注於提供通用的 then
方法。then
方法可以被同一個 promise
呼叫多次,每次返回新 promise
物件 。then
方法接受兩個引數onResolved
、onRejected
(可選)。在 promise
被 resolve
或 reject
後,所有 onResolved
或 onRejected
函式須按照其註冊順序依次回撥,且呼叫次數不超過一次。
根據上述,then
函式執行流程大致為:
- 例項化空
promise
物件用來返回(保持then
鏈式呼叫) - 構造
then(..)
註冊回撥處理函式結構體 - 判斷當前
promise
狀態,pending
狀態儲存延遲處理物件deferred
,非pending
狀態執行onResolved
或onRejected
回撥 - ...
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
函式封裝儲存 onResolved
、onRejected
函式和新生成 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 === promise1
,promise2
狀態也應該等於 promise1
同為 resolved
。而 onResolved
回撥中返回狀態為 rejected
物件。考慮到 Promise
狀態一旦 resolved
或 rejected
就不能再遷移,所以這裡 promise2
也沒辦法轉為回撥函式返回的 rejected
狀態,產生矛盾。
handleResolved
函式功能為根據當前 promise
狀態,非同步執行 onResolved
或 onRejected
回撥函式。因在 resolve
或 reject
函式內部同樣需要相關功能,提取為單獨模組。往下翻閱檢視。
resolve 函式
Promise
例項化時立即執行傳入的 fn
函式,同時傳遞內部 resolve
函式作為引數用來改變 promise
狀態。resolve
函式簡易版邏輯大概為:判斷並改變當前 promise
狀態,儲存 resolve(..)
的 value
值。判斷當前是否存在 then(..)
註冊回撥執行函式,若存在則依次非同步執行 onResolved
回撥。
但如文初所 thenable
章節描述,為使 Promise
的實現更具有通用性,當 value
為存在 then(..)
方法的 thenable
物件,需要做 Promise Resolution Procedure
處理,規範描述為 [[Resolve]](promise, x)
。(x
即 為後面 value
引數)。
具體處理邏輯流程如下:
-
如果
promise
和x
指向同一物件,以TypeError
為據因拒絕執行promise
-
如果
x
為Promise
,則使promise
接受x
的狀態 -
如果
x
為物件或函式- 把
x.then
賦值給then
- 如果取
x.then
的值時丟擲錯誤 e ,則以 e 為據因拒絕promise
- 如果
then
是函式,將x
作為函式的作用域 this 呼叫之。 - 如果
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
函式邏輯較為複雜,主要集中在處理 value
(x
)值多種可能性。如果 value
為 Promise
且狀態為pending
時,須使 promise
接受 value
的狀態。在 value
狀態為 pending
時,簡單將 promise
的 deferreds
回撥處理陣列賦予 value
deferreds
變數。非 pending
狀態,使用 value
內部值回撥 promise
註冊的 deferreds
。
如果 value
為 thenable
物件,以 value
作為函式的作用域 this
呼叫之,同時回撥呼叫內部 resolve(..)
、reject(..)
函式。
其他情形則以 value
為引數執行 promise
,呼叫 onResolved
或 onRejected
處理函式。
事實上,Promise A+規範
定義的 Promise Resolution Procedure
處理流程是用來處理 then(..)
註冊的 onResolved
或 onRejected
呼叫返回值 與 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
函式、以及內部 resolve
和 reject
函式實現,你會發現其中所有的回撥執行我們都統一呼叫 handleResolved
函式,那 handleResolved
到底做了哪些事情,實現又有什麼注意點?
handleResolved
函式具體會根據 promise
當前狀態判斷呼叫 onResolved
、onRejected
,處理 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
註冊的 onResolved
、onRejected
函式為什麼要採用非同步執行?再來看一段例項程式碼。
var a = 1;
promise1.then(function (value) {
a = 2;
})
console.log(a)
複製程式碼
promise1 內部執行同步或非同步操作未知。假如未規定 then
註冊回撥為非同步執行,則這裡列印 a 可能存在兩種值。promise1 內部同步操時 a === 2,相反執行非同步操作時 a === 1。為遮蔽依賴外部的不確定性,規範指定 onFulfilled
和 onRejected
方法非同步執行。
promise 內部錯誤或異常
如果 promise
被 rejected
,則會呼叫拒絕回撥並傳入拒由。比如在 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.race
、Promise.all
、 Promise.resolve
、Promise.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
內部實現暫未達到庖丁解牛程度,有些地方一筆帶過,可能讀者心生疑惑。針對不解的地方,建議多讀兩遍或參考書籍理解。
如果讀完拙文能多少有點收穫,也算達到筆者初衷,大家一起成長。最後筆者也非完人,文中不免語句不順或詞不達意,望理解。如果對於本文有任何疑問或錯誤,歡迎斧正,在此先行謝過。
附錄
參考文件
參考書籍
event loop
打個廣告,歡迎關注筆者公眾號