Promise使用手冊

路易斯發表於2017-04-19

本篇以Promise為核心, 逐步展開, 最終分析process.nextTick , promise.then , setTimeout , setImmediate 它們的非同步機制.

導讀

Promise問世已久, 其科普類文章亦不計其數. 遂本篇初衷不為科普, 只為能夠溫故而知新.

比如說, catch能捕獲所有的錯誤嗎? 為什麼有些時候會丟擲"Uncaught (in promise) …"? Promise.resolvePromise.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使用手冊
new Promise

建立Promise時, 瀏覽器同步執行傳入的第一個方法, 從而輸出log. 新建立的promise例項物件, 初始狀態為等待(pending), 除此之外, Promise還有另外兩個狀態:

  • fulfilled, 表示操作完成, 實現了. 只在resolve方法執行時才進入該狀態.
  • rejected, 表示操作失敗, 拒絕了. 只在reject方法執行時或丟擲錯誤的情況下才進入該狀態.

如下圖展示了Promise的狀態變化過程(圖片來自MDN):

Promise使用手冊
Promise state

從初始狀態(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);
});複製程式碼

執行結果如下:

Promise使用手冊

為什麼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丟擲的錯誤, 還會被程式的unhandledRejectionrejectionHandled事件捕獲.

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.

  1. 首先前面有提到, new Promise第一個回撥函式內的語句同步執行, 因此控制檯將順序輸出1,2, 此處應無異議.
  2. console.log(3), 這裡是同步執行, 因此接著將輸出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, 對於其執行過程, 不妨作如下簡化:

  1. 首先執行當前macrotask, 將setTimeout回撥以一個新的task形式, 加入到macrotask queue末尾.
  2. 當前macrotask繼續執行, 建立一個新的Promise, 同步執行其回撥函式, 輸出1; for迴圈1w次, 然後執行resolve方法, 將該Promise回撥加入到microtask queue末尾, 迴圈結束, 接著輸出2.
  3. 當前macrotask繼續執行, 輸出3. 至此, 當前macrotask執行完畢.
  4. 開始順序執行microtask queue中的所有任務, 也包括剛剛加入到佇列末尾 Promise回撥, 故輸出5. 至此, microtask queue任務全部執行完畢, microtask queue清空.
  5. 瀏覽器掛起js引擎, 可能切換至GUI執行緒或者執行垃圾回收等.
  6. 切換回js引擎, 繼續從macrotask queue取出下一個macrotask, 執行之, 然後再取出microtask queue, 執行之, 後續所有的macrotask均如此重複. 自然, 也包括剛剛加入到佇列末尾的setTimeout回撥, 故輸出4.

這裡直接給出事件回撥優先順序:

process.nextTick > promise.then > setTimeout ? setImmediate複製程式碼

nodejs中每一次event loop稱作tick. _tickCallback在macrotask queue中每個task執行完成後觸發. 實際上, _tickCallback內部共幹了兩件事:

  1. 執行nextTick queue中的所有任務, 包括process.nextTick註冊的回撥.
  2. 第一步完成後執行 _runMicrotasks函式, 即執行microtask queue中的所有任務, 包括promise.then註冊的回撥.

因此, process.nextTick優先順序比promise.then高.

那麼setTimeout與setImmediate到底哪個更快呢? 回答是並不確定. 請看如下程式碼:

setImmediate(function(){
    console.log(1);
});
setTimeout(function(){
    console.log(0);
}, 0);複製程式碼

前後兩次的執行結果如下:

Promise使用手冊

測試時, 我本地node版本是v5.7.0.


本問就討論這麼多內容,大家有什麼問題或好的想法歡迎在下方參與留言和評論.

本文作者: louis

本文連結: louiszhai.github.io/2017/02/25/…

參考文章

相關文章