【前端知識點】promise簡書-30分鐘帶你搞懂promise面試必備

前言

寫作初衷

本書的目的是以目前還在制定中的ECMAScript 6 Promises規範爲中心,着重向各位讀者介紹JavaScript中對Promise相關技術的支持情況。

通過閱讀本書,我們希望各位讀者能在下面三個目標上有所收穫。

  • 學習Promise相關內容,能熟練使用Promise模式並進行測試

  • 學習Promise適合什麼、不適合什麼,知道Promise不是萬能的,不能什麼都想用Promise來解決

  • 以ES6 Promises爲基礎進行學習,逐漸發展形成自己的風格

像上面所提到的那樣,本書主要是以ES6 Promises,即JavaScript的標準規範爲基礎的、Promise的相關知識爲主要講解內容。

在Firefox和Chrome這樣技術比較超前的瀏覽器上,不需要安裝額外的插件就能使用Promise功能,此外ES6 Promises的規範來源於Promises/A+社區,它有很多版本的實現。

我們將會從基礎API開始介紹可以在瀏覽器的原生支持或者通過插件支持的Promise功能。 也希望各位讀者能瞭解這其中Promise適合幹什麼,不適合幹什麼,能根據實際需求選擇合適的技術實現方案。

開始閱讀之前

本書的閱讀對象需要對JavaScript有基本的瞭解和知識。

如果你讀過上面的其中一本的話,就應該非常容易理解本書的內容了。

另外如果你有使用JavaScript編寫Web應用程序的經驗,或者使用Node.js編寫過命令行、服務器端程序的話,那麼你可能會對本文中的一些內容感到非常熟悉。

本書的一本分章節將會以Node.js環境爲背景進行說明,如果你有Node.js基礎的話,那麼一定會非常容易理解這部分內容了。

格式約定

本書爲了節約篇幅,用了下面一些格式上的約定。

  • 關於Promise的術語請參考術語集

    • 一般一個名詞第一次出現時都會附帶相關鏈接。

  • 實例方法都用 instance#method 的形式。

    • 比如 Promise#then 這種寫法表示的是 Promise的實例對象的 then 這一方法。

  • 對象方法都採用 object.method 的形式。

    • 這沿用了JavaScript中的使用方式,Promise.all 表示的是一個靜態方法。

這部分內容主要講述的是對正文部分的補充說明。

推薦瀏覽器

我們推薦使用內置對Promise支持的瀏覽器來閱讀本書。

Firefox和Chrome的話都支持ES6 Promises標準。

此外,雖然不是推薦的閱讀環境,但是讀者還是能在iOS等移動終端上閱讀本書。

運行示例代碼

本網站使用了Promise的Polyfill類庫,因此即使在不支持Promise的瀏覽器上也能執行示例代碼。

此外像下面這樣,各位讀者可以通過運行按鈕來運行可執行的示例代碼。

1
var promise = new Promise(function(resolve){
2
    resolve(42);
3
});
4
promise.then(function(value){
5
    console.log(value);
6
}).catch(function(error){
7
    console.error(error);
8
});
  
42
42
42
按下  按鈕之後,代碼區會變成編輯區,代碼也會被執行。當然你也可以通過這個按鈕再次運行代碼。
 按鈕用於清除由 console.log 打印出來的log。 
按鈕用來退出編輯模式。

如果你對哪裏有疑問的話,都可以現場修改代碼並執行,以加深對該部分代碼的理解。

本書源代碼/License

本書中示例代碼都可以在GitHub上找到。

本書採用 AsciiDoc 格式編寫。

此外代碼倉庫中還包含本書示例代碼的測試代碼。

源代碼的許可證爲MIT許可證,文章內容可以基於CC-BY-NC使用。

意見和疑問

如果有意見或者問題的話,可以直接在GitHub上提Issue即可。

此外,你也可以在 在線聊天 上留言。

  • Gitter

各位讀者除了能免費閱讀本書,也有編輯本書的權利。你可以在GitHub上通過 Pull Requests 來貢獻自己的工作。

1. Chapter.1 - 什麼是Promise

本章將主要對JavaScript中的Promise進行入門級的介紹。

1.1. 什麼是Promise

首先讓我們來了解一下到底什麼是Promise。

Promise是抽象異步處理對象以及對其進行各種操作的組件。 其詳細內容在接下來我們還會進行介紹,Promise並不是從JavaScript中發祥的概念。

Promise最初被提出是在 E語言中, 它是基於並列/並行處理設計的一種編程語言。

現在JavaScript也擁有了這種特性,這就是本書所介紹的JavaScript Promise。

另外,如果說到基於JavaScript的異步處理,我想大多數都會想到利用回調函數。

使調
----
getAsync("fileA.txt", function(error, result){
    if(error){// 取得失敗時的處理
        throw error;
    }
    // 取得成功時的處理
});
----
<1> 調(error )

Node.js等則規定在JavaScript的回調函數的第一個參數爲 Error 對象,這也是它的一個慣例。

像上面這樣基於回調函數的異步處理如果統一參數使用規則的話,寫法也會很明瞭。 但是,這也僅是編碼規約而已,即使採用不同的寫法也不會出錯。

而Promise則是把類似的異步處理對象和處理規則進行規範化, 並按照採用統一的接口來編寫,而採取規定方法之外的寫法都會出錯。

使Promise
----
var promise = getAsyncPromise("fileA.txt"); 
promise.then(function(result){
    // 獲取文件內容成功時的處理
}).catch(function(error){
    // 獲取文件內容失敗時的處理
});
----
<1> promise

我們可以向這個預設了抽象化異步處理的promise對象, 註冊這個promise對象執行成功時和失敗時相應的回調函數。

這和回調函數方式相比有哪些不同之處呢? 在使用promise進行一步處理的時候,我們必須按照接口規定的方法編寫處理代碼。

也就是說,除promise對象規定的方法(這裏的 then 或 catch)以外的方法都是不可以使用的, 而不會像回調函數方式那樣可以自己自由的定義回調函數的參數,而必須嚴格遵守固定、統一的編程方式來編寫代碼。

這樣,基於Promise的統一接口的做法, 就可以形成基於接口的各種各樣的異步處理模式。

所以,promise的功能是可以將複雜的異步處理輕鬆地進行模式化, 這也可以說得上是使用promise的理由之一。

接下來,讓我們在實踐中來學習JavaScript的Promise吧。

1.2. Promise簡介

在 ES6 Promises 標準中定義的API還不是很多。

目前大致有下面三種類型。

Constructor

Promise類似於 XMLHttpRequest,從構造函數 Promise 來創建一個新建新promise對象作爲接口。

要想創建一個promise對象、可以使用new來調用Promise的構造器來進行實例化。

var promise = new Promise(function(resolve, reject) {
    // 異步處理
    // 處理結束後、調用resolve 或 reject
});

Instance Method

對通過new生成的promise對象爲了設置其值在 resolve(成功) / reject(失敗)時調用的回調函數 可以使用promise.then() 實例方法。

promise.then(onFulfilled, onRejected)
resolve(成功)時

onFulfilled 會被調用

reject(失敗)時

onRejected 會被調用

onFulfilledonRejected 兩個都爲可選參數。

promise.then 成功和失敗時都可以使用。 另外在只想對異常進行處理時可以採用 promise.then(undefined, onRejected) 這種方式,只指定reject時的回調函數即可。 不過這種情況下 promise.catch(onRejected) 應該是個更好的選擇。

promise.catch(onRejected)

Static Method

像 Promise 這樣的全局對象還擁有一些靜態方法。

包括 Promise.all() 還有 Promise.resolve() 等在內,主要都是一些對Promise進行操作的輔助方法。

1.2.1. Promise workflow

我們先來看一看下面的示例代碼。

promise-workflow.js
function asyncFunction() {
    
    return new Promise(function (resolve, reject) {
        setTimeout(function () {
            resolve('Async Hello world');
        }, 16);
    });
}

asyncFunction().then(function (value) {
    console.log(value);    // => 'Async Hello world'
}).catch(function (error) {
    console.log(error);
});
new Promise構造器之後,會返回一個promise對象
<1>爲promise對象用設置 .then 調用返回值時的回調函數。

asyncFunction 這個函數會返回promise對象, 對於這個promise對象,我們調用它的 then 方法來設置resolve後的回調函數, catch 方法來設置發生錯誤時的回調函數。

該promise對象會在setTimeout之後的16ms時被resolve, 這時 then 的回調函數會被調用,並輸出 'Async Hello world' 。

在這種情況下 catch 的回調函數並不會被執行(因爲promise返回了resolve), 不過如果運行環境沒有提供 setTimeout 函數的話,那麼上面代碼在執行中就會產生異常,在 catch 中設置的回調函數就會被執行。

當然,像promise.then(onFulfilled, onRejected) 的方法聲明一樣, 如果不使用catch 方法只使用 then方法的話,如下所示的代碼也能完成相同的工作。

asyncFunction().then(function (value) {
    console.log(value);
}, function (error) {
    console.log(error);
});

1.2.2. Promise的狀態

我們已經大概瞭解了Promise的處理流程,接下來讓我們來稍微整理一下Promise的狀態。

new Promise 實例化的promise對象有以下三個狀態。

"has-resolution" - Fulfilled

resolve(成功)時。此時會調用 onFulfilled

"has-rejection" - Rejected

reject(失敗)時。此時會調用 onRejected

"unresolved" - Pending

既不是resolve也不是reject的狀態。也就是promise對象剛被創建後的初始化狀態等

關於上面這三種狀態的讀法,其中 左側爲在 ES6 Promises 規範中定義的術語, 而右側則是在 Promises/A+ 中描述狀態的術語。

基本上狀態在代碼中是不會涉及到的,所以名稱也無需太在意。 在這本書中,我們會基於 Promises/A+ 中 Pending 、 Fulfilled 、 Rejected 的狀態名稱進行講述。

promise-states
Figure 1. promise states

在 ECMAScript Language Specification ECMA-262 6th Edition – DRAFT 中 [[PromiseStatus]] 都是在內部定義的狀態。 由於沒有公開的訪問 [[PromiseStatus]] 的用戶API,所以暫時還沒有查詢其內部狀態的方法。

到此在本文中我們已經介紹了promise所有的三種狀態。

promise對象的狀態,從Pending轉換爲FulfilledRejected之後, 這個promise對象的狀態就不會再發生任何變化。

也就是說,Promise與Event等不同,在.then 後執行的函數可以肯定地說只會被調用一次。

另外,FulfilledRejected這兩個中的任一狀態都可以表示爲Settled(不變的)。

Settled

resolve(成功) 或 reject(失敗)。

PendingSettled的對稱關係來看,Promise狀態的種類/遷移是非常簡單易懂的。

當promise的對象狀態發生變化時,用.then 來定義只會被調用一次的函數。

JavaScript Promises - Thinking Sync in an Async World // Speaker Deck 這個ppt中有關於Promise狀態遷移的非常容易理解的說明。

1.3. 編寫Promise代碼

這裏我們來介紹一下如何編寫Promise代碼。

1.3.1. 創建promise對象

創建promise對象的流程如下所示。

  1. new Promise(fn) 返回一個promise對象

  2. fn 中指定異步等處理

    • 處理結果正常的話,調用resolve(處理結果值)

    • 處理結果錯誤的話,調用reject(Error對象)

按這個流程我們來實際編寫下promise代碼吧。

我們的任務是用Promise來通過異步處理方式來獲取XMLHttpRequest(XHR)的數據。

創建XHR的promise對象

首先,創建一個用Promise把XHR處理包裝起來的名爲 getURL 的函數。

xhr-promise.js
function getURL(URL) {
    return new Promise(function (resolve, reject) {
        var req = new XMLHttpRequest();
        req.open('GET', URL, true);
        req.onload = function () {
            if (req.status === 200) {
                resolve(req.responseText);
            } else {
                reject(new Error(req.statusText));
            }
        };
        req.onerror = function () {
            reject(new Error(req.statusText));
        };
        req.send();
    });
}
// 運行示例
var URL = "http://httpbin.org/get";
getURL(URL).then(function onFulfilled(value){
    console.log(value);
}).catch(function onRejected(error){
    console.error(error);
});

getURL 只有在通過XHR取得結果狀態爲200時纔會調用 resolve - 也就是隻有數據取得成功時,而其他情況(取得失敗)時則會調用 reject 方法。

resolve(req.responseText) 在response的內容中加入了參數。 resolve方法的參數並沒有特別的規則,基本上把要傳給回調函數參數放進去就可以了。 ( then 方法可以接收到這個參數值)

熟悉Node.js的人,經常會在寫回調函數時將 callback(error, response) 的第一個參數設爲error對象,而在Promise中resolve/reject則擔當了這個職責(處理正常和異常的情況),所以 在resolve方法中只傳一個response參數是沒有問題的。

接下來我們來看一下reject函數。

XHR中 onerror 事件被觸發的時候就是發生錯誤時,所以理所當然調用reject。 這裏我們重點來看一下傳給reject的值。

發生錯誤時要像這樣 reject(new Error(req.statusText)); ,創建一個Error對象後再將具體的值傳進去。 傳給reject 的參數也沒有什麼特殊的限制,一般只要是Error對象(或者繼承自Error對象)就可以。

傳給reject 的參數,其中一般是包含了reject原因的Error對象。 本次因爲狀態值不等於200而被reject,所以reject 中放入的是statusText。 (這個參數的值可以被 then 方法的第二個參數或者 catch 方法中使用)

1.3.2. 編寫promise對象處理方法

讓我們在實際中使用一下剛纔創建的返回promise對象的函數

getURL("http://example.com/"); // => 返回promise對象

Promises Overview 中做的簡單介紹一樣,promise對象擁有幾個實例方法, 我們使用這些實例方法來爲promise對象創建依賴於promise的具體狀態、並且只會被執行一次的回調函數。

爲promise對象添加處理方法主要有以下兩種

  • promise對象被 resolve 時的處理(onFulfilled)

  • promise對象被 reject 時的處理(onRejected)

promise-resolve-flow
Figure 2. promise value flow

首先,我們來嘗試一下爲 getURL 通信成功並取到值時添加的處理函數。

此時所謂的 通信成功 , 指的就是在被resolve後, promise對象變爲FulFilled狀態 。

resolve後的處理,可以在.then 方法中傳入想要調用的函數。

var URL = "http://httpbin.org/get";
getURL(URL).then(function onFulfilled(value){ 
    console.log(value);
});
爲了方便理解我們把函數命名爲 onFulfilled

getURL函數 中的 resolve(req.responseText); 會將promise對象變爲resolve(Fulfilled)狀態, 同時使用其值調用 onFulfilled函數。

不過目前我們還沒有對其中可能發生的錯誤做任何處理, 接下來,我們就來爲 getURL 函數添加發生錯誤時的異常處理。

此時 發生錯誤 , 指的也就是reject後 promise對象變爲Rejected狀態 。

reject後的處理,可以在.then 的第二個參數 或者是在 .catch 方法中設置想要調用的函數。

把下面reject時的處理加入到剛纔的代碼,如下所示。

var URL = "http://httpbin.org/status/500"; 
getURL(URL).then(function onFulfilled(value){
    console.log(value);
}).catch(function onRejected(error){ 
    console.error(error);
});
服務端返回的狀態碼爲500
爲了方便理解函數被命名爲 onRejected

getURL 的處理中發生任何異常,或者被明確reject的情況下, 該異常原因(Error對象)會作爲 .catch 方法的參數被調用。

其實 .catch只是 promise.then(undefined, onRejected) 的別名而已, 如下代碼也可以完成同樣的功能。

getURL(URL).then(onFulfilled, onRejected);
onFulfilled, onRejected 是和剛纔相同的函數

一般說來,使用.catch來將resolve和reject處理分開來寫是比較推薦的做法, 這兩者的區別會在then和catch的區別中再做詳細介紹。

總結

在本章我們簡單介紹了以下內容:

  • 用 new Promise 方法創建promise對象

  • .then 或 .catch 添加promise對象的處理函數

到此爲止我們已經學習了Promise的基本寫法。 其他很多處理都是由此基本語法延伸的,也使用了Promise提供的一些靜態方法來實現。

實際上即使使用回調方式的寫法也能完成上面同樣的工作,而使用Promise方式的話有什麼優點麼?在本小節中我們沒有講到兩者的對比及Promise的優點。在接下來的章節中,我們將會對Promise優點之一,即錯誤處理機制進行介紹,以及和傳統的回調方式的對比。

2. Chapter.2 - 實戰Promise

本章我們將會學習Promise提供的各種方法以及如何進行錯誤處理。

2.1. Promise.resolve

一般情況下我們都會使用 new Promise() 來創建promise對象,但是除此之外我們也可以使用其他方法。

在這裏,我們將會學習如何使用 Promise.resolve 和 Promise.reject這兩個方法。

2.1.1. new Promise的快捷方式

靜態方法Promise.resolve(value) 可以認爲是 new Promise() 方法的快捷方式。

比如 Promise.resolve(42); 可以認爲是以下代碼的語法糖。

new Promise(function(resolve){
    resolve(42);
});

在這段代碼中的 resolve(42); 會讓這個promise對象立即進入確定(即resolved)狀態,並將 42 傳遞給後面then裏所指定的 onFulfilled 函數。

方法 Promise.resolve(value); 的返回值也是一個promise對象,所以我們可以像下面那樣接着對其返回值進行 .then 調用。

Promise.resolve(42).then(function(value){
    console.log(value);
});

Promise.resolve作爲 new Promise() 的快捷方式,在進行promise對象的初始化或者編寫測試代碼的時候都非常方便。

2.1.2. Thenable

Promise.resolve 方法另一個作用就是將 thenable 對象轉換爲promise對象。

ES6 Promises裏提到了Thenable這個概念,簡單來說它就是一個非常類似promise的東西。

就像我們有時稱具有 .length 方法的非數組對象爲Array like一樣,thenable指的是一個具有 .then 方法的對象。

這種將thenable對象轉換爲promise對象的機制要求thenable對象所擁有的 then 方法應該和Promise所擁有的 then 方法具有同樣的功能和處理過程,在將thenable對象轉換爲promise對象的時候,還會巧妙的利用thenable對象原來具有的 then 方法。

到底什麼樣的對象能算是thenable的呢,最簡單的例子就是 jQuery.ajax(),它的返回值就是thenable的。

因爲jQuery.ajax() 的返回值是 jqXHR Object 對象,這個對象具有 .then 方法。

$.ajax('/json/comment.json');// => 擁有 `.then` 方法的對象

這個thenable的對象可以使用 Promise.resolve 來轉換爲一個promise對象。

變成了promise對象的話,就能直接使用 then 或者 catch 等這些在 ES6 Promises裏定義的方法了。

將thenable對象轉換promise對象
var promise = Promise.resolve($.ajax('/json/comment.json'));// => promise對象
promise.then(function(value){
   console.log(value);
});
jQuery和thenable

jQuery.ajax()的返回值是一個具有 .then 方法的 jqXHR Object對象,這個對象繼承了來自 Deferred Object 的方法和屬性。

但是Deferred Object並沒有遵循Promises/A+ES6 Promises標準,所以即使看上去這個對象轉換成了一個promise對象,但是會出現缺失部分信息的問題。

這個問題的根源在於jQuery的 Deferred Object 的 then 方法機制與promise不同。

所以我們應該注意,即使一個對象具有 .then 方法,也不一定就能作爲ES6 Promises對象使用。

Promise.resolve 只使用了共通的方法 then ,提供了在不同的類庫之間進行promise對象互相轉換的功能。

這種轉換爲thenable的功能在之前是通過使用 Promise.cast 來完成的,從它的名字我們也不難想象它的功能是什麼。

除了在編寫使用Promise的類庫等軟件時需要對Thenable有所瞭解之外,通常作爲end-user使用的時候,我們可能不會用到此功能。

我們會在後面第4章的Promise.resolve和Thenable中進行詳細的說明,介紹一下結合使用了Thenable和Promise.resolve的具體例子。

簡單總結一下 Promise.resolve 方法的話,可以認爲它的作用就是將傳遞給它的參數填充(Fulfilled)到promise對象後並返回這個promise對象。

此外,Promise的很多處理內部也是使用了 Promise.resolve 算法將值轉換爲promise對象後再進行處理的。

2.2. Promise.reject

Promise.reject(error)是和 Promise.resolve(value) 類似的靜態方法,是 new Promise() 方法的快捷方式。

比如 Promise.reject(new Error("出錯了")) 就是下面代碼的語法糖形式。

new Promise(function(resolve,reject){
    reject(new Error("出錯了"));
});

這段代碼的功能是調用該promise對象通過then指定的 onRejected 函數,並將錯誤(Error)對象傳遞給這個 onRejected 函數。

Promise.reject(new Error("BOOM!")).catch(function(error){
    console.error(error);
});

它和Promise.resolve(value) 的不同之處在於promise內調用的函數是reject而不是resolve,這在編寫測試代碼或者進行debug時,說不定會用得上。

2.3. 專欄: Promise只能進行異步操作?

在使用Promise.resolve(value) 等方法的時候,如果promise對象立刻就能進入resolve狀態的話,那麼你是不是覺得 .then 裏面指定的方法就是同步調用的呢?

實際上, .then 中指定的方法調用是異步進行的。

var promise = new Promise(function (resolve){
    console.log("inner promise"); // 1
    resolve(42);
});
promise.then(function(value){
    console.log(value); // 3
});
console.log("outer promise"); // 2

執行上面的代碼會輸出下面的log,從這些log我們清楚地知道了上面代碼的執行順序。

inner promise // 1
outer promise // 2
42            // 3

由於JavaScript代碼會按照文件的從上到下的順序執行,所以最開始 <1> 會執行,然後是 resolve(42); 被執行。這時候 promise 對象的已經變爲確定狀態,FulFilled被設置爲了 42 。

下面的代碼 promise.then 註冊了 <3> 這個回調函數,這是本專欄的焦點問題。

由於 promise.then 執行的時候promise對象已經是確定狀態,從程序上說對回調函數進行同步調用也是行得通的。

但是即使在調用 promise.then 註冊回調函數的時候promise對象已經是確定的狀態,Promise也會以異步的方式調用該回調函數,這是在Promise設計上的規定方針。

因此 <2> 會最先被調用,最後纔會調用回調函數 <3> 。

爲什麼要對明明可以以同步方式進行調用的函數,非要使用異步的調用方式呢?

2.3.1. 同步調用和異步調用同時存在導致的混亂

其實在Promise之外也存在這個問題,這裏我們以一般的使用情況來考慮此問題。

這個問題的本質是接收回調函數的函數,會根據具體的執行情況,可以選擇是以同步還是異步的方式對回調函數進行調用。

下面我們以 onReady(fn) 爲例進行說明,這個函數會接收一個回調函數進行處理。

mixed-onready.js
function onReady(fn) {
    var readyState = document.readyState;
    if (readyState === 'interactive' || readyState === 'complete') {
        fn();
    } else {
        window.addEventListener('DOMContentLoaded', fn);
    }
}
onReady(function () {
    console.log('DOM fully loaded and parsed');
});
console.log('==Starting==');

mixed-onready.js會根據執行時DOM是否已經裝載完畢來決定是對回調函數進行同步調用還是異步調用。

如果在調用onReady之前DOM已經載入的話

對回調函數進行同步調用

如果在調用onReady之前DOM還沒有載入的話

通過註冊 DOMContentLoaded 事件監聽器來對回調函數進行異步調用

因此,如果這段代碼在源文件中出現的位置不同,在控制檯上打印的log消息順序也會不同。

爲了解決這個問題,我們可以選擇統一使用異步調用的方式。

async-onready.js
function onReady(fn) {
    var readyState = document.readyState;
    if (readyState === 'interactive' || readyState === 'complete') {
        setTimeout(fn, 0);
    } else {
        window.addEventListener('DOMContentLoaded', fn);
    }
}
onReady(function () {
    console.log('DOM fully loaded and parsed');
});
console.log('==Starting==');

關於這個問題,在 Effective JavaScript 的 第67項 不要對異步回調函數進行同步調用 中也有詳細介紹。

  • 絕對不能對異步回調函數(即使在數據已經就緒)進行同步調用。

  • 如果對異步回調函數進行同步調用的話,處理順序可能會與預期不符,可能帶來意料之外的後果。

  • 對異步回調函數進行同步調用,還可能導致棧溢出或異常處理錯亂等問題。

  • 如果想在將來某時刻調用異步回調函數的話,可以使用 setTimeout 等異步API。

Effective JavaScript— David Herman

前面我們看到的 promise.then 也屬於此類,爲了避免上述中同時使用同步、異步調用可能引起的混亂問題,Promise在規範上規定 Promise只能使用異步調用方式 。

最後,如果將上面的 onReady 函數用Promise重寫的話,代碼如下面所示。

onready-as-promise.js
function onReadyPromise() {
    return new Promise(function (resolve, reject) {
        var readyState = document.readyState;
        if (readyState === 'interactive' || readyState === 'complete') {
            resolve();
        } else {
            window.addEventListener('DOMContentLoaded', resolve);
        }
    });
}
onReadyPromise().then(function () {
    console.log('DOM fully loaded and parsed');
});
console.log('==Starting==');

由於Promise保證了每次調用都是以異步方式進行的,所以我們在實際編碼中不需要調用 setTimeout 來自己實現異步調用。

2.4. Promise#then

在前面的章節裏我們對Promise基本的實例方法 then 和 catch 的使用方法進行了說明。

這其中,我想大家已經認識了 .then().catch() 這種鏈式方法的寫法了,其實在Promise裏可以將任意個方法連在一起作爲一個方法鏈(method chain)。

promise可以寫成方法鏈的形式
aPromise.then(function taskA(value){
// task A
}).then(function taskB(vaue){
// task B
}).catch(function onRejected(error){
    console.log(error);
});

如果把在 then 中註冊的每個回調函數稱爲task的話,那麼我們就可以通過Promise方法鏈方式來編寫能以taskA → task B 這種流程進行處理的邏輯了。

Promise方法鏈這種叫法有點長(其實是在日語裏有點長,中文還可以 --譯者注),因此後面我們會簡化爲 promise chain 這種叫法。

Promise之所以適合編寫異步處理較多的應用,promise chain可以算得上是其中的一個原因吧。

在本小節,我們將主要針對使用 then 的promise chain的行爲和流程進行學習。

2.4.1. promise chain

在第一章 promise chain 裏我們看到了一個很簡單的 then → catch 的例子,如果我們將方法鏈的長度變得更長的話,那在每個promise對象中註冊的onFulfilled和onRejected將會怎樣執行呢?

promise chain - 即方法鏈越短越好。 在這個例子裏我們是爲了方便說明才選擇了較長的方法鏈。

我們先來看看下面這樣的promise chain。

promise-then-catch-flow.js
function taskA() {
    console.log("Task A");
}
function taskB() {
    console.log("Task B");
}
function onRejected(error) {
    console.log("Catch Error: A or B", error);
}
function finalTask() {
    console.log("Final Task");
}

var promise = Promise.resolve();
promise
    .then(taskA)
    .then(taskB)
    .catch(onRejected)
    .then(finalTask);

上面代碼中的promise chain的執行流程,如果用一張圖來描述一下的話,像下面的圖那樣。

promise-then-catch-flow
Figure 3. promise-then-catch-flow.js附圖

在 上述代碼 中,我們沒有爲 then 方法指定第二個參數(onRejected),也可以像下面這樣來理解。

then

註冊onFulfilled時的回調函數

catch

註冊onRejected時的回調函數

再看一下 上面的流程圖 的話,我們會發現 Task A 和 Task B 都有指向 onRejected 的線出來。

這些線的意思是在 Task A 或 Task B 的處理中,在下面的情況下就會調用 onRejected 方法。

  • 發生異常的時候

  • 返回了一個Rejected狀態的promise對象

在 第一章 中我們已經看到,Promise中的處理習慣上都會採用 try-catch 的風格,當發生異常的時候,會被 catch 捕獲並被由在此函數註冊的回調函數進行錯誤處理。

另一種異常處理策略是通過 返回一個Rejected狀態的promise對象 來實現的,這種方法不通過使用 throw 就能在promise chain中對 onRejected 進行調用。

關於這種方法由於和本小節關係不大就不在這裏詳述了,大家可以參考一下第4章 使用reject而不是throw 中的內容。

此外在promise chain中,由於在 onRejected 和 Final Task 後面沒有 catch 處理了,因此在這兩個Task中如果出現異常的話將不會被捕獲,這點需要注意一下。

下面我們再來看一個具體的關於 Task A → onRejected 的例子。

Task A產生異常的例子

Task A 處理中發生異常的話,會按照TaskA → onRejected → FinalTask 這個流程來進行處理。

promise taska rejected flow
Figure 4. Task A產生異常時的示意圖

將上面流程寫成代碼的話如下所示。

promise-then-taska-throw.js
function taskA() {
    console.log("Task A");
    throw new Error("throw Error @ Task A")
}
function taskB() {
    console.log("Task B");// 不會被調用
}
function onRejected(error) {
    console.log(error);// => "throw Error @ Task A"
}
function finalTask() {
    console.log("Final Task");
}

var promise = Promise.resolve();
promise
    .then(taskA)
    .then(taskB)
    .catch(onRejected)
    .then(finalTask);

執行這段代碼我們會發現 Task B 是不會被調用的。

在本例中我們在taskA中使用了 throw 方法故意製造了一個異常。但在實際中想主動進行onRejected調用的時候,應該返回一個Rejected狀態的promise對象。關於這種兩種方法的異同,請參考 使用reject而不是throw 中的講解。

2.4.2. promise chain 中如何傳遞參數

前面例子中的Task都是相互獨立的,只是被簡單調用而已。

這時候如果 Task A 想給 Task B 傳遞一個參數該怎麼辦呢?

答案非常簡單,那就是在 Task A 中 return 的返回值,會在 Task B 執行時傳給它。

我們還是先來看一個具體例子吧。

promise-then-passing-value.js
function doubleUp(value) {
    return value * 2;
}
function increment(value) {
    return value + 1;
}
function output(value) {
    console.log(value);// => (1 + 1) * 2
}

var promise = Promise.resolve(1);
promise
    .then(increment)
    .then(doubleUp)
    .then(output)
    .catch(function(error){
        // promise chain中出現異常的時候會被調用
        console.error(error);
    });

這段代碼的入口函數是 Promise.resolve(1); ,整體的promise chain執行流程如下所示。

  1. Promise.resolve(1); 傳遞 1 給 increment 函數

  2. 函數 increment 對接收的參數進行 +1 操作並返回(通過return

  3. 這時參數變爲2,並再次傳給 doubleUp 函數

  4. 最後在函數 output 中打印結果

promise-then-passing-value
Figure 5. promise-then-passing-value.js示意圖

每個方法中 return 的值不僅只侷限於字符串或者數值類型,也可以是對象或者promise對象等複雜類型。

return的值會由 Promise.resolve(return的返回值); 進行相應的包裝處理,因此不管回調函數中會返回一個什麼樣的值,最終 then的結果都是返回一個新創建的promise對象。

關於這部分內容可以參考 專欄: 每次調用then都會返回一個新創建的promise對象 ,那裏也對一些常見錯誤進行了介紹。

也就是說, Promise#then 不僅僅是註冊一個回調函數那麼簡單,它還會將回調函數的返回值進行變換,創建並返回一個promise對象。

2.5. Promise#catch

在 前面的Promise#then 的章節裏,我們已經簡單地使用了 Promise#catch 方法。

這裏我們再說一遍,實際上 Promise#catch 只是 promise.then(undefined, onRejected); 方法的一個別名而已。 也就是說,這個方法用來註冊當promise對象狀態變爲Rejected時的回調函數。

關於如何根據場景使用 Promise#then 和 Promise#catch 可以參考 then or catch? 中介紹的內容。

2.5.1. IE8的問題

Build Status

上面的這張圖,是下面這段代碼在使用 polyfill 的情況下在個瀏覽器上執行的結果。

polyfill是一個支持在不具備某一功能的瀏覽器上使用該功能的Library。 這裏我們使用的例子則來源於 jakearchibald/es6-promise 。

Promise#catch的運行結果
var promise = Promise.reject(new Error("message"));
promise.catch(function (error) {
    console.error(error);
});

如果我們在各種瀏覽器中執行這段代碼,那麼在IE8及以下版本則會出現 identifier not found 的語法錯誤。

這是怎麼回事呢? 實際上這和 catch 是ECMAScript的 保留字 (Reserved Word)有關。

在ECMAScript 3中保留字是不能作爲對象的屬性名使用的。 而IE8及以下版本都是基於ECMAScript 3實現的,因此不能將 catch 作爲屬性來使用,也就不能編寫類似 promise.catch() 的代碼,因此就出現了 identifier not found 這種語法錯誤了。

而現在的瀏覽器都是基於ECMAScript 5的,而在ECMAScript 5中保留字都屬於 IdentifierName ,也可以作爲屬性名使用了。

在ECMAScript5中保留字也不能作爲 Identifier 即變量名或方法名使用。 如果我們定義了一個名爲 for 的變量的話,那麼就不能和循環語句的 for 區分了。 而作爲屬性名的話,我們還是很容易區分 object.for 和 for 的,仔細想想我們就應該能接受將保留字作爲屬性名來使用了。

當然,我們也可以想辦法迴避這個ECMAScript 3保留字帶來的問題。

點標記法(dot notation) 要求對象的屬性必須是有效的標識符(在ECMAScript 3中則不能使用保留字),

但是使用 中括號標記法(bracket notation)的話,則可以將非合法標識符作爲對象的屬性名使用。

也就是說,上面的代碼如果像下面這樣重寫的話,就能在IE8及以下版本的瀏覽器中運行了(當然還需要polyfill)。

解決Promise#catch標識符衝突問題
var promise = Promise.reject(new Error("message"));
promise["catch"](function (error) {
    console.error(error);
});

或者我們不單純的使用 catch ,而是使用 then 也是可以避免這個問題的。

使用Promise#then代替Promise#catch
var promise = Promise.reject(new Error("message"));
promise.then(undefined, function (error) {
    console.error(error);
});

由於 catch 標識符可能會導致問題出現,因此一些類庫(Library)也採用了 caught 作爲函數名,而函數要完成的工作是一樣的。

而且很多壓縮工具自帶了將 promise.catch 轉換爲 promise["catch"] 的功能, 所以可能不經意之間也能幫我們解決這個問題。

如果各位讀者需要支持IE8及以下版本的瀏覽器的話,那麼一定要將這個 catch 問題牢記在心中。

2.6. 專欄: 每次調用then都會返回一個新創建的promise對象

從代碼上乍一看, aPromise.then(...).catch(...) 像是針對最初的 aPromise 對象進行了一連串的方法鏈調用。

然而實際上不管是 then 還是 catch 方法調用,都返回了一個新的promise對象。

下面我們就來看看如何確認這兩個方法返回的到底是不是新的promise對象。

var aPromise = new Promise(function (resolve) {
    resolve(100);
});
var thenPromise = aPromise.then(function (value) {
    console.log(value);
});
var catchPromise = thenPromise.catch(function (error) {
    console.error(error);
});
console.log(aPromise !== thenPromise); // => true
console.log(thenPromise !== catchPromise);// => true

=== 是嚴格相等比較運算符,我們可以看出這三個對象都是互不相同的,這也就證明了 then 和 catch 都返回了和調用者不同的promise對象。

Then Catch flow

我們在對Promise進行擴展的時候需要牢牢記住這一點,否則稍不留神就有可能對錯誤的promise對象進行了處理。

如果我們知道了 then 方法每次都會創建並返回一個新的promise對象的話,那麼我們就應該不難理解下面代碼中對 then 的使用方式上的差別了。

// 1: 對同一個promise對象同時調用 `then` 方法
var aPromise = new Promise(function (resolve) {
    resolve(100);
});
aPromise.then(function (value) {
    return value * 2;
});
aPromise.then(function (value) {
    return value * 2;
});
aPromise.then(function (value) {
    console.log("1: " + value); // => 100
})

// vs

// 2: 對 `then` 進行 promise chain 方式進行調用
var bPromise = new Promise(function (resolve) {
    resolve(100);
});
bPromise.then(function (value) {
    return value * 2;
}).then(function (value) {
    return value * 2;
}).then(function (value) {
    console.log("2: " + value); // => 100 * 2 * 2
});

第1種寫法中並沒有使用promise的方法鏈方式,這在Promise中是應該極力避免的寫法。這種寫法中的 then 調用幾乎是在同時開始執行的,而且傳給每個 then 方法的 value 值都是 100 。

第2中寫法則採用了方法鏈的方式將多個 then 方法調用串連在了一起,各函數也會嚴格按照 resolve → then → then → then 的順序執行,並且傳給每個 then 方法的 value 的值都是前一個promise對象通過 return 返回的值。

下面是一個由方法1中的 then 用法導致的比較容易出現的很有代表性的反模式的例子。

✘ then 的錯誤使用方法
function badAsyncCall() {
    var promise = Promise.resolve();
    promise.then(function() {
        // 任意處理
        return newVar;
    });
    return promise;
}

這種寫法有很多問題,首先在 promise.then 中產生的異常不會被外部捕獲,此外,也不能得到 then 的返回值,即使其有返回值。

由於每次 promise.then 調用都會返回一個新創建的promise對象,因此需要像上述方式2那樣,採用promise chain的方式將調用進行鏈式化,修改後的代碼如下所示。

then 返回返回新創建的promise對象
function anAsyncCall() {
    var promise = Promise.resolve();
    return promise.then(function() {
        // 任意處理
        return newVar;
    });
}

關於這些反模式,詳細內容可以參考 Promise Anti-patterns 。

這種函數的行爲貫穿在Promise整體之中, 包括我們後面要進行說明的 Promise.all 和 Promise.race ,他們都會接收一個promise對象爲參數,並返回一個和接收參數不同的、新的promise對象。

2.7. Promise和數組

到目前爲止我們已經學習瞭如何通過 .then 和 .catch 來註冊回調函數,這些回調函數會在promise對象變爲 FulFilled 或 Rejected 狀態之後被調用。

如果只有一個promise對象的話我們可以像前面介紹的那樣編寫代碼就可以了,如果要在多個promise對象都變爲FulFilled狀態的時候纔要進行某種處理話該如何操作呢?

我們以當所有XHR(異步處理)全部結束後要進行某操作爲例來進行說明。

各位讀者現在也許有點難以在大腦中描繪出這麼一種場景,我們可以先看一下下面使用了普通的回調函數風格的XHR處理代碼。

2.7.1. 通過回調方式來進行多個異步調用

multiple-xhr-callback.js
function getURLCallback(URL, callback) {
    var req = new XMLHttpRequest();
    req.open('GET', URL, true);
    req.onload = function () {
        if (req.status === 200) {
            callback(null, req.responseText);
        } else {
            callback(new Error(req.statusText), req.response);
        }
    };
    req.onerror = function () {
        callback(new Error(req.statusText));
    };
    req.send();
}
// <1> 對JSON數據進行安全的解析
function jsonParse(callback, error, value) {
    if (error) {
        callback(error, value);
    } else {
        try {
            var result = JSON.parse(value);
            callback(null, result);
        } catch (e) {
            callback(e, value);
        }
    }
}
// <2> 發送XHR請求
var request = {
        comment: function getComment(callback) {
            return getURLCallback('http://azu.github.io/promises-book/json/comment.json', jsonParse.bind(null, callback));
        },
        people: function getPeople(callback) {
            return getURLCallback('http://azu.github.io/promises-book/json/people.json', jsonParse.bind(null, callback));
        }
    };
// <3> 啓動多個XHR請求,當所有請求返回時調用callback
function allRequest(requests, callback, results) {
    if (requests.length === 0) {
        return callback(null, results);
    }
    var req = requests.shift();
    req(function (error, value) {
        if (error) {
            callback(error, value);
        } else {
            results.push(value);
            allRequest(requests, callback, results);
        }
    });
}
function main(callback) {
    allRequest([request.comment, request.people], callback, []);
}
// 運行的例子
main(function(error, results){
    if(error){
        return console.error(error);
    }
    console.log(results);
});

這段回調函數風格的代碼有以下幾個要點。

  • 直接使用 JSON.parse 函數的話可能會拋出異常,所以這裏使用了一個包裝函數 jsonParse

  • 如果將多個XHR處理進行嵌套調用的話層次會比較深,所以使用了 allRequest 函數並在其中對request進行調用。

  • 回調函數採用了 callback(error,value) 這種寫法,第一個參數表示錯誤信息,第二個參數爲返回值

在使用 jsonParse 函數的時候我們使用了 bind 進行綁定,通過使用這種偏函數(Partial Function)的方式就可以減少匿名函數的使用。(如果在函數回調風格的代碼能很好的做到函數分離的話,也能減少匿名函數的數量)

jsonParse.bind(null, callback);
// 可以認爲這種寫法能轉換爲以下的寫法
function bindJSONParse(error, value){
    jsonParse(callback, error, value);
}

在這段回調風格的代碼中,我們也能發現如下一些問題。

  • 需要顯示進行異常處理

  • 爲了不讓嵌套層次太深,需要一個對request進行處理的函數

  • 到處都是回調函數

下面我們再來看看如何使用 Promise#then 來完成同樣的工作。

2.7.2. 使用Promise#then同時處理多個異步請求

需要事先說明的是 Promise.all 比較適合這種應用場景的需求,因此我們故意採用了大量 .then 的晦澀的寫法。

使用了.then 的話,也並不是說能和回調風格完全一致,大概重寫後代碼如下所示。

multiple-xhr.js
function getURL(URL) {
    return new Promise(function (resolve, reject) {
        var req = new XMLHttpRequest();
        req.open('GET', URL, true);
        req.onload = function () {
            if (req.status === 200) {
                resolve(req.responseText);
            } else {
                reject(new Error(req.statusText));
            }
        };
        req.onerror = function () {
            reject(new Error(req.statusText));
        };
        req.send();
    });
}
var request = {
        comment: function getComment() {
            return getURL('http://azu.github.io/promises-book/json/comment.json').then(JSON.parse);
        },
        people: function getPeople() {
            return getURL('http://azu.github.io/promises-book/json/people.json').then(JSON.parse);
        }
    };
function main() {
    function recordValue(results, value) {
        results.push(value);
        return results;
    }
    // [] 用來保存初始化的值
    var pushValue = recordValue.bind(null, []);
    return request.comment().then(pushValue).then(request.people).then(pushValue);
}
// 運行的例子
main().then(function (value) {
    console.log(value);
}).catch(function(error){
    console.error(error);
});

將上述代碼和回調函數風格相比,我們可以得到如下結論。

  • 可以直接使用 JSON.parse 函數

  • 函數 main() 返回promise對象

  • 錯誤處理的地方直接對返回的promise對象進行處理

向前面我們說的那樣,main的 then 部分有點晦澀難懂。

爲了應對這種需要對多個異步調用進行統一處理的場景,Promise準備了 Promise.all 和 Promise.race 這兩個靜態方法。

在下面的小節中我們將對這兩個函數進行說明。

2.8. Promise.all

Promise.all 接收一個 promise對象的數組作爲參數,當這個數組裏的所有promise對象全部變爲resolve或reject狀態的時候,它纔會去調用 .then 方法。

前面我們看到的批量獲得若干XHR的請求結果的例子,使用 Promise.all 的話代碼會非常簡單。

之前例子中的 getURL 返回了一個promise對象,它封裝了XHR通信的實現。 向 Promise.all 傳遞一個由封裝了XHR通信的promise對象數組的話,則只有在全部的XHR通信完成之後(變爲FulFilled或Rejected狀態)之後,纔會調用 .then 方法。

promise-all-xhr.js
function getURL(URL) {
    return new Promise(function (resolve, reject) {
        var req = new XMLHttpRequest();
        req.open('GET', URL, true);
        req.onload = function () {
            if (req.status === 200) {
                resolve(req.responseText);
            } else {
                reject(new Error(req.statusText));
            }
        };
        req.onerror = function () {
            reject(new Error(req.statusText));
        };
        req.send();
    });
}
var request = {
        comment: function getComment() {
            return getURL('http://azu.github.io/promises-book/json/comment.json').then(JSON.parse);
        },
        people: function getPeople() {
            return getURL('http://azu.github.io/promises-book/json/people.json').then(JSON.parse);
        }
    };
function main() {
    return Promise.all([request.comment(), request.people()]);
}
// 運行示例
main().then(function (value) {
    console.log(value);
}).catch(function(error){
    console.log(error);
});

這個例子的執行方法和 前面的例子 一樣。 不過Promise.all 在以下幾點和之前的例子有所不同。

  • main中的處理流程顯得非常清晰

  • Promise.all 接收 promise對象組成的數組作爲參數

Promise.all([request.comment(), request.people()]);

在上面的代碼中,request.comment() 和 request.people() 會同時開始執行,而且每個promise的結果(resolve或reject時傳遞的參數值),和傳遞給 Promise.all 的promise數組的順序是一致的。

也就是說,這時候 .then 得到的promise數組的執行結果的順序是固定的,即 [comment, people]。

main().then(function (results) {
    console.log(results); // 按照[comment, people]的順序
});

如果像下面那樣使用一個計時器來計算一下程序執行時間的話,那麼就可以非常清楚的知道傳遞給 Promise.all 的promise數組是同時開始執行的。

promise-all-timer.js
// `delay`毫秒後執行resolve
function timerPromisefy(delay) {
    return new Promise(function (resolve) {
        setTimeout(function () {
            resolve(delay);
        }, delay);
    });
}
var startDate = Date.now();
// 所有promise變爲resolve後程序退出
Promise.all([
    timerPromisefy(1),
    timerPromisefy(32),
    timerPromisefy(64),
    timerPromisefy(128)
]).then(function (values) {
    console.log(Date.now() - startDate + 'ms');
    // 約128ms
    console.log(values);    // [1,32,64,128]
});

timerPromisefy 會每隔一定時間(通過參數指定)之後,返回一個promise對象,狀態爲FulFilled,其狀態值爲傳給 timerPromisefy的參數。

而傳給 Promise.all 的則是由上述promise組成的數組。

var promises = [
    timerPromisefy(1),
    timerPromisefy(32),
    timerPromisefy(64),
    timerPromisefy(128)
];

這時候,每隔1, 32, 64, 128 ms都會有一個promise發生 resolve 行爲。

也就是說,這個promise對象數組中所有promise都變爲resolve狀態的話,至少需要128ms。實際我們計算一下Promise.all 的執行時間的話,它確實是消耗了128ms的時間。

從上述結果可以看出,傳遞給 Promise.all 的promise並不是一個個的順序執行的,而是同時開始、並行執行的。

如果這些promise全部串行處理的話,那麼需要 等待1ms → 等待32ms → 等待64ms → 等待128ms ,全部執行完畢需要225ms的時間。

要想了解更多關於如何使用Promise進行串行處理的內容,可以參考第4章的Promise中的串行處理中的介紹。

2.9. Promise.race

接着我們來看看和 Promise.all 類似的對多個promise對象進行處理的 Promise.race 方法。

它的使用方法和Promise.all一樣,接收一個promise對象數組爲參數。

Promise.all 在接收到的所有的對象promise都變爲 FulFilled 或者 Rejected 狀態之後纔會繼續進行後面的處理, 與之相對的是 Promise.race 只要有一個promise對象進入 FulFilled 或者 Rejected 狀態的話,就會繼續進行後面的處理。

像Promise.all時的例子一樣,我們來看一個帶計時器的 Promise.race 的使用例子。

promise-race-timer.js
// `delay`毫秒後執行resolve
function timerPromisefy(delay) {
    return new Promise(function (resolve) {
        setTimeout(function () {
            resolve(delay);
        }, delay);
    });
}
// 任何一個promise變爲resolve或reject 的話程序就停止運行
Promise.race([
    timerPromisefy(1),
    timerPromisefy(32),
    timerPromisefy(64),
    timerPromisefy(128)
]).then(function (value) {
    console.log(value);    // => 1
});

上面的代碼創建了4個promise對象,這些promise對象會分別在1ms,32ms,64ms和128ms後變爲確定狀態,即FulFilled,並且在第一個變爲確定狀態的1ms後, .then 註冊的回調函數就會被調用,這時候確定狀態的promise對象會調用 resolve(1) 因此傳遞給 value的值也是1,控制檯上會打印出1來。

下面我們再來看看在第一個promise對象變爲確定(FulFilled)狀態後,它之後的promise對象是否還在繼續運行。

promise-race-other.js
var winnerPromise = new Promise(function (resolve) {
        setTimeout(function () {
            console.log('this is winner');
            resolve('this is winner');
        }, 4);
    });
var loserPromise = new Promise(function (resolve) {
        setTimeout(function () {
            console.log('this is loser');
            resolve('this is loser');
        }, 1000);
    });
// 第一個promise變爲resolve後程序停止
Promise.race([winnerPromise, loserPromise]).then(function (value) {
    console.log(value);    // => 'this is winner'
});

我們在前面代碼的基礎上增加了 console.log 用來輸出調試信息。

執行上面代碼的話,我們會看到 winnter和loser promise對象的 setTimeout 方法都會執行完畢, console.log 也會分別輸出它們的信息。

也就是說, Promise.race 在第一個promise對象變爲Fulfilled之後,並不會取消其他promise對象的執行。

在 ES6 Promises 規範中,也沒有取消(中斷)promise對象執行的概念,我們必須要確保promise最終進入resolve or reject狀態之一。也就是說Promise並不適用於 狀態 可能會固定不變的處理。也有一些類庫提供了對promise進行取消的操作。

2.10. then or catch?

在 上一章 裏,我們說過 .catch 也可以理解爲 promise.then(undefined, onRejected) 。

在本書裏我們還是會將 .catch 和 .then 分開使用來進行錯誤處理的。

此外我們也會學習一下,在 .then 裏同時指定處理對錯誤進行處理的函數相比,和使用 catch 又有什麼異同。

2.10.1. 不能進行錯誤處理的onRejected

我們看看下面的這段代碼。

then-throw-error.js
function throwError(value) {
    // 拋出異常
    throw new Error(value);
}
// <1> onRejected不會被調用
function badMain(onRejected) {
    return Promise.resolve(42).then(throwError, onRejected);
}
// <2> 有異常發生時onRejected會被調用
function goodMain(onRejected) {
    return Promise.resolve(42).then(throwError).catch(onRejected);
}
// 運行示例
badMain(function(){
    console.log("BAD");
});
goodMain(function(){
    console.log("GOOD");
});

在上面的代碼中, badMain 是一個不太好的實現方式(但也不是說它有多壞), goodMain 則是一個能非常好的進行錯誤處理的版本。

爲什麼說 badMain 不好呢?,因爲雖然我們在 .then 的第二個參數中指定了用來錯誤處理的函數,但實際上它卻不能捕獲第一個參數 onFulfilled 指定的函數(本例爲 throwError )裏面出現的錯誤。

也就是說,這時候即使 throwError 拋出了異常,onRejected 指定的函數也不會被調用(即不會輸出"BAD"字樣)。

與此相對的是, goodMain 的代碼則遵循了 throwErroronRejected 的調用流程。 這時候 throwError 中出現異常的話,在會被方法鏈中的下一個方法,即 .catch 所捕獲,進行相應的錯誤處理。

.then 方法中的onRejected參數所指定的回調函數,實際上針對的是其promise對象或者之前的promise對象,而不是針對 .then 方法裏面指定的第一個參數,即onFulfilled所指向的對象,這也是 then 和 catch 表現不同的原因。

.then 和 .catch 都會創建並返回一個 新的 promise對象。 Promise實際上每次在方法鏈中增加一次處理的時候所操作的都不是完全相同的promise對象。

Then Catch flow
Figure 6. Then Catch flow

這種情況下 then 是針對 Promise.resolve(42) 的處理,在onFulfilled 中發生異常,在同一個 then 方法中指定的 onRejected 也不能捕獲該異常。

在這個 then 中發生的異常,只有在該方法鏈後面出現的 catch 方法才能捕獲。

當然,由於 .catch 方法是 .then 的別名,我們使用 .then 也能完成同樣的工作。只不過使用 .catch 的話意圖更明確,更容易理解。

Promise.resolve(42).then(throwError).then(null, onRejected);

2.10.2. 總結

這裏我們又學習到了如下一些內容。

  1. 使用promise.then(onFulfilled, onRejected) 的話

    • 在 onFulfilled 中發生異常的話,在 onRejected 中是捕獲不到這個異常的。

  2. 在 promise.then(onFulfilled).catch(onRejected) 的情況下

    • then 中產生的異常能在 .catch 中捕獲

  3. .then 和 .catch 在本質上是沒有區別的

    • 需要分場合使用。

我們需要注意如果代碼類似 badMain 那樣的話,就可能出現程序不會按預期運行的情況,從而不能正確的進行錯誤處理。

3. Chapter.3 - Promise測試

這章我們學習如果編寫Promise 的測試代碼

3.1. 基本測試

關於ES6 Promises的語法我們已經學了一些, 我想大家應該也能夠在實際項目中編寫Promise 的Demo代碼了吧。

這時,接下來你可能要苦惱該如何編寫Promise 的測試代碼了。

那麼讓我們先來學習下如何使用 Mocha來對Promise 進行基本的測試吧。

先聲明一下,這章中涉及的測試代碼都是運行在Node.js環境下的。

本書中出現的示例代碼也都有相應的測試代碼。 測試代碼可以參考 azu/promises-book 。

3.1.1. Mocha

Mocha是Node.js下的測試框架工具,在這裏,我們並不打算對 Mocha本身進行詳細講解。對 Mocha感興趣的讀者可以自行學習。

Mocha可以自由選擇BDD、TDD、exports中的任意風格,測試中用到的Assert 方法也同樣可以跟任何其他類庫組合使用。 也就是說,Mocha本身只提供執行測試時的框架,而其他部分則由使用者自己選擇。

這裏我們選擇使用Mocha,主要基於下面3點理由。

  • 它是非常著名的測試框架

  • 支持基於Node.js 和瀏覽器的測試

  • 支持"Promise測試"

最後至於爲什麼說 支持"Promise測試" ,這個我們在後面再講。

要想在本章中使用Mocha,我們需要先通過npm來安裝Mocha。

$ npm install -g mocha

另外,Assert庫我們使用的是Node.js自帶的assert模塊,所以不需要額外安裝。

首先,讓我們試着編寫一個對傳統回調風格的異步函數進行測試的代碼。

3.1.2. 回調函數風格的測試

如果想使用回調函數風格來對一個異步處理進行測試,使用Mocha的話代碼如下所示。

basic-test.js
var assert = require('power-assert');
describe('Basic Test', function () {
    context('When Callback(high-order function)', function () {
        it('should use `done` for test', function (done) {
            setTimeout(function () {
                assert(true);
                done();
            }, 0);
        });
    });
    context('When promise object', function () {
        it('should use `done` for test?', function (done) {
            var promise = Promise.resolve(1);
            // このテストコードはある欠陥があります
            promise.then(function (value) {
                assert(value === 1);
                done();
            });
        });
    });
});

將這段代碼保存爲 basic-test.js,之後就可以使用剛纔安裝的Mocha的命令行工具進行測試了。

$ mocha basic-test.js

Mocha的 it 方法指定了 done 參數,在 done() 函數被執行之前, 該測試一直處於等待狀態,這樣就可以對異步處理進行測試。

Mocha中的異步測試,將會按照下面的步驟執行。

it("should use `done` for test", function (done) {
    
    setTimeout(function () {
        assert(true);
        done();
    }, 0);
});
回調式的異步處理
調用done 後測試結束

這也是一種非常常見的實現方式。

3.1.3. 使用done 的Promise測試

接下來,讓我們看看如何使用 done 來進行Promise測試。

it("should use `done` for test?", function (done) {
    var promise = Promise.resolve(42);
    promise.then(function (value) {
        assert(value === 42);
        done();
    });
});
創建名爲Fulfilled 的promise對象
調用done 後測試結束

Promise.resolve 用來返回promise對象, 返回的promise對象狀態爲FulFilled。 最後,通過 .then 設置的回調函數也會被調用。

專欄: Promise只能進行異步操作? 中已經提到的那樣, promise對象的調用總是異步進行的,所以測試也同樣需要以異步調用的方式來編寫。

但是,在前面的測試代碼中,在assert 失敗的情況下就會出現問題。

對異常promise測試
it("should use `done` for test?", function (done) {
    var promise = Promise.resolve();
    promise.then(function (value) {
        assert(false);// => throw AssertionError
        done();
    });
});

在此次測試中 assert 失敗了,所以你可能認爲應該拋出“測試失敗”的錯誤, 而實際情況卻是測試並不會結束,直到超時。

promise test timeout
Figure 7. 由於測試不會結束,所以直到發生超時時間未知,一直會處於掛起狀態。

通常情況下,assert 失敗的時候,會throw一個error, 測試框架會捕獲該error,來判斷測試失敗。

但是,Promise的情況下 .then 綁定的函數執行時發生的error 會被Promise捕獲,而測試框架則對此error將會一無所知。

我們來改善一下assert 失敗的promise測試, 讓它能正確處理 assert 失敗時的測試結果。

測試正常失敗的示例
it("should use `done` for test?", function (done) {
    var promise = Promise.resolve();
    promise.then(function (value) {
        assert(false);
    }).then(done, done);
});

在上面測試正常失敗的示例中,爲了確保 done 一定會被調用, 我們在最後添加了 .then(done, done); 語句。

assert 測試通過(成功)時會調用 done() ,而 assert 失敗時則調用 done(error) 。

這樣,我們就編寫出了和 回調函數風格的測試 相同的Promise測試。

但是,爲了處理 assert 失敗的情況,我們需要額外添加 .then(done, done); 的代碼。 這就要求我們在編寫Promise測試時要格外小心,忘了加上上面語句的話,很可能就會寫出一個永遠不會返回直到超時的測試代碼。

在下一節,讓我們接着學習一下最初提到的使用Mocha理由中的支持"Promises測試"究竟是一種什麼機制。

3.2. Mocha對Promise的支持

在這裏,我們將會學習什麼是Mocha支持的“對Promise測試”。

官方網站 Asynchronous code 也記載了關於Promise測試的概要。

Alternately, instead of using the done() callback, you can return a promise. This is useful if the APIs you are testing return promises instead of taking callbacks:

這段話的意思是,在對Promise進行測試的時候,不使用 done() 這樣的回調風格的代碼編寫方式,而是返回一個promise對象。

那麼實際上代碼將會是什麼樣的呢?這裏我們來看個具體的例子應該容易理解了。

mocha-promise-test.js
var assert = require('power-assert');
describe('Promise Test', function () {
    it('should return a promise object', function () {
        var promise = Promise.resolve(1);
        return promise.then(function (value) {
            assert(value === 1);
        });
    });
});

這段代碼將前面 前面使用 done 的例子 按照Mocha的Promise測試方式進行了重寫。

修改的地方主要在以下兩點:

  • 刪除了 done

  • 返回結果爲promise對象

採用這種寫法的話,當 assert 失敗的時候,測試本身自然也會失敗。

it("should be fail", function () {
    return Promise.resolve().then(function () {
        assert(false);// => 測試失敗
    });
});

採用這種方法,就能從根本上省略諸如 .then(done, done); 這樣本質上跟測試邏輯並無直接關係的代碼。

Mocha已經支持對Promises的測試 | Web scratch 這篇(日語)文章裏也提到了關於Mocha對Promise測試的支持。

3.2.1. 意料之外(失敗的)的測試結果

因爲Mocha提供了對Promise的測試,所以我們會認爲按照Mocha的規則來寫會比較好。 但是這種代碼可能會帶來意想不到的異常情況的發生。

比如對下面的mayBeRejected() 函數的測試代碼,該函數返回一個當滿足某一條件就變爲Rejected的promise對象。

想對Error Object進行測試
function mayBeRejected(){ 
    return Promise.reject(new Error("woo"));
}
it("is bad pattern", function () {
    return mayBeRejected().catch(function (error) {
        assert(error.message === "woo");
    });
});
這個函數用來對返回的promise對象進行測試

這個測試的目的包括以下兩點:

mayBeRejected() 返回的promise對象如果變爲FulFilled狀態的話

測試將會失敗

mayBeRejected() 返回的promise對象如果變爲Rejected狀態的話

在 assert 中對Error對象進行檢查

上面的測試代碼,當promise對象變爲Rejected的時候,會調用在 onRejected 中註冊的函數,從而沒有走正promise的處理常流程,測試會成功。

這段測試代碼的問題在於當mayBeRejected() 返回的是一個 爲FulFilled狀態的promise對象時,測試會一直成功。

function mayBeRejected(){ 
    return Promise.resolve();
}
it("is bad pattern", function () {
    return mayBeRejected().catch(function (error) {
        assert(error.message === "woo");
    });
});
返回的promise對象會變爲FulFilled

在這種情況下,由於在 catch 中註冊的 onRejected 函數並不會被調用,因此 assert 也不會被執行,測試會一直通過(passed,成功)。

爲了解決這個問題,我們可以在 .catch 的前面加入一個 .then 調用,可以理解爲如果調用了 .then 的話,那麼測試就需要失敗。

function failTest() { 
    throw new Error("Expected promise to be rejected but it was fulfilled");
}
function mayBeRejected(){
    return Promise.resolve();
}
it("should bad pattern", function () {
    return mayBeRejected().then(failTest).catch(function (error) {
        assert.deepEqual(error.message === "woo");
    });
});
通過throw來使測試失敗

但是,這種寫法會像在前面 then or catch? 中已經介紹的一樣, failTest 拋出的異常會被 catch 捕獲。

Then Catch flow
Figure 8. Then Catch flow

程序的執行流程爲 then → catch,傳遞給 catch 的Error對象爲AssertionError類型 , 這並不是我們想要的東西。

也就是說,我們希望測試只能通過狀態會變爲onRejected的promise對象, 如果promise對象狀態爲onFulfilled狀態的話,那麼該測試就會一直通過。

3.2.2. 明確兩種狀態,改善測試中的意外(異常)狀況

在編寫 上面對Error對象進行測試的例子 時, 怎麼才能剔除那些會意外通過測試的情況呢?

最簡單的方式就是像下面這樣,在測試代碼中判斷在各種promise對象的狀態下,應進行如何的操作。

變爲FulFilled狀態的時候

測試會預期失敗

變爲Rejected狀態的時候

使用 assert 進行測試

也就是說,我們需要在測試代碼中明確指定在Fulfilled和Rejected這兩種狀態下,都需進行什麼樣的處理。

function mayBeRejected() {
    return Promise.resolve();
}
it("catch -> then", function () {
    // 變爲FulFilled的時候測試失敗
    return mayBeRejected().then(failTest, function (error) {
        assert(error.message === "woo");
    });
});

像這樣的話,就能在promise變爲FulFilled的時候編寫出失敗用的測試代碼了。

Promise onRejected test
Figure 9. Promise onRejected test

在 then or catch? 中我們已經講過,爲了避免遺漏對錯誤的處理, 與使用 .then(onFulfilled, onRejected) 這樣帶有二個參數的調用形式相比, 我們更推薦使用 then → catch 這樣的處理方式。

但是在編寫測試代碼的時候,Promise強大的錯誤處理機制反而成了限制我們的障礙。 因此我們不得已採取了 .then(failTest, onRejected) 這種寫法,明確指定promise在各種狀態下進行何種的處理。

3.2.3. 總結

在本小節中我們對在使用Mocha進行Promise測試時可能出現的一些意外情況進行了介紹。

  • 普通的代碼採用 then → catch 的流程的話比較容易理解

  • 將測試代碼集中到 then 中處理

    • 爲了能將AssertionError對象傳遞到測試框架中。

通過使用 .then(onFulfilled, onRejected) 這種形式的寫法, 我們可以明確指定promise對象在變爲 Fulfilled或Rejected時如何進行處理。

但是,由於需要顯示的指定 Rejected時的測試處理, 像下面這樣的代碼看起來總是有一些讓人感到不太直觀的感覺。

promise.then(failTest, function(error){
    // 使用assert對error進行測試
});

在下一小節,我們會介紹如何編寫helper函數以方便編寫Promise的測試代碼, 以及怎樣去編寫更容易理解的測試代碼。

3.3. 編寫可控測試(controllable tests)

在繼續進行說明之前,我們先來定義一下什麼是可控測試。在這裏我們對可控測試的定義如下。

待測試的promise對象

  • 如果編寫預期爲Fulfilled狀態的測試的話

    • Rejected的時候要 Fail

    • assertion 的結果不一致的時候要 Fail

  • 如果預期爲Rejected狀態的話

    • 結果爲Fulfilled 測試爲 Fail

    • assertion 的結果不一致的時候要 Fail

如果一個測試能網羅上面的用例(Fail)項,那麼我們就稱其爲可控測試。

也就是說,一個測試用例應該包括下面的測試內容。

  • 結果滿足 Fulfilled or Rejected 之一

  • 對傳遞給assertion的值進行檢查

在前面使用了 .then 的代碼就是一個期望結果爲 Rejected 的測試。

promise.then(failTest, function(error){
    // 通過assert驗證error對象
    assert(error instanceof Error);
});

3.3.1. 必須明確指定轉換後的狀態

爲了編寫有效的測試代碼, 我們需要明確指定 promise的狀態 爲 Fulfilled or Rejected 的兩者之一。

但是由於 .then 的話在調用的時候可以省略參數,有時候可能會忘記加入使測試失敗的條件。

因此,我們可以定義一個helper函數,用來明確定義promise期望的狀態。

筆者(原著者)創建了一個類庫 azu/promise-test-helper 以方便對Promise進行測試,本文中使用的是這個類庫的簡略版。

首先我們創建一個名爲 shouldRejected 的helper函數,用來在剛纔的 .then 的例子中,期待測試返回狀態爲 onRejected 的結果的例子。

shouldRejected-test.js
var assert = require('power-assert');
function shouldRejected(promise) {
    return {
        'catch': function (fn) {
            return promise.then(function () {
                throw new Error('Expected promise to be rejected but it was fulfilled');
            }, function (reason) {
                fn.call(promise, reason);
            });
        }
    };
}
it('should be rejected', function () {
    var promise = Promise.reject(new Error('human error'));
    return shouldRejected(promise).catch(function (error) {
        assert(error.message === 'human error');
    });
});

shouldRejected 函數接收一個promise對象作爲參數,並且返回一個帶有 catch 方法的對象。

在這個 catch 中可以使用和 onRejected 裏一樣的代碼,因此我們可以在 catch 使用基於 assertion 方法的測試代碼。

在 shouldRejected 外部,都是類似如下、和普通的promise處理大同小異的代碼。

  1. 將需要測試的promise對象傳遞給 shouldRejected 方法

  2. 在返回的對象的 catch 方法中編寫進行onRejected處理的代碼

  3. 在onRejected裏使用assertion進行判斷

在使用 shouldRejected 函數的時候,如果是 Fulfilled 被調用了的話,則會throw一個異常,測試也會失敗。

promise.then(failTest, function(error){
    assert(error.message === 'human error');
});
// == 幾乎這兩段代碼是同樣的意思
shouldRejected(promise).catch(function (error) {
    assert(error.message === 'human error');
});

使用 shouldRejected 這樣的helper函數,測試代碼也會變得很直觀。

Promise onRejected test
Figure 10. Promise onRejected test

像上面一樣,我們也可以編寫一個測試promise對象期待結果爲Fulfilled的 shouldFulfilled helper函數。

shouldFulfilled-test.js
var assert = require('power-assert');
function shouldFulfilled(promise) {
    return {
        'then': function (fn) {
            return promise.then(function (value) {
                fn.call(promise, value);
            }, function (reason) {
                throw reason;
            });
        }
    };
}
it('should be fulfilled', function () {
    var promise = Promise.resolve('value');
    return shouldFulfilled(promise).then(function (value) {
        assert(value === 'value');
    });
});

這和上面的 shouldRejected-test.js 結構基本相同,只不過返回對象的 catch 方法變爲了 then ,promise.then的兩個參數也調換了。

3.3.2. 小結

在本小節我們學習瞭如何編寫針對Promise特定狀態的測試代碼,以及如何使用便於測試的helper函數。

這裏我們使用到的 shouldFulfilled 和 shouldRejected 也可以在下面的類庫中找到。

此外,本小節中的helper方法都是以 Mocha對Promise的支持 爲前提的, 在 基於done 的測試 中使用的話可能會比較麻煩。

是使用基於測試框架對Promis的支持,還是使用基於類似done 這樣回調風格的測試方式,每個人都可以自由的選擇,只是風格問題,我覺得倒沒必要去爭一個孰優孰劣。

比如在 CoffeeScript下進行測試的話,由於CoffeeScript 會隱式的使用return返回,所以使用 done 的話可能更容易理解一些。

對Promise進行測試比對通常的異步函數進行測試坑更多,雖說採取什麼樣的測試方法是個人的自由,但是在同一項目中採取前後風格一致的測試則是非常重要。

4. Chapter.4 - Advanced

在這一章裏,我們會基於前面學到的內容,再深入瞭解一下Promise裏的一些高級內容,加深對Promise的理解。

4.1. Promise的實現類庫(Library)

在本小節裏,我們將不打算對瀏覽器實現的Promise進行說明,而是要介紹一些第三方實現的和Promise兼容的類庫。

4.1.1. 爲什麼需要這些類庫?

爲什麼需要這些類庫呢?我想有些讀者不免會有此疑問。首先能想到的原因是有些運行環境並不支持 ES6 Promises 。

當我們在網上查找Promise的實現類庫的時候,有一個因素是首先要考慮的,那就是是否具有 Promises/A+兼容性 。

Promises/A+ 是 ES6 Promises 的前身,Promise的 then 也是來自於此的基於社區的規範。

如果說一個類庫兼容 Promises/A+ 的話,那麼就是說它除了具有標準的 then 方法之外,很多情況下也說明此類庫還支持 Promise.all和 catch 等功能。

但是 Promises/A+ 實際上只是定義了關於 Promise#then 的規範,所以有些類庫可能實現了其它諸如 all 或 catch 等功能,但是可能名字卻不一樣。

如果我們說一個類庫具有 then 兼容性的話,實際上指的是 Thenable ,它通過使用 Promise.resolve 基於ES6 Promise的規定,進行promise對象的變換。

ES6 Promise 裏關於promise對象的規定包括在使用 catch 方法,或使用 Promise.all 進行處理的時候不能出現錯誤。

4.1.2. Polyfill和擴展類庫

在這些Promise的實現類庫中,我們這裏主要對兩種類型的類庫進行介紹。

一種是被稱爲 Polyfill (這是一款英國產品,就是裝修刮牆用的膩子,其意義可想而知 — 譯者注)的類庫,另一種是即具有 Promises/A+兼容性 ,又增加了自己獨特功能的類庫。

Promise的實現類庫數量非常之多,這裏我們只是介紹了其中有限的幾個。
Polyfill

只需要在瀏覽器中加載Polyfill類庫,就能使用IE10等或者還沒有提供對Promise支持的瀏覽器中使用Promise裏規定的方法。

也就是說如果加載了Polyfill類庫,就能在還不支持Promise的環境中,運行本文中的各種示例代碼。

jakearchibald/es6-promise

一個兼容 ES6 Promises 的Polyfill類庫。 它基於 RSVP.js 這個兼容 Promises/A+ 的類庫, 它只是 RSVP.js 的一個子集,只實現了Promises 規定的 API。

yahoo/ypromise

這是一個獨立版本的 YUI 的 Promise Polyfill,具有和 ES6 Promises 的兼容性。 本書的示例代碼也都是基於這個 ypromise 的 Polyfill 來在線運行的。

getify/native-promise-only

以作爲ES6 Promises的polyfill爲目的的類庫 它嚴格按照ES6 Promises的規範設計,沒有添加在規範中沒有定義的功能。 如果運行環境有原生的Promise支持的話,則優先使用原生的Promise支持。

Promise擴展類庫

Promise擴展類庫除了實現了Promise中定義的規範之外,還增加了自己獨自定義的功能。

Promise擴展類庫數量非常的多,我們只介紹其中兩個比較有名的類庫。

kriskowal/q

類庫 Q 實現了 Promises 和 Deferreds 等規範。 它自2009年開始開發,還提供了面向Node.js的文件IO API Q-IO 等, 是一個在很多場景下都能用得到的類庫。

petkaantonov/bluebird

這個類庫除了兼容 Promise 規範之外,還擴展了取消promise對象的運行,取得promise的運行進度,以及錯誤處理的擴展檢測等非常豐富的功能,此外它在實現上還在性能問題下了很大的功夫。

Q 和 Bluebird 這兩個類庫除了都能在瀏覽器裏運行之外,充實的API reference也是其特徵。

Q等文檔裏詳細介紹了Q的Deferred和jQuery裏的Deferred有哪些異同,以及要怎麼進行遷移 Coming from jQuery 等都進行了詳細的說明。

Bluebird的文檔除了提供了使用Promise豐富的實現方式之外,還涉及到了在出現錯誤時的對應方法以及 Promise中的反模式 等內容。

這兩個類庫的文檔寫得都很友好,即使我們不使用這兩個類庫,閱讀一下它們的文檔也具有一定的參考價值。

4.1.3. 總結

本小節介紹了Promise的實現類庫中的 Polyfill 和擴展類庫這兩種。

Promise的實現類庫種類繁多,到底選擇哪個來使用完全看自己的喜好了。

但是由於這些類庫實現的 Promise 同時具有 Promises/A+ 或 ES6 Promises 共通的接口,所以在使用某一類庫的時候,有時候也可以參考一下其他類庫的代碼或者擴展功能。

熟練掌握Promise中的共通概念,進而能在實際中能對這些技術運用自如,這也是本書的寫作目的之一。

4.2. Promise.resolve和Thenable

在 第二章的Promise.resolve 中我們已經說過, Promise.resolve 的最大特徵之一就是可以將thenable的對象轉換爲promise對象。

在本小節裏,我們將學習一下利用將thenable對象轉換爲promise對象這個功能都能具體做些什麼事情。

4.2.1. 將Web Notifications轉換爲thenable對象

這裏我們以桌面通知 API Web Notifications 爲例進行說明。

關於Web Notifications API的詳細信息可以參考下面的網址。

簡單來說,Web Notifications API就是能像以下代碼那樣通過 new Notification 來顯示通知消息。

new Notification("Hi!");

當然,爲了顯示通知消息,我們需要在運行 new Notification 之前,先獲得用戶的許可。

確認是否允許Notification的對話框
Figure 11. 確認是否允許Notification的對話框

用戶在這個是否允許Notification的對話框選擇後的結果,會通過 Notification.permission 傳給我們的程序,它的值可能是允許("granted")或拒絕("denied")這二者之一。

是否允許Notification對話框中的可選項,在Firefox中除了允許、拒絕之外,還增加了 永久有效 和 會話範圍內有效 兩種額外選項,當然 Notification.permission 的值都是一樣的。

在程序中可以通過 Notification.requestPermission() 來彈出是否允許Notification對話框, 用戶選擇的結果會通過 status 參數傳給回調函數。

從這個回調函數我們也可以看出來,用戶選擇允許還是拒絕通知是異步進行的。

Notification.requestPermission(function (status) {
    // status的值爲 "granted" 或 "denied"
    console.log(status);
});

到用戶收到並顯示通知爲止,整體的處理流程如下所示。

  • 顯示是否允許通知的對話框,並異步處理用戶選擇結果

  • 如果用戶允許的話,則通過 new Notification 顯示通知消息。這又分兩種情況

    • 用戶之前已經允許過

    • 當場彈出是否允許桌面通知對話框

  • 當用戶不允許的時候,不執行任何操作

雖然上面說到了幾種情景,但是最終結果就是用戶允許或者拒絕,可以總結爲如下兩種模式。

允許時("granted")

使用 new Notification 創建通知消息

拒絕時("denied")

沒有任何操作

這兩種模式是不是覺得有在哪裏看過的感覺? 呵呵,用戶的選擇結果,正和在Promise中promise對象變爲 Fulfilled 或 Rejected 狀態非常類似。

resolve(成功)時 == 用戶允許("granted")

調用 onFulfilled 方法

reject(失敗)時 == 用戶拒絕("denied")

調用 onRejected 函數

是不是我們可以用Promise的方式去編寫桌面通知的代碼呢?我們先從回調函數風格的代碼入手看看到底怎麼去做。

4.2.2. Web Notification 包裝函數(wrapper)

首先,我們以回到函數風格的代碼對上面的Web Notification API包裝函數進行重寫,新代碼如下所示。

notification-callback.js
function notifyMessage(message, options, callback) {
    if (Notification && Notification.permission === 'granted') {
        var notification = new Notification(message, options);
        callback(null, notification);
    } else if (Notification.requestPermission) {
        Notification.requestPermission(function (status) {
            if (Notification.permission !== status) {
                Notification.permission = status;
            }
            if (status === 'granted') {
                var notification = new Notification(message, options);
                callback(null, notification);
            } else {
                callback(new Error('user denied'));
            }
        });
    } else {
        callback(new Error('doesn\'t support Notification API'));
    }
}
// 運行實例
// 第二個參數是傳給 `Notification` 的option對象
notifyMessage("Hi!", {}, function (error, notification) {
    if(error){
        return console.error(error);
    }
    console.log(notification);// 通知對象
});

在回調風格的代碼裏,當用戶拒絕接收通知的時候, error 會被設置值,而如果用戶同意接收通知的時候,則會顯示通知消息並且 notification 會被設置值。

回調函數接收error和notification兩個參數
function callback(error, notification){

}

下面,我想再將這個回調函數風格的代碼使用Promise進行改寫。

4.2.3. Web Notification as Promise

基於上述回調風格的 notifyMessage 函數,我們再來創建一個返回promise對象的 notifyMessageAsPromise 方法。

notification-as-promise.js
function notifyMessage(message, options, callback) {
    if (Notification && Notification.permission === 'granted') {
        var notification = new Notification(message, options);
        callback(null, notification);
    } else if (Notification.requestPermission) {
        Notification.requestPermission(function (status) {
            if (Notification.permission !== status) {
                Notification.permission = status;
            }
            if (status === 'granted') {
                var notification = new Notification(message, options);
                callback(null, notification);
            } else {
                callback(new Error('user denied'));
            }
        });
    } else {
        callback(new Error('doesn\'t support Notification API'));
    }
}
function notifyMessageAsPromise(message, options) {
    return new Promise(function (resolve, reject) {
        notifyMessage(message, options, function (error, notification) {
            if (error) {
                reject(error);
            } else {
                resolve(notification);
            }
        });
    });
}
// 運行示例
notifyMessageAsPromise("Hi!").then(function (notification) {
    console.log(notification);// 通知對象
}).catch(function(error){
    console.error(error);
});

在用戶允許接收通知的時候,運行上面的代碼,會顯示 "Hi!" 消息。

當用戶接收通知消息的時候, .then 函數會被調用,當用戶拒絕接收消息的時候, .catch 方法會被調用。

由於瀏覽器是以網站爲單位保存Web Notifications API的許可狀態的,所以實際上有下面四種模式存在。

已經獲得用戶許可

.then 方法被調用

彈出詢問對話框並獲得許可

.then 方法被調用

已經是被用戶拒絕的狀態

.catch 方法被調用

彈出詢問對話框並被用戶拒絕

.catch 方法被調用

也就是說,如果使用原生的Web Notifications API的話,那麼需要在程序中對上述四種情況都進行處理,我們可以像下面的包裝函數那樣,將上述四種情況簡化爲兩種以方便處理。

上面的 notification-as-promise.js 雖然看上去很方便,但是實際上使用的時候,很可能出現 在不支持Promise的環境下不能使用 的問題。

如果你想編寫像notification-as-promise.js這樣具有Promise風格和的類庫的話,我覺得你有如下的一些選擇。

支持Promise的環境是前提
  • 需要最終用戶保證支持Promise

  • 在不支持Promise的環境下不能正常工作(即應該出錯)。

在類庫中實現Promise
  • 在類庫中實現Promise功能

  • 例如) localForage

在回調函數中也應該能夠使用 Promise
  • 用戶可以選擇合適的使用方式

  • 返回Thenable類型

notification-as-promise.js就是以Promise存在爲前提的寫法。

迴歸正文,在這裏Thenable是爲了幫助實現在回調函數中也能使用Promise的一個概念。

4.2.4. Web Notifications As Thenable

我們已經說過,thenable就是一個具有 .then方法的一個對象。下面我們就在notification-callback.js中增加一個返回值爲 thenable 類型的方法。

notification-thenable.js
function notifyMessage(message, options, callback) {
    if (Notification && Notification.permission === 'granted') {
        var notification = new Notification(message, options);
        callback(null, notification);
    } else if (Notification.requestPermission) {
        Notification.requestPermission(function (status) {
            if (Notification.permission !== status) {
                Notification.permission = status;
            }
            if (status === 'granted') {
                var notification = new Notification(message, options);
                callback(null, notification);
            } else {
                callback(new Error('user denied'));
            }
        });
    } else {
        callback(new Error('doesn\'t support Notification API'));
    }
}
// 返回 `thenable`
function notifyMessageAsThenable(message, options) {
    return {
        'then': function (resolve, reject) {
            notifyMessage(message, options, function (error, notification) {
                if (error) {
                    reject(error);
                } else {
                    resolve(notification);
                }
            });
        }
    };
}
// 運行示例
Promise.resolve(notifyMessageAsThenable("message")).then(function (notification) {
    console.log(notification);// 通知對象
}).catch(function(error){
    console.error(error);
});

notification-thenable.js裏增加了一個 notifyMessageAsThenable方法。這個方法返回的對象具備一個then方法。

then方法的參數和 new Promise(function (resolve, reject){}) 一樣,在確定時執行 resolve 方法,拒絕時調用 reject 方法。

then 方法和 notification-as-promise.js 中的 notifyMessageAsPromise 方法完成了同樣的工作。

我們可以看出, Promise.resolve(thenable) 通過使用了 thenable 這個promise對象,就能利用Promise功能了。

Promise.resolve(notifyMessageAsThenable("message")).then(function (notification) {
    console.log(notification);// 通知對象
}).catch(function(error){
    console.error(error);
});

使用了Thenable的notification-thenable.js 和依賴於Promise的 notification-as-promise.js ,實際上都是非常相似的使用方法。

notification-thenable.js 和 notification-as-promise.js比起來,有以下的不同點。

  • 類庫側沒有提供 Promise 的實現

    • 用戶通過 Promise.resolve(thenable) 來自己實現了 Promise

  • 作爲Promise使用的時候,需要和 Promise.resolve(thenable) 一起配合使用

通過使用Thenable對象,我們可以實現類似已有的回調式風格和Promise風格中間的一種實現風格。

4.2.5. 總結

在本小節我們主要學習了什麼是Thenable,以及如何通過Promise.resolve(thenable) 使用Thenable,將其作爲promise對象來使用。

Callback — Thenable — Promise

Thenable風格表現爲位於回調和Promise風格中間的一種狀態,作爲類庫的公開API有點不太成熟,所以並不常見。

Thenable本身並不依賴於Promise功能,但是Promise之外也沒有使用Thenable的方式,所以可以認爲Thenable間接依賴於Promise。

另外,用戶需要對 Promise.resolve(thenable) 有所理解才能使用好Thenable,因此作爲類庫的公開API有一部分會比較難。和公開API相比,更多情況下是在內部使用Thenable。

在編寫異步處理的類庫的時候,推薦採用先編寫回調風格的函數,然後再轉換爲公開API這種方式。

貌似Node.js的Core module就採用了這種方式,除了類庫提供的基本回調風格的函數之外,用戶也可以通過Promise或者Generator等自己擅長的方式進行實現。

最初就是以能被Promise使用爲目的的類庫,或者其本身依賴於Promise等情況下,我想將返回promise對象的函數作爲公開API應該也沒什麼問題。

什麼時候該使用Thenable?

那麼,又是在什麼情況下應該使用Thenable呢?

恐怕最可能被使用的是在 Promise類庫 之間進行相互轉換了。

比如,類庫Q的Promise實例爲Q promise對象,提供了 ES6 Promises 的promise對象不具備的方法。Q promise對象提供了 promise.finally(callback) 和 promise.nodeify(callback) 等方法。

如果你想將ES6 Promises的promise對象轉換爲Q promise的對象,輪到Thenable大顯身手的時候就到了。

使用thenable將promise對象轉換爲Q promise對象
var Q = require("Q");
// 這是一個ES6的promise對象
var promise = new Promise(function(resolve){
    resolve(1);
});
// 變換爲Q promise對象
Q(promise).then(function(value){
    console.log(value);
}).finally(function(){ 
    console.log("finally");
});
因爲是Q promise對象所以可以使用 finally 方法

上面代碼中最開始被創建的promise對象具備then方法,因此是一個Thenable對象。我們可以通過Q(thenable)方法,將這個Thenable對象轉換爲Q promise對象。

可以說它的機制和 Promise.resolve(thenable) 一樣,當然反過來也一樣。

像這樣,Promise類庫雖然都有自己類型的promise對象,但是它們之間可以通過Thenable這個共通概念,在類庫之間(當然也包括native Promise)進行promise對象的相互轉換。

我們看到,就像上面那樣,Thenable多在類庫內部實現中使用,所以從外部來說不會經常看到Thenable的使用。但是我們必須牢記Thenable是Promise中一個非常重要的概念。

4.3. 使用reject而不是throw

Promise的構造函數,以及被 then 調用執行的函數基本上都可以認爲是在 try...catch 代碼塊中執行的,所以在這些代碼中即使使用 throw ,程序本身也不會因爲異常而終止。

如果在Promise中使用 throw 語句的話,會被 try...catch 住,最終promise對象也變爲Rejected狀態。

var promise = new Promise(function(resolve, reject){
    throw new Error("message");
});
promise.catch(function(error){
    console.error(error);// => "message"
});

代碼像這樣其實運行時倒也不會有什麼問題,但是如果想把 promise對象狀態 設置爲Rejected狀態的話,使用 reject 方法則更顯得合理。

所以上面的代碼可以改寫爲下面這樣。

var promise = new Promise(function(resolve, reject){
    reject(new Error("message"));
});
promise.catch(function(error){
    console.error(error);// => "message"
})

其實我們也可以這麼來考慮,在出錯的時候我們並沒有調用 throw 方法,而是使用了 reject ,那麼給 reject 方法傳遞一個Error類型的對象也就很好理解了。

4.3.1. 使用reject有什麼優點?

話說回來,爲什麼在想將promise對象的狀態設置爲Rejected的時候應該使用 reject 而不是 throw 呢?

首先是因爲我們很難區分 throw 是我們主動拋出來的,還是因爲真正的其它 異常 導致的。

比如在使用Chrome瀏覽器的時候,Chrome的開發者工具提供了在程序發生異常的時候自動在調試器中break的功能。

Pause On Caught Exceptions
Figure 12. Pause On Caught Exceptions

當我們開啓這個功能的時候,在執行到下面代碼中的 throw 時就會觸發調試器的break行爲。

var promise = new Promise(function(resolve, reject){
    throw new Error("message");
});

本來這是和調試沒有關係的地方,也因爲在Promise中的 throw 語句被break了,這也嚴重的影響了瀏覽器提供的此功能的正常使用。

4.3.2. 在then中進行reject

在Promise構造函數中,有一個用來指定 reject 方法的參數,使用這個參數而不是依靠 throw 將promise對象的狀態設置爲Rejected狀態非常簡單。

那麼如果像下面那樣想在 then 中進行reject的話該怎麼辦呢?

var promise = Promise.resolve();
promise.then(function (value) {
    setTimeout(function () {
        // 經過一段時間後還沒處理完的話就進行reject - 2
    }, 1000);
    // 比較耗時的處理 - 1
    somethingHardWork();
}).catch(function (error) {
    // 超時錯誤 - 3
});

上面的超時處理,需要在 then 中進行 reject 方法調用,但是傳遞給當前的回調函數的參數只有前面的一promise對象,這該怎麼辦呢?

關於使用Promise進行超時處理的具體實現方法可以參考 使用Promise.race和delay取消XHR請求 中的詳細說明。

在這裏我們再次回憶下 then 的工作原理。

在 then 中註冊的回調函數可以通過 return 返回一個值,這個返回值會傳給後面的 then 或 catch 中的回調函數。

而且return的返回值類型不光是簡單的字面值,還可以是複雜的對象類型,比如promise對象等。

這時候,如果返回的是promise對象的話,那麼根據這個promise對象的狀態,在下一個 then 中註冊的回調函數中的onFulfilled和onRejected的哪一個會被調用也是能確定的。

var promise = Promise.resolve();
promise.then(function () {
    var retPromise = new Promise(function (resolve, reject) {
        // resolve or reject 的狀態決定 onFulfilled or onRejected 的哪個方法會被調用
    });
    return retPromise;
}).then(onFulfilled, onRejected);
後面的then調用哪個回調函數是由promise對象的狀態來決定的

也就是說,這個 retPromise 對象狀態爲Rejected的時候,會調用後面then中的 onRejected 方法,這樣就實現了即使在 then 中不使用 throw 也能進行reject處理了。

var onRejected = console.error.bind(console);
var promise = Promise.resolve();
promise.then(function () {
    var retPromise = new Promise(function (resolve, reject) {
       reject(new Error("this promise is rejected"));
    });
    return retPromise;
}).catch(onRejected);

使用 Promise.reject 的話還能再將代碼進行簡化。

var onRejected = console.error.bind(console);
var promise = Promise.resolve();
promise.then(function () {
    return Promise.reject(new Error("this promise is rejected"));
}).catch(onRejected);

4.3.3. 總結

在本小節我們主要學習了

  • 使用 reject 會比使用 throw 安全

  • 在 then 中使用reject的方法

也許實際中我們可能不常使用 reject ,但是比起來不假思索的使用 throw 來說,使用 reject 的好處還是很多的。

關於上面講的內容的比較詳細的例子,大家可以參考在 使用Promise.race和delay取消XHR請求 小節的介紹。

4.4. Deferred和Promise

這一節我們來簡單介紹下Deferred和Promise之間的關係

4.4.1. 什麼是Deferred?

說起Promise,我想大家一定同時也聽說過Deferred這個術語。比如 jQuery.Deferred 和 JSDeferred 等,一定都是大家非常熟悉的內容了。

Deferred和Promise不同,它沒有共通的規範,每個Library都是根據自己的喜好來實現的。

在這裏,我們打算以 jQuery.Deferred 類似的實現爲中心進行介紹。

4.4.2. Deferred和Promise的關係

簡單來說,Deferred和Promise具有如下的關係。

  • Deferred 擁有 Promise

  • Deferred 具備對 Promise的狀態進行操作的特權方法(圖中的"特権メソッド")

Deferred和Promise
Figure 13. Deferred和Promise

我想各位看到此圖應該就很容易理解了,Deferred和Promise並不是處於競爭的關係,而是Deferred內涵了Promise。

這是jQuery.Deferred結構的簡化版。當然也有的Deferred實現並沒有內涵Promise。

光看圖的話也許還難以理解,下面我們就看看看怎麼通過Promise來實現Deferred。

4.4.3. Deferred top on Promise

基於Promise實現Deferred的例子。

deferred.js
function Deferred() {
    this.promise = new Promise(function (resolve, reject) {
        this._resolve = resolve;
        this._reject = reject;
    }.bind(this));
}
Deferred.prototype.resolve = function (value) {
    this._resolve.call(this.promise, value);
};
Deferred.prototype.reject = function (reason) {
    this._reject.call(this.promise, reason);
};

我們再將之前使用Promise實現的 getURL 用Deferred改寫一下。

xhr-deferred.js
function Deferred() {
    this.promise = new Promise(function (resolve, reject) {
        this._resolve = resolve;
        this._reject = reject;
    }.bind(this));
}
Deferred.prototype.resolve = function (value) {
    this._resolve.call(this.promise, value);
};
Deferred.prototype.reject = function (reason) {
    this._reject.call(this.promise, reason);
};
function getURL(URL) {
    var deferred = new Deferred();
    var req = new XMLHttpRequest();
    req.open('GET', URL, true);
    req.onload = function () {
        if (req.status === 200) {
            deferred.resolve(req.responseText);
        } else {
            deferred.reject(new Error(req.statusText));
        }
    };
    req.onerror = function () {
        deferred.reject(new Error(req.statusText));
    };
    req.send();
    return deferred.promise;
}
// 運行示例
var URL = "http://httpbin.org/get";
getURL(URL).then(function onFulfilled(value){
    console.log(value);
}).catch(console.error.bind(console));

所謂的能對Promise狀態進行操作的特權方法,指的就是能對promise對象的狀態進行resolve、reject等調用的方法,而通常的Promise的話只能在通過構造函數傳遞的方法之內對promise對象的狀態進行操作。

我們來看看Deferred和Promise相比在實現上有什麼異同。

xhr-promise.js
function getURL(URL) {
    return new Promise(function (resolve, reject) {
        var req = new XMLHttpRequest();
        req.open('GET', URL, true);
        req.onload = function () {
            if (req.status === 200) {
                resolve(req.responseText);
            } else {
                reject(new Error(req.statusText));
            }
        };
        req.onerror = function () {
            reject(new Error(req.statusText));
        };
        req.send();
    });
}
// 運行示例
var URL = "http://httpbin.org/get";
getURL(URL).then(function onFulfilled(value){
    console.log(value);
}).catch(console.error.bind(console));

對比上述兩個版本的 getURL ,我們發現它們有如下不同。

  • Deferred 的話不需要將代碼用Promise括起來

    • 由於沒有被嵌套在函數中,可以減少一層縮進

    • 反過來沒有Promise裏的錯誤處理邏輯

在以下方面,它們則完成了同樣的工作。

  • 整體處理流程

    • 調用 resolvereject 的時機

  • 函數都返回了promise對象

由於Deferred包含了Promise,所以大體的流程還是差不多的,不過Deferred有用對Promise進行操作的特權方法,以及高度自由的對流程控制進行自由定製。

比如在Promise一般都會在構造函數中編寫主要處理邏輯,對 resolvereject 方法的調用時機也基本是很確定的。

new Promise(function (resolve, reject){
    // 在這裏進行promise對象的狀態確定
});

而使用Deferred的話,並不需要將處理邏輯寫成一大塊代碼,只需要先創建deferred對象,可以在任何時機對 resolvereject 方法進行調用。

var deferred = new Deferred();

// 可以在隨意的時機對 `resolve`、`reject` 方法進行調用

上面我們只是簡單的實現了一個 Deferred ,我想你已經看到了它和 Promise 之間的差異了吧。

如果說Promise是用來對值進行抽象的話,Deferred則是對處理還沒有結束的狀態或操作進行抽象化的對象,我們也可以從這一層的區別來理解一下這兩者之間的差異。

換句話說,Promise代表了一個對象,這個對象的狀態現在還不確定,但是未來一個時間點它的狀態要麼變爲正常值(FulFilled),要麼變爲異常值(Rejected);而Deferred對象表示了一個處理還沒有結束的這種事實,在它的處理結束的時候,可以通過Promise來取得處理結果。

如果各位讀者還想深入瞭解一下Deferred的話,可以參考下面的這些資料。

Deferred最初是在Python的 Twisted 框架中被提出來的概念。 在JavaScript領域可以認爲它是由 MochiKit.Async 、 dojo/Deferred 等Library引入的。

4.5. 使用Promise.race和delay取消XHR請求

在本小節中,作爲在第2章所學的 Promise.race 的具體例子,我們來看一下如何使用Promise.race來實現超時機制。

當然XHR有一個 timeout 屬性,使用該屬性也可以簡單實現超時功能,但是爲了能支持多個XHR同時超時或者其他功能,我們採用了容易理解的異步方式在XHR中通過超時來實現取消正在進行中的操作。

4.5.1. 讓Promise等待指定時間

首先我們來看一下如何在Promise中實現超時。

所謂超時就是要在經過一定時間後進行某些操作,使用 setTimeout 的話很好理解。

首先我們來串講一個單純的在Promise中調用 setTimeout 的函數。

delayPromise.js
function delayPromise(ms) {
    return new Promise(function (resolve) {
        setTimeout(resolve, ms);
    });
}

delayPromise(ms) 返回一個在經過了參數指定的毫秒數後進行onFulfilled操作的promise對象,這和直接使用 setTimeout 函數比較起來只是編碼上略有不同,如下所示。

setTimeout(function () {
    alert("已經過了100ms!");
}, 100);
// == 幾乎同樣的操作
delayPromise(100).then(function () {
    alert("已經過了100ms!");
});

在這裏 promise對象 這個概念非常重要,請切記。

4.5.2. Promise.race中的超時

讓我們回顧一下靜態方法 Promise.race ,它的作用是在任何一個promise對象進入到確定(解決)狀態後就繼續進行後續處理,如下面的例子所示。

var winnerPromise = new Promise(function (resolve) {
        setTimeout(function () {
            console.log('this is winner');
            resolve('this is winner');
        }, 4);
    });
var loserPromise = new Promise(function (resolve) {
        setTimeout(function () {
            console.log('this is loser');
            resolve('this is loser');
        }, 1000);
    });
// 第一個promise變爲resolve後程序停止
Promise.race([winnerPromise, loserPromise]).then(function (value) {
    console.log(value);    // => 'this is winner'
});

我們可以將剛纔的 delayPromise 和其它promise對象一起放到 Promise.race 中來是實現簡單的超時機制。

simple-timeout-promise.js
function delayPromise(ms) {
    return new Promise(function (resolve) {
        setTimeout(resolve, ms);
    });
}
function timeoutPromise(promise, ms) {
    var timeout = delayPromise(ms).then(function () {
            throw new Error('Operation timed out after ' + ms + ' ms');
        });
    return Promise.race([promise, timeout]);
}

函數 timeoutPromise(比較對象promise, ms) 接收兩個參數,第一個是需要使用超時機制的promise對象,第二個參數是超時時間,它返回一個由 Promise.race 創建的相互競爭的promise對象。

之後我們就可以使用 timeoutPromise 編寫下面這樣的具有超時機制的代碼了。

function delayPromise(ms) {
    return new Promise(function (resolve) {
        setTimeout(resolve, ms);
    });
}
function timeoutPromise(promise, ms) {
    var timeout = delayPromise(ms).then(function () {
            throw new Error('Operation timed out after ' + ms + ' ms');
        });
    return Promise.race([promise, timeout]);
}
// 運行示例
var taskPromise = new Promise(function(resolve){
    // 隨便一些什麼處理
    var delay = Math.random() * 2000;
    setTimeout(function(){
        resolve(delay + "ms");
    }, delay);
});
timeoutPromise(taskPromise, 1000).then(function(value){
    console.log("taskPromise在規定時間內結束 : " + value);
}).catch(function(error){
    console.log("發生超時", error);
});

雖然在發生超時的時候拋出了異常,但是這樣的話我們就不能區分這個異常到底是普通的錯誤還是超時錯誤了。

爲了能區分這個 Error 對象的類型,我們再來定義一個Error 對象的子類 TimeoutError

4.5.3. 定製Error對象

Error 對象是ECMAScript的內建(build in)對象。

但是由於stack trace等原因我們不能完美的創建一個繼承自 Error 的類,不過在這裏我們的目的只是爲了和Error有所區別,我們將創建一個 TimeoutError 類來實現我們的目的。

在ECMAScript6中可以使用 class 語法來定義類之間的繼承關係。

class MyError extends Error{
    // 繼承了Error類的對象
}

爲了讓我們的 TimeoutError 能支持類似 error instanceof TimeoutError 的使用方法,我們還需要進行如下工作。

TimeoutError.js
function copyOwnFrom(target, source) {
    Object.getOwnPropertyNames(source).forEach(function (propName) {
        Object.defineProperty(target, propName, Object.getOwnPropertyDescriptor(source, propName));
    });
    return target;
}
function TimeoutError() {
    var superInstance = Error.apply(null, arguments);
    copyOwnFrom(this, superInstance);
}
TimeoutError.prototype = Object.create(Error.prototype);
TimeoutError.prototype.constructor = TimeoutError;

我們定義了 TimeoutError 類和構造函數,這個類繼承了Error的prototype。

它的使用方法和普通的 Error 對象一樣,使用 throw 語句即可,如下所示。

var promise = new Promise(function(){
    throw TimeoutError("timeout");
});

promise.catch(function(error){
    console.log(error instanceof TimeoutError);// true
});

有了這個 TimeoutError 對象,我們就能很容易區分捕獲的到底是因爲超時而導致的錯誤,還是其他原因導致的Error對象了。

本章裏介紹的繼承JavaScript內建對象的方法可以參考 Chapter 28. Subclassing Built-ins ,那裏有詳細的說明。此外 Error - JavaScript | MDN 也針對Error對象進行了詳細說明。

4.5.4. 通過超時取消XHR操作

到這裏,我想各位讀者都已經對如何使用Promise來取消一個XHR請求都有一些思路了吧。

取消XHR操作本身的話並不難,只需要調用 XMLHttpRequest 對象的 abort() 方法就可以了。

爲了能在外部調用 abort() 方法,我們先對之前本節出現的 getURL 進行簡單的擴展,cancelableXHR 方法除了返回一個包裝了XHR的promise對象之外,還返回了一個用於取消該XHR請求的abort方法。

delay-race-cancel.js
function cancelableXHR(URL) {
    var req = new XMLHttpRequest();
    var promise = new Promise(function (resolve, reject) {
            req.open('GET', URL, true);
            req.onload = function () {
                if (req.status === 200) {
                    resolve(req.responseText);
                } else {
                    reject(new Error(req.statusText));
                }
            };
            req.onerror = function () {
                reject(new Error(req.statusText));
            };
            req.onabort = function () {
                reject(new Error('abort this request'));
            };
            req.send();
        });
    var abort = function () {
        // 如果request還沒有結束的話就執行abort
        // https://developer.mozilla.org/en/docs/Web/API/XMLHttpRequest/Using_XMLHttpRequest
        if (req.readyState !== XMLHttpRequest.UNSENT) {
            req.abort();
        }
    };
    return {
        promise: promise,
        abort: abort
    };
}

在這些問題都明瞭之後,剩下只需要進行Promise處理的流程進行編碼即可。大體的流程就像下面這樣。

  1. 通過 cancelableXHR 方法取得包裝了XHR的promise對象和取消該XHR請求的方法

  2. 在 timeoutPromise 方法中通過 Promise.race 讓XHR的包裝promise和超時用promise進行競爭。

    • XHR在超時前返回結果的話

      1. 和正常的promise一樣,通過 then 返回請求結果

    • 發生超時的時候

      1. 拋出 throw TimeoutError 異常並被 catch

      2. catch的錯誤對象如果是 TimeoutError 類型的話,則調用 abort 方法取消XHR請求

將上面的步驟總結一下的話,代碼如下所示。

delay-race-cancel-play.js
function copyOwnFrom(target, source) {
    Object.getOwnPropertyNames(source).forEach(function (propName) {
        Object.defineProperty(target, propName, Object.getOwnPropertyDescriptor(source, propName));
    });
    return target;
}
function TimeoutError() {
    var superInstance = Error.apply(null, arguments);
    copyOwnFrom(this, superInstance);
}
TimeoutError.prototype = Object.create(Error.prototype);
TimeoutError.prototype.constructor = TimeoutError;
function delayPromise(ms) {
    return new Promise(function (resolve) {
        setTimeout(resolve, ms);
    });
}
function timeoutPromise(promise, ms) {
    var timeout = delayPromise(ms).then(function () {
            return Promise.reject(new TimeoutError('Operation timed out after ' + ms + ' ms'));
        });
    return Promise.race([promise, timeout]);
}
function cancelableXHR(URL) {
    var req = new XMLHttpRequest();
    var promise = new Promise(function (resolve, reject) {
            req.open('GET', URL, true);
            req.onload = function () {
                if (req.status === 200) {
                    resolve(req.responseText);
                } else {
                    reject(new Error(req.statusText));
                }
            };
            req.onerror = function () {
                reject(new Error(req.statusText));
            };
            req.onabort = function () {
                reject(new Error('abort this request'));
            };
            req.send();
        });
    var abort = function () {
        // 如果request還沒有結束的話就執行abort
        // https://developer.mozilla.org/en/docs/Web/API/XMLHttpRequest/Using_XMLHttpRequest
        if (req.readyState !== XMLHttpRequest.UNSENT) {
            req.abort();
        }
    };
    return {
        promise: promise,
        abort: abort
    };
}
var object = cancelableXHR('http://httpbin.org/get');
// main
timeoutPromise(object.promise, 1000).then(function (contents) {
    console.log('Contents', contents);
}).catch(function (error) {
    if (error instanceof TimeoutError) {
        object.abort();
        return console.log(error);
    }
    console.log('XHR Error :', error);
});

上面的代碼就通過在一定的時間內變爲解決狀態的promise對象實現了超時處理。

通常進行開發的情況下,由於這些邏輯會頻繁使用,因此將這些代碼分割保存在不同的文件應該是一個不錯的選擇。

4.5.5. promise和操作方法

在前面的 cancelableXHR 中,promise對象及其操作方法都是在一個對象中返回的,看起來稍微有些不太好理解。

從代碼組織的角度來說一個函數只返回一個值(promise對象)是一個非常好的習慣,但是由於在外面不能訪問 cancelableXHR 方法中創建的 req 變量,所以我們需要編寫一個專門的函數(上面的例子中的abort)來對這些內部對象進行處理。

當然也可以考慮到對返回的promise對象進行擴展,使其支持abort方法,但是由於promise對象是對值進行抽象化的對象,如果不加限制的增加操作用的方法的話,會使整體變得非常複雜。

大家都知道一個函數做太多的工作都不認爲是一個好的習慣,因此我們不會讓一個函數完成所有功能,也許像下面這樣對函數進行分割是一個不錯的選擇。

  • 返回包含XHR的promise對象

  • 接收promise對象作爲參數並取消該對象中的XHR請求

將這些處理整理爲一個模塊的話,以後擴展起來也方便,一個函數所做的工作也會比較精煉,代碼也會更容易閱讀和維護。

我們有很多方法來創建一個模塊(AMD,CommonJS,ES6 module etc..),在這裏,我們將會把前面的 cancelableXHR 整理爲一個Node.js的模塊使用。

cancelableXHR.js
"use strict";
var requestMap = {};
function createXHRPromise(URL) {
    var req = new XMLHttpRequest();
    var promise = new Promise(function (resolve, reject) {
        req.open('GET', URL, true);
        req.onreadystatechange = function () {
            if (req.readyState === XMLHttpRequest.DONE) {
                delete requestMap[URL];
            }
        };
        req.onload = function () {
            if (req.status === 200) {
                resolve(req.responseText);
            } else {
                reject(new Error(req.statusText));
            }
        };
        req.onerror = function () {
            reject(new Error(req.statusText));
        };
        req.onabort = function () {
            reject(new Error('abort this req'));
        };
        req.send();
    });
    requestMap[URL] = {
        promise: promise,
        request: req
    };
    return promise;
}

function abortPromise(promise) {
    if (typeof promise === "undefined") {
        return;
    }
    var request;
    Object.keys(requestMap).some(function (URL) {
        if (requestMap[URL].promise === promise) {
            request = requestMap[URL].request;
            return true;
        }
    });
    if (request != null && request.readyState !== XMLHttpRequest.UNSENT) {
        request.abort();
    }
}
module.exports.createXHRPromise = createXHRPromise;
module.exports.abortPromise = abortPromise;

使用方法也非常簡單,我們通過 createXHRPromise 方法得到XHR的promise對象,當想對這個XHR進行abort操作的時候,將這個promise對象傳遞給 abortPromise(promise) 方法就可以了。

var cancelableXHR = require("./cancelableXHR");

var xhrPromise = cancelableXHR.createXHRPromise('http://httpbin.org/get');
xhrPromise.catch(function (error) {
    // 調用 abort 拋出的錯誤
});
cancelableXHR.abortPromise(xhrPromise);
創建包裝了XHR的promise對象
取消在1中創建的promise對象的請求操作

4.5.6. 總結

在這裏我們學到了如下內容。

  • 經過一定時間後變爲解決狀態的delayPromise

  • 基於delayPromise和Promise.race的超時實現方式

  • 取消XHR promise請求

  • 通過模塊化實現promise對象和操作的分離

Promise能非常靈活的進行處理流程的控制,爲了充分發揮它的能力,我們需要注意不要將一個函數寫的過於龐大冗長,而是應該將其分割成更小更簡單的處理,並對之前JavaScript中提到的機制進行更深入的瞭解。

4.6. 什麼是 Promise.prototype.done ?

如果你使用過其他的Promise實現類庫的話,可能見過用done代替then的例子。

這些類庫都提供了 Promise.prototype.done 方法,使用起來也和 then 一樣,但是這個方法並不會返回promise對象。

雖然 ES6 PromisesPromises/A+等在設計上並沒有對Promise.prototype.done 做出任何規定,但是很多實現類庫都提供了該方法的實現。

在本小節中,我們將會學習什麼是 Promise.prototype.done ,以及爲什麼很多類庫都提供了對該方法的支持。

4.6.1. 使用done的代碼示例

看一下實際使用done的代碼的例子的話,應該就非常容易理解 done 方法的行爲了。

promise-done-example.js
if (typeof Promise.prototype.done === 'undefined') {
    Promise.prototype.done = function (onFulfilled, onRejected) {
        this.then(onFulfilled, onRejected).catch(function (error) {
            setTimeout(function () {
                throw error;
            }, 0);
        });
    };
}
var promise = Promise.resolve();
promise.done(function () {
    JSON.parse('this is not json');    // => SyntaxError: JSON.parse
});
// => 請打開瀏覽器的開發者工具中的控制檯窗口看一下

在前面我們已經說過,promise設計規格並沒有對 Promise.prototype.done做出任何規定,因此在使用的時候,你可以使用已有類庫提供的實現,也可以自己去實現。

我們會在後面講述如何去自己實現,首先我們這裏先對使用 then 和使用 done這兩種方式進行一下比較。

使用then的場景
var promise = Promise.resolve();
promise.then(function () {
    JSON.parse("this is not json");
}).catch(function (error) {
    console.error(error);// => "SyntaxError: JSON.parse"
});

從上面我們可以看出,兩者之間有以下不同點。

  • done 並不返回promise對象

    • 也就是說,在done之後不能使用 catch 等方法組成方法鏈

  • done 中發生的異常會被直接拋給外面

    • 也就是說,不會進行Promise的錯誤處理(Error Handling)

由於done 不會返回promise對象,所以我們不難理解它只能出現在一個方法鏈的最後。

此外,我們已經介紹過了Promise具有強大的錯誤處理機制,而done則會在函數中跳過錯誤處理,直接拋出異常。

爲什麼很多類庫都提供了這個和Promise功能相矛盾的函數呢?看一下下面Promise處理失敗的例子,也許我們多少就能理解其中原因了吧。

4.6.2. 消失的錯誤

Promise雖然具備了強大的錯誤處理機制,但是(調試工具不能順利運行的時候)這個功能會導致人爲錯誤(human error)更加複雜,這也是它的一個缺點。

也許你還記得,我們在 then or catch? 中也看到了類似的內容。

像下面那樣,我們看一個能返回promise對象的函數。

json-promise.js
function JSONPromise(value) {
    return new Promise(function (resolve) {
        resolve(JSON.parse(value));
    });
}

這個函數將接收到的參數傳遞給 JSON.parse ,並返回一個基於JSON.parse的promise對象。

我們可以像下面那樣使用這個Promise函數,由於 JSON.parse 會解析失敗並拋出一個異常,該異常會被 catch 捕獲。

function JSONPromise(value) {
    return new Promise(function (resolve) {
        resolve(JSON.parse(value));
    });
}
// 運行示例
var string = "非合法json編碼字符串";
JSONPromise(string).then(function (object) {
    console.log(object);
}).catch(function(error){
    // => JSON.parse拋出異常時
    console.error(error);
});

如果這個解析失敗的異常被正常捕獲的話則沒什麼問題,但是如果編碼時忘記了處理該異常,一旦出現異常,那麼查找異常發生的源頭將會變得非常棘手,這就是使用promise需要注意的一面。

忘記了使用catch進行異常處理的的例子
var string = "非合法json編碼字符串";
JSONPromise(string).then(function (object) {
    console.log(object);
}); 
雖然拋出了異常,但是沒有對該異常進行處理

如果是JSON.parse 這樣比較好找的例子還算好說,如果是拼寫錯誤的話,那麼發生了Syntax Error錯誤的話將會非常麻煩。

typo錯誤
var string = "{}";
JSONPromise(string).then(function (object) {
    conosle.log(object);
});
存在conosle這個拼寫錯誤

這這個例子裏,我們錯把 console 拼成了 conosle ,因此會發生如下錯誤。

ReferenceError: conosle is not defined

但是,由於Promise的try-catch機制,這個問題可能會被內部消化掉。 如果在調用的時候每次都無遺漏的進行 catch 處理的話當然最好了,但是如果在實現的過程中出現了這個例子中的錯誤的話,那麼進行錯誤排除的工作也會變得困難。

這種錯誤被內部消化的問題也被稱爲 unhandled rejection ,從字面上看就是在Rejected時沒有找到相應處理的意思。

這種unhandled rejection錯誤到底有多難檢查,也依賴於Promise的實現。 比如 ypromise 在檢測到 unhandled rejection 錯誤的時候,會在控制檯上提示相應的信息。

Promise rejected but no error handlers were registered to it

另外, Bluebird 在比較明顯的人爲錯誤,即ReferenceError等錯誤的時候,會直接顯示到控制檯上。

"Possibly unhandled ReferenceError. conosle is not defined

原生(Native)的 Promise實現爲了應對同樣問題,提供了GC-based unhandled rejection tracking功能。

該功能是在promise對象被垃圾回收器回收的時候,如果是unhandled rejection的話,則進行錯誤顯示的一種機制。

Firefox 或 Chrome 的原生Promise都進行了部分實現。

4.6.3. done的實現

作爲方法論,在Promise中 done 是怎麼解決上面提到的錯誤被忽略呢? 其實它的方法很簡單直接,那就是必須要進行錯誤處理。

由於可以在 Promise上實現 done 方法,因此我們看看如何對 Promise.prototype.done 這個Promise的prototype進行擴展。

promise-prototype-done.js
"use strict";
if (typeof Promise.prototype.done === "undefined") {
    Promise.prototype.done = function (onFulfilled, onRejected) {
        this.then(onFulfilled, onRejected).catch(function (error) {
            setTimeout(function () {
                throw error;
            }, 0);
        });
    };
}

那麼它是如何將異常拋到Promise的外面的呢?其實這裏我們利用的是在setTimeout中使用throw方法,直接將異常拋給了外部。

setTimeout的回調函數中拋出異常
try{
    setTimeout(function callback() {
        throw new Error("error");
    }, 0);
}catch(error){
    console.error(error);
}
這個例外不會被捕獲

關於爲什麼異步的callback中拋出的異常不會被捕獲的原因,可以參考下面內容。

仔細看一下 Promise.prototype.done的代碼,我們會發現這個函數什麼也沒 return 。 也就是說, done按照「Promise chain在這裏將會中斷,如果出現了異常,直接拋到promise外面即可」的原則進行了處理。

如果實現和運行環境實現的比較完美的話,就可以進行 unhandled rejection 檢測,done也不一定是必須的了。 另外像本小節中的 Promise.prototype.done一樣,done也可以在既有的Promise之上進行實現,也可以說它沒有進入到 ES6 Promises的設計規範之中。

本文中的 Promise.prototype.done 的實現方法參考了 promisejs.org 。

4.6.4. 總結

在本小節中,我們學習了 Q 、 Bluebird 和 prfun 等Promise類庫提供的 done 的基礎和實現細節,以及done方法和 then 方法有什麼區別等內容。

我們也學到了 done 有以下兩個特點。

  • done 中出現的錯誤會被作爲異常拋出

  • 終結 Promise chain

和 then or catch? 中說到的一樣,由Promise內部消化掉的錯誤,隨着調試工具或者類庫的改進,大多數情況下也許已經不是特別大的問題了。

此外,由於 done 不會有返回值,因此不能在它之後進行方法鏈的創建,爲了實現Promise方法風格上的統一,我們也可以使用done方法。

ES6 Promises 本身提供的功能並不是特別多。 因此,我想很多時候可能需要我們自己進行擴展或者使用第三方類庫。

我們好不容易將異步處理統一採用Promise進行統一處理,但是如果做過頭了,也會將系統變得特別複雜,因此,保持風格的統一是Promise作爲抽象對象非常重要的部分。

在 Promises: The Extension Problem (part 4) | getiblog 中,介紹了一些如何編寫Promise擴展程序的方法。

  • 擴展 Promise.prototype 的方法

  • 利用 Wrapper/Delegate 創建抽象層

此外,關於 Delegate 的詳細使用方法,也可以參考 Chapter 28. Subclassing Built-ins ,那裏有詳細的說明。

4.7. Promise和方法鏈(method chain)

在Promise中你可以將 then 和 catch 等方法連在一起寫。這非常像DOM或者jQuery中的方法鏈。

一般的方法鏈都通過返回 this 將多個方法串聯起來。

關於如何創建方法鏈,可以從參考 方法鏈的創建方法 - 餘味(日語博客) 等資料。

另一方面,由於Promise 每次都會返回一個新的promise對象 ,所以從表面上看和一般的方法鏈幾乎一模一樣。

在本小節裏,我們會在不改變已有采用方法鏈編寫的代碼的外部接口的前提下,學習如何在內部使用Promise進行重寫。

4.7.1. fs中的方法鏈

我們下面將會以 Node.js中的fs 爲例進行說明。

此外,這裏的例子我們更重視代碼的易理解性,因此從實際上來說這個例子可能並不算太實用。

fs-method-chain.js
"use strict";
var fs = require("fs");
function File() {
    this.lastValue = null;
}
// Static method for File.prototype.read
File.read = function FileRead(filePath) {
    var file = new File();
    return file.read(filePath);
};
File.prototype.read = function (filePath) {
    this.lastValue = fs.readFileSync(filePath, "utf-8");
    return this;
};
File.prototype.transform = function (fn) {
    this.lastValue = fn.call(this, this.lastValue);
    return this;
};
File.prototype.write = function (filePath) {
    this.lastValue = fs.writeFileSync(filePath, this.lastValue);
    return this;
};
module.exports = File;

這個模塊可以將類似下面的 read → transform → write 這一系列處理,通過組成一個方法鏈來實現。

var File = require("./fs-method-chain");
var inputFilePath = "input.txt",
    outputFilePath = "output.txt";
File.read(inputFilePath)
    .transform(function (content) {
        return ">>" + content;
    })
    .write(outputFilePath);

transform 接收一個方法作爲參數,該方法對其輸入參數進行處理。在這個例子裏,我們對通過read讀取的數據在前面加上了 >> 字符串。

4.7.2. 基於Promise的fs方法鏈

下面我們就在不改變剛纔的方法鏈對外接口的前提下,採用Promise對內部實現進行重寫。

fs-promise-chain.js
"use strict";
var fs = require("fs");
function File() {
    this.promise = Promise.resolve();
}
// Static method for File.prototype.read
File.read = function (filePath) {
    var file = new File();
    return file.read(filePath);
};

File.prototype.then = function (onFulfilled, onRejected) {
    this.promise = this.promise.then(onFulfilled, onRejected);
    return this;
};
File.prototype["catch"] = function (onRejected) {
    this.promise = this.promise.catch(onRejected);
    return this;
};
File.prototype.read = function (filePath) {
    return this.then(function () {
        return fs.readFileSync(filePath, "utf-8");
    });
};
File.prototype.transform = function (fn) {
    return this.then(fn);
};
File.prototype.write = function (filePath) {
    return this.then(function (data) {
        return fs.writeFileSync(filePath, data)
    });
};
module.exports = File;

新增加的then 和catch都可以看做是指向內部保存的promise對象的別名,而其它部分從對外接口的角度來說都沒有改變,使用方法也和原來一樣。

因此,在使用這個模塊的時候我們只需要修改 require 的模塊名即可。

var File = require("./fs-promise-chain");
var inputFilePath = "input.txt",
    outputFilePath = "output.txt";
File.read(inputFilePath)
    .transform(function (content) {
        return ">>" + content;
    })
    .write(outputFilePath);

File.prototype.then 方法會調用 this.promise.then 方法,並將返回的promise對象賦值給了 this.promise 變量這個內部promise對象。

這究竟有什麼奧妙麼?通過以下的僞代碼,我們可以更容易理解這背後發生的事情。

var File = require("./fs-promise-chain");
File.read(inputFilePath)
    .transform(function (content) {
        return ">>" + content;
    })
    .write(outputFilePath);
// => 處理流程類似以下的僞代碼
promise.then(function read(){
        return fs.readFileSync(filePath, "utf-8");
    }).then(function transform(content) {
         return ">>" + content;
    }).then(function write(){
        return fs.writeFileSync(filePath, data);
    });

看到 promise = promise.then(...) 這種寫法,會讓人以爲promise的值會被覆蓋,也許你會想是不是promise的chain被截斷了。

你可以想象爲類似 promise = addPromiseChain(promise, fn); 這樣的感覺,我們爲promise對象增加了新的處理,並返回了這個對象,因此即使自己不實現順序處理的話也不會帶來什麼問題。

4.7.3. 兩者的區別

同步和異步

要說fs-method-chain.jsPromise版兩者之間的差別,最大的不同那就要算是同步和異步了。

如果在類似 fs-method-chain.js 的方法鏈中加入隊列等處理的話,就可以實現幾乎和異步方法鏈同樣的功能,但是實現將會變得非常複雜,所以我們選擇了簡單的同步方法鏈。

Promise版的話如同在 專欄: Promise只能進行異步處理?裏介紹過的一樣,只會進行異步操作,因此使用了promise的方法鏈也是異步的。

錯誤處理

雖然fs-method-chain.js裏面並不包含錯誤處理的邏輯, 但是由於是同步操作,因此可以將整段代碼用 try-catch 包起來。

在 Promise版 提供了指向內部promise對象的then 和 catch 別名,所以我們可以像其它promise對象一樣使用catch來進行錯誤處理。

fs-promise-chain中的錯誤處理
var File = require("./fs-promise-chain");
File.read(inputFilePath)
    .transform(function (content) {
        return ">>" + content;
    })
    .write(outputFilePath)
    .catch(function(error){
        console.error(error);
    });

如果你想在fs-method-chain.js中自己實現異步處理的話,錯誤處理可能會成爲比較大的問題;可以說在進行異步處理的時候,還是使用Promise實現起來比較簡單。

4.7.4. Promise之外的異步處理

如果你很熟悉Node.js的話,那麼看到方法鏈的話,你是不是會想起來 Stream 呢。

如果使用 Stream 的話,就可以免去了保存 this.lastValue 的麻煩,還能改善處理大文件時候的性能。 另外,使用Stream的話可能會比使用Promise在處理速度上會快些。

使用Stream進行read→transform→write
readableStream.pipe(transformStream).pipe(writableStream);

因此,在異步處理的時候並不是說Promise永遠都是最好的選擇,要根據自己的目的和實際情況選擇合適的實現方式。

Node.js的Stream是一種基於Event的技術

關於Node.js中Stream的詳細信息可以參考以下網頁。

4.7.5. Promise wrapper

再回到 fs-method-chain.js 和 Promise版,這兩種方法相比較內部實現也非常相近,讓人覺得是不是同步版本的代碼可以直接就當做異步方式來使用呢?

由於JavaScript可以向對象動態添加方法,所以從理論上來說應該可以從非Promise版自動生成Promise版的代碼。(當然靜態定義的實現方式容易處理)

儘管 ES6 Promises 並沒有提供此功能,但是著名的第三方Promise實現類庫 bluebird 等提供了被稱爲 Promisification 的功能。

如果使用類似這樣的類庫,那麼就可以動態給對象增加promise版的方法。

var fs = Promise.promisifyAll(require("fs"));

fs.readFileAsync("myfile.js", "utf8").then(function(contents){
    console.log(contents);
}).catch(function(e){
    console.error(e.stack);
});
Array的Promise wrapper

前面的 Promisification 具體都幹了些什麼光憑想象恐怕不太容易理解,我們可以通過給原生的 Array 增加Promise版的方法爲例來進行說明。

在JavaScript中原生DOM或String等也提供了很多創建方法鏈的功能。 Array 中就有諸如 map 和 filter 等方法,這些方法會返回一個數組類型,可以用這些方法方便的組建方法鏈。

array-promise-chain.js
"use strict";
function ArrayAsPromise(array) {
    this.array = array;
    this.promise = Promise.resolve();
}
ArrayAsPromise.prototype.then = function (onFulfilled, onRejected) {
    this.promise = this.promise.then(onFulfilled, onRejected);
    return this;
};
ArrayAsPromise.prototype["catch"] = function (onRejected) {
    this.promise = this.promise.catch(onRejected);
    return this;
};
Object.getOwnPropertyNames(Array.prototype).forEach(function (methodName) {
    // Don't overwrite
    if (typeof ArrayAsPromise[methodName] !== "undefined") {
        return;
    }
    var arrayMethod = Array.prototype[methodName];
    if (typeof  arrayMethod !== "function") {
        return;
    }
    ArrayAsPromise.prototype[methodName] = function () {
        var that = this;
        var args = arguments;
        this.promise = this.promise.then(function () {
            that.array = Array.prototype[methodName].apply(that.array, args);
            return that.array;
        });
        return this;
    };
});

module.exports = ArrayAsPromise;
module.exports.array = function newArrayAsPromise(array) {
    return new ArrayAsPromise(array);
};

原生的 Array 和 ArrayAsPromise 在使用時有什麼差異呢?我們可以通過對 上面的代碼 進行測試來了解它們之間的不同點。

array-promise-chain-test.js
"use strict";
var assert = require("power-assert");
var ArrayAsPromise = require("../src/promise-chain/array-promise-chain");
describe("array-promise-chain", function () {
    function isEven(value) {
        return value % 2 === 0;
    }

    function double(value) {
        return value * 2;
    }

    beforeEach(function () {
        this.array = [1, 2, 3, 4, 5];
    });
    describe("Native array", function () {
        it("can method chain", function () {
            var result = this.array.filter(isEven).map(double);
            assert.deepEqual(result, [4, 8]);
        });
    });
    describe("ArrayAsPromise", function () {
        it("can promise chain", function (done) {
            var array = new ArrayAsPromise(this.array);
            array.filter(isEven).map(double).then(function (value) {
                assert.deepEqual(value, [4, 8]);
            }).then(done, done);
        });
    });
});

我們看到,在 ArrayAsPromise 中也能使用 Array的方法。而且也和前面的例子類似,原生的Array是同步處理,而 ArrayAsPromise則是異步處理,這也是它們的不同之處。

仔細看一下 ArrayAsPromise 的實現,也許你已經注意到了, Array.prototype 的所有方法都被實現了。 但是,Array.prototype 中也存在着類似array.indexOf 等並不會返回數組類型數據的方法,這些方法如果也要支持方法鏈的話就有些不自然了。

在這裏非常重要的一點是,我們可以通過這種方式,爲具有接收相同類型數據接口的API動態的創建Promise版的API。 如果我們能意識到這種API的規則性的話,那麼就可能發現一些新的使用方法。

前面我們看到的 Promisification 方法,借鑑了了 Node.js的Core模塊中在進行異步處理時將 function(error,result){} 方法的第一個參數設爲 error 這一規則,自動的創建由Promise包裝好的方法。

4.7.6. 總結

在本小節我們主要學習了下面的這些內容。

  • Promise版的方法鏈實現

  • Promise並不是總是異步編程的最佳選擇

  • Promisification

  • 統一接口的重用

ES6 Promises只提供了一些Core級別的功能。 因此,我們也許需要對現有的方法用Promise方式重新包裝一下。

但是,類似Event等調用次數沒有限制的回調函數等在並不適合使用Promise,Promise也不能說什麼時候都是最好的選擇。

至於什麼情況下應該使用Promise,什麼時候不該使用Promise,並不是本書要討論的目的, 我們需要牢記的是不要什麼都用Promise去實現,我想最好根據自己的具體目的和情況,來考慮是應該使用Promise還是其它方法。

4.8. 使用Promise進行順序(sequence)處理

在第2章 Promise.all 中,我們已經學習瞭如何讓多個promise對象同時開始執行的方法。

但是 Promise.all 方法會同時運行多個promise對象,如果想進行在A處理完成之後再開始B的處理,對於這種順序執行的話 Promise.all就無能爲力了。

此外,在同一章的Promise和數組 中,我們也介紹了一種效率不是特別高的,使用了 重複使用多個then的方法 來實現如何按順序進行處理。

在本小節中,我們將對如何在Promise中進行順序處理進行介紹。

4.8.1. 循環和順序處理

在 重複使用多個then的方法 中的實現方法如下。

function getURL(URL) {
    return new Promise(function (resolve, reject) {
        var req = new XMLHttpRequest();
        req.open('GET', URL, true);
        req.onload = function () {
            if (req.status === 200) {
                resolve(req.responseText);
            } else {
                reject(new Error(req.statusText));
            }
        };
        req.onerror = function () {
            reject(new Error(req.statusText));
        };
        req.send();
    });
}
var request = {
        comment: function getComment() {
            return getURL('http://azu.github.io/promises-book/json/comment.json').then(JSON.parse);
        },
        people: function getPeople() {
            return getURL('http://azu.github.io/promises-book/json/people.json').then(JSON.parse);
        }
    };
function main() {
    function recordValue(results, value) {
        results.push(value);
        return results;
    }
    // [] 用來保存初始化的值
    var pushValue = recordValue.bind(null, []);
    return request.comment().then(pushValue).then(request.people).then(pushValue);
}
// 運行示例
main().then(function (value) {
    console.log(value);
}).catch(function(error){
    console.error(error);
});

使用這種寫法的話那麼隨着 request 中元素數量的增加,我們也需要不斷增加對 then 方法的調用

因此,如果我們將處理內容統一放到數組裏,再配合for循環進行處理的話,那麼處理內容的增加將不會再帶來什麼問題。首先我們就使用for循環來完成和前面同樣的處理。

promise-foreach-xhr.js
function getURL(URL) {
    return new Promise(function (resolve, reject) {
        var req = new XMLHttpRequest();
        req.open('GET', URL, true);
        req.onload = function () {
            if (req.status === 200) {
                resolve(req.responseText);
            } else {
                reject(new Error(req.statusText));
            }
        };
        req.onerror = function () {
            reject(new Error(req.statusText));
        };
        req.send();
    });
}
var request = {
        comment: function getComment() {
            return getURL('http://azu.github.io/promises-book/json/comment.json').then(JSON.parse);
        },
        people: function getPeople() {
            return getURL('http://azu.github.io/promises-book/json/people.json').then(JSON.parse);
        }
    };
function main() {
    function recordValue(results, value) {
        results.push(value);
        return results;
    }
    // [] 用來保存初始化值
    var pushValue = recordValue.bind(null, []);
    // 返回promise對象的函數的數組
    var tasks = [request.comment, request.people];
    var promise = Promise.resolve();
    // 開始的地方
    for (var i = 0; i < tasks.length; i++) {
        var task = tasks[i];
        promise = promise.then(task).then(pushValue);
    }
    return promise;
}
// 運行示例
main().then(function (value) {
    console.log(value);
}).catch(function(error){
    console.error(error);
});

使用for循環的時候,如同我們在 專欄: 每次調用then都會返回一個新創建的promise對象 以及 Promise和方法鏈 中學到的那樣,每次調用 Promise#then 方法都會返回一個新的promise對象。

因此類似 promise = promise.then(task).then(pushValue); 的代碼就是通過不斷對promise進行處理,不斷的覆蓋 promise 變量的值,以達到對promise對象的累積處理效果。

但是這種方法需要 promise 這個臨時變量,從代碼質量上來說顯得不那麼簡潔。

如果將這種循環寫法改用 Array.prototype.reduce 的話,那麼代碼就會變得聰明多了。

4.8.2. Promise chain和reduce

如果將上面的代碼用 Array.prototype.reduce 重寫的話,會像下面一樣。

promise-reduce-xhr.js
function getURL(URL) {
    return new Promise(function (resolve, reject) {
        var req = new XMLHttpRequest();
        req.open('GET', URL, true);
        req.onload = function () {
            if (req.status === 200) {
                resolve(req.responseText);
            } else {
                reject(new Error(req.statusText));
            }
        };
        req.onerror = function () {
            reject(new Error(req.statusText));
        };
        req.send();
    });
}
var request = {
        comment: function getComment() {
            return getURL('http://azu.github.io/promises-book/json/comment.json').then(JSON.parse);
        },
        people: function getPeople() {
            return getURL('http://azu.github.io/promises-book/json/people.json').then(JSON.parse);
        }
    };
function main() {
    function recordValue(results, value) {
        results.push(value);
        return results;
    }
    var pushValue = recordValue.bind(null, []);
    var tasks = [request.comment, request.people];
    return tasks.reduce(function (promise, task) {
        return promise.then(task).then(pushValue);
    }, Promise.resolve());
}
// 運行示例
main().then(function (value) {
    console.log(value);
}).catch(function(error){
    console.error(error);
});

這段代碼中除了 main 函數之外的其他處理都和使用for循環的時候相同。

Array.prototype.reduce 的第二個參數用來設置盛放計算結果的初始值。在這個例子中, Promise.resolve() 會賦值給 promise,此時的 task 爲 request.comment 。

在reduce中第一個參數中被 return 的值,則會被賦值爲下次循環時的 promise 。也就是說,通過返回由 then 創建的新的promise對象,就實現了和for循環類似的 Promise chain 了。

下面是關於 Array.prototype.reduce 的詳細說明。

使用reduce和for循環不同的地方是reduce不再需要臨時變量 promise 了,因此也不用編寫 promise = promise.then(task).then(pushValue); 這樣冗長的代碼了,這是非常大的進步。

雖然 Array.prototype.reduce 非常適合用來在Promise中進行順序處理,但是上面的代碼有可能讓人難以理解它是如何工作的。

因此我們再來編寫一個名爲 sequenceTasks 的函數,它接收一個數組作爲參數,數組裏面存放的是要進行的處理Task。

從下面的調用代碼中我們可以非常容易的從其函數名想到,該函數的功能是對 tasks 中的處理進行順序執行了。

var tasks = [request.comment, request.people];
sequenceTasks(tasks);

4.8.3. 定義進行順序處理的函數

基本上我們只需要基於 使用reduce的方法 重構出一個函數。

promise-sequence.js
function sequenceTasks(tasks) {
    function recordValue(results, value) {
        results.push(value);
        return results;
    }
    var pushValue = recordValue.bind(null, []);
    return tasks.reduce(function (promise, task) {
        return promise.then(task).then(pushValue);
    }, Promise.resolve());
}

需要注意的一點是,和 Promise.all 等不同,這個函數接收的參數是一個函數的數組。

爲什麼傳給這個函數的不是一個promise對象的數組呢?這是因爲promise對象創建的時候,XHR已經開始執行了,因此再對這些promise對象進行順序處理的話就不能正常工作了。

因此 sequenceTasks 將函數(該函數返回一個promise對象)的數組作爲參數。

最後,使用 sequenceTasks 重寫最開始的例子的話,如下所示。

promise-sequence-xhr.js
function sequenceTasks(tasks) {
    function recordValue(results, value) {
        results.push(value);
        return results;
    }
    var pushValue = recordValue.bind(null, []);
    return tasks.reduce(function (promise, task) {
        return promise.then(task).then(pushValue);
    }, Promise.resolve());
}
function getURL(URL) {
    return new Promise(function (resolve, reject) {
        var req = new XMLHttpRequest();
        req.open('GET', URL, true);
        req.onload = function () {
            if (req.status === 200) {
                resolve(req.responseText);
            } else {
                reject(new Error(req.statusText));
            }
        };
        req.onerror = function () {
            reject(new Error(req.statusText));
        };
        req.send();
    });
}
var request = {
        comment: function getComment() {
            return getURL('http://azu.github.io/promises-book/json/comment.json').then(JSON.parse);
        },
        people: function getPeople() {
            return getURL('http://azu.github.io/promises-book/json/people.json').then(JSON.parse);
        }
    };
function main() {
    return sequenceTasks([request.comment, request.people]);
}
// 運行示例
main().then(function (value) {
    console.log(value);
}).catch(function(error){
    console.error(error);
});

怎樣, main() 中的流程是不是更清晰易懂了。

如上所述,在Promise中,我們可以選擇多種方法來實現處理的按順序執行。

但是,這些方法都是基於JavaScript中對數組及進行操作的for循環或 forEach 等,本質上並無大區別。 因此從一定程度上來說,在處理Promise的時候,將大塊的處理分成小函數來實現是一個非常好的實踐。

4.8.4. 總結

在本小節中,我們對如何在Promise中進行和 Promise.all 相反,按順序讓promise一個個進行處理的實現方式進行了介紹。

爲了實現順序處理,我們也對從過程風格的編碼方式到自定義順序處理函數的方式等實現方式進行了介紹,也再次強調了在Promise領域我們應遵循將處理按照函數進行劃分的基本原則。

在Promise中如果還使用了Promise chain將多個處理連接起來的話,那麼還可能使源代碼中的一條語句變得很長。

這時候如果我們回想一下這些編程的基本原則進行函數拆分的話,代碼整體結構會變得非常清晰。

此外,Promise的構造函數以及 then 都是高階函數,如果將處理分割爲函數的話,還能得到對函數進行靈活組合使用的副作用,意識到這一點對我們也會有一些幫助的。

高階函數指的是一個函數可以接受其參數爲函數對象的實例

5. Promises API Reference

5.1. Promise#then

promise.then(onFulfilled, onRejected);
then代碼示例
var promise = new Promise(function(resolve, reject){
    resolve("傳遞給then的值");
});
promise.then(function (value) {
    console.log(value);
}, function (error) {
    console.error(error);
});

這段代碼創建一個promise對象,定義了處理onFulfilled和onRejected的函數(handler),然後返回這個promise對象。

這個promise對象會在變爲resolve或者reject的時候分別調用相應註冊的回調函數。

  • 當handler返回一個正常值的時候,這個值會傳遞給promise對象的onFulfilled方法。

  • 定義的handler中產生異常的時候,這個值則會傳遞給promise對象的onRejected方法。

5.2. Promise#catch

promise.catch(onRejected);
catch代碼示例
var promise = new Promise(function(resolve, reject){
    resolve("傳遞給then的值");
});
promise.then(function (value) {
    console.log(value);
}).catch(function (error) {
    console.error(error);
});

這是一個等價於promise.then(undefined, onRejected) 的語法糖。

5.3. Promise.resolve

Promise.resolve(promise);
Promise.resolve(thenable);
Promise.resolve(object);
Promise.resolve代碼示例
var taskName = "task 1"
asyncTask(taskName).then(function (value) {
    console.log(value);
}).catch(function (error) {
    console.error(error);
});
function asyncTask(name){
    return Promise.resolve(name).then(function(value){
        return "Done! "+ value;
    });
}

根據接收到的參數不同,返回不同的promise對象。

雖然每種情況都會返回promise對象,但是大體來說主要分爲下面3類。

接收到promise對象參數的時候

返回的還是接收到的promise對象

接收到thenable類型的對象的時候

返回一個新的promise對象,這個對象具有一個 then 方法

接收的參數爲其他類型的時候(包括JavaScript對或null等)

返回一個將該對象作爲值的新promise對象

5.4. Promise.reject

Promise.reject(object)
Promise.reject代碼示例
var failureStub = sinon.stub(xhr, "request").returns(Promise.reject(new Error("bad!")));

返回一個使用接收到的值進行了reject的新的promise對象。

而傳給Promise.reject的值也應該是一個 Error 類型的對象。

另外,和 Promise.resolve不同的是,即使Promise.reject接收到的參數是一個promise對象,該函數也還是會返回一個全新的promise對象。

var r = Promise.reject(new Error("error"));
console.log(r === Promise.reject(r));// false

5.5. Promise.all

Promise.all(promiseArray);
Promise.all代碼示例
var p1 = Promise.resolve(1),
    p2 = Promise.resolve(2),
    p3 = Promise.resolve(3);
Promise.all([p1, p2, p3]).then(function (results) {
    console.log(results);  // [1, 2, 3]
});

生成並返回一個新的promise對象。

參數傳遞promise數組中所有的promise對象都變爲resolve的時候,該方法纔會返回, 新創建的promise則會使用這些promise的值。

如果參數中的任何一個promise爲reject的話,則整個Promise.all調用會立即終止,並返回一個reject的新的promise對象。

由於參數數組中的每個元素都是由 Promise.resolve 包裝(wrap)的,所以Paomise.all可以處理不同類型的promose對象。

5.6. Promise.race

Promise.race(promiseArray);
Promise.race代碼示例
var p1 = Promise.resolve(1),
    p2 = Promise.resolve(2),
    p3 = Promise.resolve(3);
Promise.race([p1, p2, p3]).then(function (value) {
    console.log(value);  // 1
});

生成並返回一個新的promise對象。

參數 promise 數組中的任何一個promise對象如果變爲resolve或者reject的話, 該函數就會返回,並使用這個promise對象的值進行resolve或者reject。

6. 用語集

Promises

Promise規範自身

promise對象

promise對象指的是 Promise 實例對象

ES6 Promises

如果想明確表示使用 ECMAScript 6th Edition 的話,可以使用ES6作爲前綴(prefix)

Promises/A+

Promises/A+。 這是ES6 Promises的前身,是一個社區規範,它和 ES6 Promises 有很多共通的內容。

Thenable

類Promise對象。 擁有名爲.then方法的對象。

promise chain

指使用 then 或者 catch 方法將promise對象連接起來的行爲。 此用語只是在本書中的說法,而不是在 ES6 Promises 中定義的官方用語。

w3ctag/promises-guide

Promises指南 - 這裏有很多關於概念方面的說明

domenic/promises-unwrapping

ES6 Promises規範的repo - 可以通過查看issue來了解各種關於規範的來龍去脈和信息

ECMAScript Language Specification ECMA-262 6th Edition – DRAFT

ES6 Promises的規範 - 如果想參考關於ES6 Promises的規範,則應該先看這裏

JavaScript Promises: There and back again - HTML5 Rocks

關於Promises的文章 - 這裏的示例代碼和參考(reference)的完成度都很高

Node.js Promise再次降臨! - ぼちぼち日記

關於Node.js和Promise的文章 - thenable部分參考了本文

8. 關於作者

azu azu (Twitter : @azu_re )

關注瀏覽器、JavaScript相關的最新技術。

擅長將目的作爲手段,本書也是因此而成。

管理着個人主頁 Web Scratch 和 JSer.info 。

9. 關於譯者

9.1. 給原著者留言、後記

後記.pdf 裏面記錄了筆者爲什麼要寫這麼一本書,編寫的過程,以及如何進行測試。

你可以在Gumroad以免費的價格或者自己設定一個任意的價格來下載本書的後記。

在下載的時候,會有一個給作者留言的地方, 希望各位讀者能寫下一點什麼之後下載。

如果本書有任何問題的話,也可以通過 GitHub或者Gitter 來提交。

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