Promise使用手冊

文章轉載自掘金:Promise使用手冊

本篇以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);

new Promise

new Promise

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

  • fulfilled, 表示操作完成, 實現了. 只在resolve方法執行時才進入該狀態.
  • rejected, 表示操作失敗, 拒絕了. 只在reject方法執行時或拋出錯誤的情況下才進入該狀態.
    如下圖展示了Promise的狀態變化過程(圖片來自MDN):
    Promise狀態及轉變

從初始狀態(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對象.(筆者注:這也意味着除非Promise內部發生不可捕獲的錯誤,否則Promise最終狀態總是{[[PromiseStatus]]: "resolved"。在Chrome下輸出“resolved”等同於“fulfilled”)

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拋出的錯誤, 還會被進程的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.race(iterable)

該方法接一個迭代器(如數組等), 返回一個新的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);

前後兩次的執行結果如下:
這裏寫圖片描述

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

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

本文作者: louis

本文鏈接: http://louiszhai.github.io/2017/02/25/promise/

參考文章

完全理解Promise_JavaScript_第七城市
Promise - JavaScript | MDN
Promise的隊列與setTimeout的隊列的有何關聯 -知乎
HTML Standard event loop
Promises/A+
setImmediate API demo
Process.nextTick 和 setImmediate 的區別? - 知乎

發佈了21 篇原創文章 · 獲贊 27 · 訪問量 8萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章