一塊兒來學Promise

注意,本文主要針對ES6標準實現的Promise語法進行闡述,實例代碼也都使用ES6語法,快速入門ES6請參見ECMAScript 6 掃盲javascript

一分鐘快速入門

被回調地獄整怕了?快試Promise吧!。Promise的核心思想其實很簡單,就是將異步操做結果處理交給Promise對象的方法註冊,而後等到異步操做完了再去取用這些處理操做。至於取用哪一個處理操做,就得看Promise對象狀態了。Promise對象一共有三種狀態:Pending(初始狀態)、Fulfilled(異步操做成功)、Rejected(異步操做失敗)。而三者間的轉換隻有兩種狀況:Pending—>Fulfilled、Pending—>Rejected;詳見下圖:html

prmoise-sates

瞭解了狀態及其轉換後,咱們就能夠來使用Promise對象了:前端

let promise = new Promise((resolve, reject)=> {
    // 異步操做
    // 異步操做成功時調用
    resolve(value)
    // 異步操做失敗時調用
    reject(error)
    });

上述代碼中傳給Promise構造函數的兩個函數resolve, reject,分別用於觸發Promise對象的Fullfilled和Rejected狀態。當處於Fullfilled狀態時Promise會調用then方法,而處於Rejected狀態時則會調用catch方法,這兩個方法都會返回Promise對象,因此咱們能夠採用鏈式寫法:java

promise.then((value)=> {...})
    .catch((error)=> {...});

上面的方法鏈中,then方法裏註冊了Fullfilled狀態的處理函數、catch方法則註冊了Rejected狀態的處理函數。這種簡單明瞭的寫法把異步操做的結果處理函數分離了出來,若是這些處理自己又是異步操做,那咱們天然也就把層層異步回調也從回調地獄中剝離了,代碼瞬間清爽有木有!node

深刻Promise調用鏈

前面咱們只是將一層處理操做分離到then方法中(其中catch方法只是then方法的一個語法糖,後面會再做講解);但在實際應用中多個異步操做每每會以串行或並行的方式連續出現,好比下面這個預約房間的流程:es6

order-room

其中數據校驗、向API發送請求、往數據庫插入數據都是異步操做,一種用回調的寫法大概長這樣:ajax

validate(data, (err)=> {
    if (err) return errorHandler(err);
    request(apiUrl, (err, apiResponse)=> {
            if (err) return errorHandler(err);
            if (apiResponse.isSuccessful) insertToDB(data, (err)=> {
                    if (err) return errorHandler(err);
                    successHandler();
                });
            else errorHandler(new Error('API error'));
        });
    });

根據前面咱們瞭解的Promise用法,咱們已經能將validate這個異步操做寫成Promise形式了:數據庫

let promiseValidate = new Promise((resolve, reject)=> {
    validate(data, (err)=> {
        if (err) return reject(err);
        resolve();
        });
    });

promiseValidate(data)
    .then(()=> {
        request(apiUrl, (err, apiResponse)=> {
                if (err) return errorHandler(err);
                if (apiResponse.isSuccessful) insertToDB(data, (err)=> {
                        if (err) return errorHandler(err);
                        successHandler();
                    });
                else errorHandler(new Error('API error'));
            });
        })
    .catch((err)=> errorHandler(err));

但要改就改到底,上面這種Promise和回調寫法混合得就不三不四,除了仍存在回調嵌套的問題,屢次出現的錯誤判斷和處理也有點違反DRY。因此接下來咱們會深刻研究下Promise調用鏈的行爲,重點探討then方法裏註冊的回調對調用鏈上數據傳遞和Promise對象狀態變化的影響,以及如何在調用鏈上對錯誤進行統一的處理。segmentfault

Promise.resolve和Promise.reject

咱們先來看下一種「快速」生成Promise對象的方法:直接調用Promise.resolve(value)Promise.reject(err)。這種方法和new一個Promise對象的區別在於,Promise對象在生成的時候狀態就已經肯定,要麼是Fullfilled(使用Promise.resolve())、要麼是Rejected(使用Promise.reject()),不會和new實例化同樣等要異步操做完了再發生變化。api

此外,若是傳給Promise.resolve方法的是一個具備then方法的對象(即所謂的Thenable對象),好比jQuery的$.ajax(),那麼返回的Promise對象,後續調用的then就是原對象then方法的同一形式(參見下面的代碼)。簡單來說,就是Promise.resolve會將Thenable對象轉爲ES6的Promise對象,這一特性常被用來將Promise的不一樣實現轉換爲ES6實現。

$.ajax('https://httpbin.org/ip').then((value)=> {
    /* 輸出223.65.191.59 */
    console.log(value.origin)
    });

Promise.resolve($.ajax('https://httpbin.org/ip'))
    .then((value)=> {
        /* 輸出223.65.191.59 */
        console.log(value.origin)
        });

詳解Promise.prototype.then

有了前面知識的鋪墊,咱們終於能夠來詳細講一下Promise對象的then方法了。

參數

如前面所提到的,catch方法只是then方法的一個語法糖,
緣由就在於then方法的參數爲其實是「兩個」回調函數,分別用於處理調用它的Promise對象的Fullfilled和Rejected狀態,而catch方法就等價於then(undefined, Rejected狀態處理函數)

關於這兩個回調函數,首先要注意它們是異步調用的:

var v = 1;
/* 輸出result: 2 */
Promise.resolve().then(()=> {console.log('result: ' + v)});
/* 輸出result: 2 */
Promise.reject().then(undefined, ()=> {console.log('result: ' + v)});
v++;

而兩個回調函數的參數,則是經過調用then方法的Promise對象指定的:

  • new Promise()產生的Promise對象,會分別用內部resolve()reject()函數的參數

  • Promise.resolve()Promise.reject()產生的Promise對象,則分別用Promise.resolve()Promise.reject()的參數

而兩個回調函數的返回值,會用Promise.resolve(第一個回調返回值)Promise.reject(第二個回調返回值)的形式做包裝,用來「替換」then方法返回的Promise對象。結合上面提到的then回調函數參數指定方式,回調返回值會這樣影響下一個then的回調函數:

  • 返回的是普通數據,會傳給下一級調用的then方法做爲回調函數的參數

  • 返回的是Promise對象或Thenable對象,會被拿來「替換」then方法返回的Promise對象,具體then的回調函數怎麼調用和傳參就得看其內部實現了

返回值

一個新的Promise對象,狀態看執行哪一個回調函數決定。注意這是一個新對象,不是簡單把調用then的Promise對象拿來改裝後返回:

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

鏈式調用

知道了then方法的具體細節後,咱們就能明白Promise調用鏈上:

  • 傳遞數據的方法:利用上面提到的then回調的參數傳遞形式——不管是在Promise對象產生過程當中直接傳遞、仍是在then回調返回值中間接傳遞——就能實現將每一級異步操做的結果傳遞給後續then中註冊的處理函數處理。

  • Promise對象狀態傳遞和改變的方法:利用then回調的返回值,能夠控制某個操做後then方法返回的Promise對象及其狀態。

如今咱們把全部異步操做改成Promise語法,再利用在Promise調用鏈傳遞數據和控制狀態的方法,就能把本節開始提到的預約房間操做中的回調嵌套都展開來了:

let promiseValidate = new Promise((resolve, reject)=> {
    validate(data, (err)=> {
        if (err) return reject(err);
        resolve();
        });
    });

let promiseRequest = new Promise((resolve, reject)=> {
    request(data, (err, apiResponse)=> {
        if (err) return reject(err);
        // 在Promise對象產生過程當中直接傳遞異步操做的結果
        resolve(apiResponse);
        });
    }
);

let promiseInsertToDB = new Promise((resolve, reject)=> {
    insertToDB(data, (err)=> {
        if (err) return reject(err);
        resolve();
        });
    }
);

promiseValidate(data)
    .then(()=> promiseRequest(apiUrl))
    .then((apiResponse)=> {
        // 控制then回調的返回值,來改變then方法返回的新Promise對象的狀態
        if (apiResponse.isSuccessful) return insertToDB(data);
        else errorHandler(new Error('API error'));
        })
    .then(()=> successHandler())
    .catch((err)=> return errorHandler(err));

上面的代碼不只將嵌套的代碼展開,讓咱們掙脫了「回調地獄」;並且能夠對異步操做的錯誤直接利用統一的Promise錯誤處理方法,避免寫一堆重複的代碼。若是要進一步DRY,能夠抽象出一個將典型的Node.js回調接口封裝爲Promise接口的函數:

/* 處理形如 receiver.fn(...args, (err, res)=> {}) 的接口 */
let promisify = (fn, receiver) => {
  return (...args) => { // 返回從新封裝的Promise接口
    return new Promise((resolve, reject) => {
      fn.apply(receiver, [...args, (err, res) => { // 從新綁定this
        return err ? reject(err) : resolve(res);
      }]);
    });
  };
};

/* 用例 */
let promiseValidate = promisify(validate, global);
let promiseRequest = promisify(request, global);
let promiseInsertToDB = promisify(insertToDB, global);

注意,因爲resolve和reject方法只能接收一個參數,所上面這個函數處理的回調裏只能有err和一個數據參數。

Promise調用鏈上的錯誤處理

在Promise調用鏈上的處理錯誤的思路,就是去觸發Promise對象的Rejected狀態,利用狀態的傳遞特性實現對錯誤的捕獲,再在catchthen回調裏處理這些錯誤。下面咱們就來進行相關的探討:

錯誤的捕獲

首先咱們有必要詳細瞭解下Promise對象的Rejected狀態的產生和傳遞過程。

Rejected狀態的產生有兩種狀況:

  • 調用了reject函數:Promise對象實例化的回調調用了reject(),或者直接調用了Promise.reject()

  • 經過throw拋出錯誤

而只要產生了Rejected狀態,就會在調用鏈上持續傳遞,直到碰見Rejected狀態的處理回調(catch的回調或then的第二個回調)。再結合以前提到的Promise調用鏈上的數據傳遞方法,錯誤就能在調用鏈上做爲參數被相應的回調「捕獲」了。這個過程能夠參見下圖:

promise-reject-flow

這裏要注意,經過throw拋出錯時,若是錯誤是在setTimeout等的回調中拋出,是不會讓Promise對象產生Rejected狀態的,這也覺得着Promise調用鏈上捕獲不了這個錯誤。舉個例子,下面這段代碼就不會有任何輸出:

Promise.resolve()
    .then(()=> setTimeout(100, ()=> {throw new Error('hi')}))
    .catch((err)=> console.log(err));

究其緣由,是由於setTimeout的異步操做和Promise的異步操做不屬於同一種任務隊列,setTimeout回調裏的錯誤會直接拋到全局變成Uncaught Error,而不會做用到Promise對象及其調用鏈上。這就也意味着,想要保證在調用鏈上產生的錯誤能被捕獲,就必須始終使用調用reject函數的方式來產生和傳遞錯誤。

錯誤處理

錯誤處理能夠在catch的回調或then的第二個回調裏進行。雖然前面提到catch方法等價於then(undefined, Rejected狀態處理函數),但推薦始終使用catch來處理錯誤,緣由有兩個:

  • 代碼的可讀性

  • 對於then(Fullfilled狀處理函數, Rejected狀態的處理函數)這種寫法,若是Fullfilled狀態的處理函數裏出錯了,那錯誤只會繼續向下傳遞,同級的Rejected狀態處理函數沒辦法捕獲該錯誤

優化房間預訂例子的錯誤處理

瞭解完了Promise調用鏈上的錯誤處理,咱們再來回顧一開始提到的房間預訂例子。以前咱們的代碼裏只是對異步操做中的可能出現錯誤進行了統一的處理,可是其中的API error等別的執行錯誤並未使用在Promise調用鏈上捕獲和處理錯誤的方式。爲了進一步DRY,咱們能夠經過調用Promise.reject,強制將返回的Promise對象變爲Rejected狀態,共用統一的Promise錯誤處理:

(apiResponse)=> {
        if (apiResponse.isSuccessful) return insertToDB(data);
        // 返回的Promise對象爲Rejected狀態,共用統一的Promise錯誤處理
        else return Promise.reject(new Error('API error'));
        }

Promise.all和Promise.race

前面研究的多個異步操做間每每具備先後依賴關係,或者說它們是「串行」進行的,只有前一個完成了才能進行後一個。但有時咱們處理的異步操做間可能並不具備依賴關係,好比處理多張圖片,這時再使用上面的調用鏈寫法,就只能等處理完一張圖片、對應的Promise對象狀態變化了,才能再去處理下一張,就顯得很低效了。因此,咱們須要一種能在調用鏈中同時處理多個Promise對象的方法,Promise.allPromise.race就是這樣應運而生的。

這兩個方法的相同點是會接受一個Promise對象組成的數組做爲參數,包裝返回成一個新的Promise實例。而它們的區別就在於返回的這個Promise實例狀態如何變化:

  • Promise.all

    • 全部傳入的Promise對象狀態都變成Fullfilled,最終狀態纔會變成Fullfilled;此時便會調用Promise.resolve(各Promise對象resolve參數組成的數組),生成新狀態的Promise對象返回

    • 各個Promise對象如有一個被reject,最終狀態就變成Rejected;此時便會調用Promise.reject(第一個被reject的實例的reject參數),生成新狀態的Promise對象返回

  • Promise.race:只要傳入的各個Promise對象中有一個率先改變狀態(Fullfilled或Rejected),返回的Promise對象狀態就會改變爲相應狀態

有了這兩個方法,咱們就能在Promise調用鏈上「並行」等待某些異步操做了,仍是用前面提到的客房例子來舉例,若是咱們在預約房間時須要請求的API不止一個,調用鏈能夠這麼寫:

promiseValidate(data)
    /* 請求多個API */
    .then(()=> Promise.all([promiseRequest(apiUrl1), promiseRequest(apiUrl2)]))
    .then((apiResponse)=> {
        /* 傳給下個then回調的是一個resolve參數組成的數組 */
        if (apiResponse[0].isSuccessful && apiResponse[1].isSuccessful) return insertToDB(data);
        else return Promise.reject(new Error('API error'));
        })
    .then(()=> successHandler())
    .catch((err)=> return errorHandler(err));

Promise的應用

Promise是一種異步調用的寫法,天然是用來寫出清晰的異步代碼、讓咱們擺脫回調寫法帶來的種種弊端,本文一直使用的預約房間例子就是一個佐證。不過考慮實際的應用場景,仍是有一些須要注意的地方:

前端異步處理

前端的瀏覽器兼容性是阻礙新技術運用的一大難題,雖然使目前瀏覽器對於ES6的支持愈來愈完善了,但除非你不考慮IE(兼容性表),不然在前端代碼裏直接使用的原生的Promise實現並不太現實。對於這種狀況,咱們能夠用一些Polyfill或拓展類庫來讓咱們能寫Promise代碼。

Node的異步處理:

Node.js環境下對ES6的Promise支持,在零點幾版開始就有了,因此咱們在編寫服務器代碼、或者寫一些跑在Node上的模塊時能夠直接上Promise語法。不過要注意的是,Node上的大部分模塊開放的API,仍是默認使用回調風格,這是爲了方便用戶在不瞭解Promise語法時快速上手;因此通常本身寫的模塊API也會遵循這個慣例,至於模塊內部實現那就隨你的意願使用了。

還有一個要值得注意的是,最近Node實現了更優雅的異步寫法--async函數,不過新的寫法是基於Promise實現的,因此雖然async函數的出現讓Promise有種高不成低不就的感受,但瞭解Promise的用法仍是頗有必要的,但願本文能幫你作到這點:D。

參考

JavaScript Promise迷你書
Promise 的鏈式調用與停止
如何把 Callback 接口包裝成 Promise 接口

相關文章
相關標籤/搜索