本篇以Promise為核心, 逐步展開, 最終分析process.nextTick , promise.then , setTimeout , setImmediate 它們的非同步機制.
導讀
Promise問世已久, 其科普類文章亦不計其數. 遂本篇初衷不為科普, 只為能夠溫故而知新.
比如說, catch能捕獲所有的錯誤嗎? 為什麼有些時候會丟擲"Uncaught (in promise) …"? Promise.resolve
和 Promise.reject
處理Promise物件時又有什麼不一樣的地方?
Promise
引子
閱讀此篇之前, 我們先體驗一下如下程式碼:
setTimeout(function() {
console.log(4)
}, 0);
new Promise(function(resolve) {
console.log(1);
for (var i = 0; i < 10000; i++) {
i == 9999 && resolve()
}
console.log(2);
}).then(function() {
console.log(5)
});
console.log(3);複製程式碼
這裡先賣個關子, 後續將給出答案並提供詳細分析.
和往常文章一樣, 我喜歡從api入手, 先具象地瞭解一個概念, 然後再抽象或擴充套件這個概念, 接著再談談概念的具體應用場景, 通常末尾還會有一個簡短的小結. 這樣, 查詢api的讀者可以選擇性地閱讀上文, 希望深入的讀者可以繼續剖析概念, 當然我更希望你能耐心地讀到應用場景處, 這樣便能昇華對這個概念或技術的運用, 也能避免踩坑.
new Promise
Promise的設計初衷是避免非同步回撥地獄. 它提供更簡潔的api, 同時展平回撥為鏈式呼叫, 使得程式碼更加清爽, 易讀.
如下, 即建立一個Promise物件:
const p = new Promise(function(resolve, reject) {
console.log('Create a new Promise.');
});
console.log(p);複製程式碼
建立Promise時, 瀏覽器同步執行傳入的第一個方法, 從而輸出log. 新建立的promise例項物件, 初始狀態為等待(pending), 除此之外, Promise還有另外兩個狀態:
- fulfilled, 表示操作完成, 實現了. 只在resolve方法執行時才進入該狀態.
- rejected, 表示操作失敗, 拒絕了. 只在reject方法執行時或丟擲錯誤的情況下才進入該狀態.
如下圖展示了Promise的狀態變化過程(圖片來自MDN):
從初始狀態(pending)到實現(fulfilled)或拒絕(rejected)狀態的轉換, 這是兩個分支, 實現或拒絕即最終狀態, 一旦到達其中之一的狀態, promise的狀態便穩定了. (因此, 不要嘗試實現或拒絕狀態的互轉, 它們都是最終狀態, 沒法轉換)
以上, 建立Promise物件時, 傳入的回撥函式function(resolve, reject){}
預設擁有兩個引數, 分別為:
- resolve, 用於改變該Promise本身的狀態為實現, 執行後, 將觸發then的onFulfilled回撥, 並把resolve的引數傳遞給onFulfilled回撥.
- reject, 用於改變該Promise本身的狀態為拒絕, 執行後, 將觸發 then | catch的onRejected回撥, 並把reject的引數傳遞給onRejected回撥.
Promise的原型僅有兩個自身方法, 分別為 Promise.prototype.then
, Promise.prototype.catch
. 而它自身僅有四個方法, 分別為 Promise.reject
, Promise.resolve
, Promise.all
, Promise.race
.
then
語法: Promise.prototype.then(onFulfilled, onRejected)
用於繫結後續操作. 使用十分簡單:
p.then(function(res) {
console.log('此處執行後續操作');
});
// 當然, then的最大便利之處便是可以鏈式呼叫
p.then(function(res) {
console.log('先做一件事');
}).then(function(res) {
console.log('再做一件事');
});
// then還可以同時接兩個回撥,分別處理成功和失敗狀態
p.then(function(SuccessRes) {
console.log('處理成功的操作');
}, function(failRes) {
console.log('處理失敗的操作');
});複製程式碼
不僅如此, Promise的then中還可返回一個新的Promise物件, 後續的then將接著繼續處理這個新的Promise物件.
p.then(function(){
return new Promise(function(resolve, reject) {
console.log('這裡是一個新的Promise物件');
resolve('New Promise resolve.');
});
}).then(function(res) {
console.log(res);
});複製程式碼
那麼, 如果沒有指定返回值, 會怎麼樣?
根據Promise規範, then或catch即使未顯式指定返回值, 它們也總是預設返回一個新的fulfilled狀態的promise物件.
catch
語法: Promise.prototype.catch(onRejected)
用於捕獲並處理異常. 無論是程式丟擲的異常, 還是主動reject掉Promise自身, 都會被catch捕獲到.
new Promise(function(resolve, reject) {
reject('該prormise已被拒絕');
}).catch(function(reason) {
console.log('catch:', reason);
});複製程式碼
同then語句一樣, catch也是可以鏈式呼叫的.
new Promise(function(resolve, reject){
reject('該prormise已被拒絕');
}).catch(function(reason){
console.log('catch:', reason);
console.log(a);
}).catch(function(reason){
console.log(reason);
});複製程式碼
以上, 將依次輸出兩次log, 第一次輸出promise被拒絕, 第二次輸出"ReferenceError a is not defined"的堆疊資訊.
catch能捕獲哪些錯誤
那是不是catch可以捕獲所有錯誤呢? 可以, 怎麼不可以, 我以前也這麼天真的認為. 直到有一天我執行了如下的語句, 我就學乖了.
new Promise(function(resolve, reject){
Promise.reject('返回一個拒絕狀態的Promise');
}).catch(function(reason){
console.log('catch:', reason);
});複製程式碼
執行結果如下:
為什麼catch沒有捕獲到該錯誤呢? 這個問題, 待下一節我們瞭解了Promise.reject語法後再做分析.
Promise.reject
語法: Promise.reject(value)
該方法返回一個拒絕狀態的Promise物件, 同時傳入的引數作為PromiseValue.
//params: String
Promise.reject('該prormise已被拒絕');
.catch(function(reason){
console.log('catch:', reason);
});
//params: Error
Promise.reject(new Error('這是一個error')).then(function(res) {
console.log('fulfilled:', res);
}, function(reason) {
console.log('rejected:', reason); // rejected: Error: 這是一個error...
});複製程式碼
即使引數為Promise物件, 它也一樣會把Promise當作拒絕的理由, 在外部再包裝一個拒絕狀態的Promise物件予以返回.
//params: Promise
const p = new Promise(function(resolve) {
console.log('This is a promise');
});
Promise.reject(p).catch(function(reason) {
console.log('rejected:', reason);
console.log(p == reason);
});
// "This is a promise"
// rejected: Promise {[[PromiseStatus]]: "pending", [[PromiseValue]]: undefined}
// true複製程式碼
以上程式碼片段, Promise.reject(p)
進入到了catch語句中, 說明其返回了一個拒絕狀態的Promise, 同時拒絕的理由就是傳入的引數p.
錯誤處理
我們都知道, Promise.reject返回了一個拒絕狀態的Promise物件. 對於這樣的Promise物件, 如果其後續then | catch中都沒有宣告onRejected回撥, 它將會丟擲一個 "Uncaught (in promise) ..."的錯誤. 如上圖所示, 原語句是 "Promise.reject('返回一個拒絕狀態的Promise');" 其後續並沒有跟隨任何then | catch語句, 因此它將丟擲錯誤, 且該錯外部的Promise無法捕獲.
不僅如此, Promise之間涇渭分明, 內部Promise丟擲的任何錯誤, 外部Promise物件都無法感知並捕獲. 同時, 由於promise是非同步的, try catch語句也無法捕獲其錯誤.
因此養成良好習慣, promise記得寫上catch.
除了catch, nodejs下Promise丟擲的錯誤, 還會被程式的unhandledRejection
和 rejectionHandled
事件捕獲.
var p = new Promise(function(resolve, reject){
//console.log(a);
reject('rejected');
});
setTimeout(function(){
p.catch(function(reason){
console.info('promise catch:', reason);
});
});
process.on('uncaughtException', (e) => {
console.error('uncaughtException', e);
});
process.on('unhandledRejection', (e) => {
console.info('unhandledRejection:', e);
});
process.on('rejectionHandled', (e) => {
console.info('rejectionHandled', e);
});
//unhandledRejection: rejected
//rejectionHandled Promise { <rejected> 'rejected' }
//promise catch: rejected複製程式碼
即使去掉以上程式碼中的註釋, 輸出依然一致. 可見, Promise內部丟擲的錯誤, 都不會被uncaughtException
事件捕獲.
鏈式寫法的好處
請看如下程式碼:
new Promise(function(resolve, reject) {
resolve('New Promise resolve.');
}).then(function(str) {
throw new Error("oops...");
},function(error) {
console.log('then catch:', error);
}).catch(function(reason) {
console.log('catch:', reason);
});
//catch: Error: oops...複製程式碼
可見, then語句的onRejected回撥並不能捕獲onFulfilled回撥內丟擲的錯誤, 尾隨其後的catch語句卻可以, 因此推薦鏈式寫法.
Promise.resolve
語法: Promise.resolve(value | promise | thenable)
thenable 表示一個定義了 then
方法的物件或函式.
引數為promise時, 返回promise本身.
引數為thenable的物件或函式時, 將其then屬性作為new promise時的回撥, 返回一個包裝的promise物件.(注意: 這裡與Promise.reject直接包裝一個拒絕狀態的Promise不同)
其他情況下, 返回一個實現狀態的Promise物件, 同時傳入的引數作為PromiseValue.
//params: String
//return: fulfilled Promise
Promise.resolve('返回一個fulfilled狀態的promise').then(function(res) {
console.log(res); // "返回一個fulfilled狀態的promise"
});
//params: Array
//return: fulfilled Promise
Promise.resolve(['a', 'b', 'c']).then(function(res) {
console.log(res); // ["a", "b", "c"]
});
//params: Promise
//return: Promise self
let resolveFn;
const p2 = new Promise(function(resolve) {
resolveFn = resolve;
});
const r2 = Promise.resolve(p2);
r2.then(function(res) {
console.log(res);
});
resolveFn('xyz'); // "xyz"
console.log(r2 === p2); // true
//params: thenable Object
//return: 根據thenable的最終狀態返回不同的promise
const thenable = {
then: function(resolve, reject) { //作為new promise時的回撥函式
reject('promise rejected!');
}
};
Promise.resolve(thenable).then(function(res) {
console.log('res:', res);
}, function(reason) {
console.log('reason:', reason);
});複製程式碼
可見, Promise.resolve並非返回實現狀態的Promise這麼簡單, 我們還需基於傳入的引數動態判斷.
至此, 我們基本上不用期望使用Promise全域性方法中去改變其某個例項的狀態.
- 對於Promise.reject(promise), 它只是簡單地包了一個拒絕狀態的promise殼, 引數promise什麼都沒變.
- 對於Promise.resolve(promise), 僅僅返回引數promise本身.
Promise.all
語法: Promise.all(iterable)
該方法接一個迭代器(如陣列等), 返回一個新的Promise物件. 如果迭代器中所有的Promise物件都被實現, 那麼, 返回的Promise物件狀態為"fulfilled", 反之則為"rejected". 概念上類似Array.prototype.every.
//params: all fulfilled promise
//return: fulfilled promise
Promise.all([1, 2, 3]).then(function(res){
console.log('promise fulfilled:', res); // promise fulfilled: [1, 2, 3]
});
//params: has rejected promise
//return: rejected promise
const p = new Promise(function(resolve, reject){
reject('rejected');
});
Promise.all([1, 2, p]).then(function(res){
console.log('promise fulfilled:', res);
}).catch(function(reason){
console.log('promise reject:', reason); // promise reject: rejected
});複製程式碼
Promise.all特別適用於處理依賴多個非同步請求的結果的場景.
Promise.race
該方法接一個迭代器(如陣列等), 返回一個新的Promise物件. 只要迭代器中有一個Promise物件狀態改變(被實現或被拒絕), 那麼返回的Promise將以相同的值被實現或拒絕, 然後它將忽略迭代器中其他Promise的狀態變化.
Promise.race([1, Promise.reject(2)]).then(function(res){
console.log('promise fulfilled:', res);
}).catch(function(reason){
console.log('promise reject:', reason);
});
// promise fulfilled: 1複製程式碼
如果調換以上引數的順序, 結果將輸出 "promise reject: 2". 可見對於狀態穩定的Promise(fulfilled 或 rejected狀態), 哪個排第一, 將返回哪個.
Promise.race適用於多者中取其一的場景, 比如同時傳送多個請求, 只要有一個請求成功, 那麼就以該Promise的狀態作為最終的狀態, 該Promise的值作為最終的值, 包裝成一個新的Promise物件予以返回.
在 Fetch進階指南 一文中, 我曾利用Promise.race模擬了Promise的abort和timeout機制.
Promises/A+規範的要點
promise.then(onFulfilled, onRejected)中, 引數都是可選的, 如果onFulfilled或onRejected不是函式, 那麼將忽略它們.
catch只是then的語法糖, 相當於promise.then(null, onRejected).
任務佇列之謎
終於, 我們要一起來看看文章起始的一道題目.
setTimeout(function() {
console.log(4)
}, 0);
new Promise(function(resolve) {
console.log(1);
for (var i = 0; i < 10000; i++) {
i == 9999 && resolve()
}
console.log(2);
}).then(function() {
console.log(5)
});
console.log(3);複製程式碼
這道題目來自知乎(機智的你可能早已看穿, 但千萬別戳破?), 可以戳此連結 Promise的佇列與setTimeout的佇列有何關聯 圍觀點贊.
圍觀完了, 別忘了繼續讀下去, 這裡請允許我站在諸位知乎大神的肩膀上, 繼續深入分析.
以上程式碼, 最終執行結果是1,2,3,5,4. 並不是1,2,3,4,5.
- 首先前面有提到, new Promise第一個回撥函式內的語句同步執行, 因此控制檯將順序輸出1,2, 此處應無異議.
console.log(3)
, 這裡是同步執行, 因此接著將輸出3, 此處應無異議.- 剩下便是setTimeout 和 Promise的then的博弈了, 同為非同步事件, 為什麼then後註冊卻先於setTimeout執行?
之前, 我們在 Ajax知識體系 一文中有提到:
瀏覽器中, js引擎執行緒會迴圈從
任務佇列
中讀取事件並且執行, 這種執行機制稱作Event Loop
(事件迴圈).
不僅如此, event loop至少擁有如下兩種佇列:
- task queue, 也叫macrotask queue, 指的是巨集任務佇列, 包括rendering, script(頁面指令碼), 滑鼠, 鍵盤, 網路請求等事件觸發, setTimeout, setInterval, setImmediate(node)等等.
- microtask queue, 指的是微任務佇列, 用於在瀏覽器重新渲染前執行, 包含Promise, process.nextTick(node), Object.observe, MutationObserver回撥等.
如下是HTML規範原文:
An event loop has one or more task queues. A task queue is an ordered list of tasks, which are algorithms that are responsible for such work as: events, parsing, callbacks, using a resource, reacting to DOM manipulation...
Each event loop has a microtask queue. A microtask is a task that is originally to be queued on the microtask queue rather than a task queue.
瀏覽器(或宿主環境) 遵循佇列先進先出原則, 依次遍歷macrotask queue中的每一個task, 不過每執行一個macrotask, 並不是立即就執行下一個, 而是執行一遍microtask queue中的任務, 然後切換GUI執行緒重新渲染或垃圾回收等.
上述程式碼塊可以看做是一個macrotask, 對於其執行過程, 不妨作如下簡化:
- 首先執行當前macrotask, 將setTimeout回撥以一個新的task形式, 加入到macrotask queue末尾.
- 當前macrotask繼續執行, 建立一個新的Promise, 同步執行其回撥函式, 輸出1; for迴圈1w次, 然後執行resolve方法, 將該Promise回撥加入到microtask queue末尾, 迴圈結束, 接著輸出2.
- 當前macrotask繼續執行, 輸出3. 至此, 當前macrotask執行完畢.
- 開始順序執行microtask queue中的所有任務, 也包括剛剛加入到佇列末尾 Promise回撥, 故輸出5. 至此, microtask queue任務全部執行完畢, microtask queue清空.
- 瀏覽器掛起js引擎, 可能切換至GUI執行緒或者執行垃圾回收等.
- 切換回js引擎, 繼續從macrotask queue取出下一個macrotask, 執行之, 然後再取出microtask queue, 執行之, 後續所有的macrotask均如此重複. 自然, 也包括剛剛加入到佇列末尾的setTimeout回撥, 故輸出4.
這裡直接給出事件回撥優先順序:
process.nextTick > promise.then > setTimeout ? setImmediate複製程式碼
nodejs中每一次event loop稱作tick. _tickCallback在macrotask queue中每個task執行完成後觸發. 實際上, _tickCallback內部共幹了兩件事:
- 執行nextTick queue中的所有任務, 包括process.nextTick註冊的回撥.
- 第一步完成後執行 _runMicrotasks函式, 即執行microtask queue中的所有任務, 包括promise.then註冊的回撥.
因此, process.nextTick優先順序比promise.then高.
那麼setTimeout與setImmediate到底哪個更快呢? 回答是並不確定. 請看如下程式碼:
setImmediate(function(){
console.log(1);
});
setTimeout(function(){
console.log(0);
}, 0);複製程式碼
前後兩次的執行結果如下:
測試時, 我本地node版本是v5.7.0.
本問就討論這麼多內容,大家有什麼問題或好的想法歡迎在下方參與留言和評論.
本文作者: louis
本文連結: louiszhai.github.io/2017/02/25/…
參考文章