1. 程式人生 > >promise 進階 —— async / await 結合 bluebird

promise 進階 —— async / await 結合 bluebird

一、背景


1、Node.js 非同步控制

在之前寫的 callback vs async.js vs promise vs async / await 裡,我介紹了 ES6 的 promise 和 ES7 的 async / await 的基本用法。

可以肯定的是,node.js 的非同步控制(asynchronous JavaScript),promise 就是未來的主流,諸如 async.js 等非 promise 庫( async.js 基於 callback )終將被淘汰,而基於 promise 的第三方庫(Q、when、WinJS、RSVP.js)也會被 async / await 寫法取代。

延伸閱讀:知乎 - nodejs非同步控制「co、async、Q 、『es6原生promise』、then.js、bluebird」有何優缺點?最愛哪個?哪個簡單?

2、已經有 ES6 Promise + async / await 了,為什麼還要用 bluebird ?

但目前基於 async / await 的 promise 寫法還不是很強大。這裡可以考慮用 bluebird,它是一個第三方的 Promise 庫,比 async / await 更早誕生,但是完全相容,因為他們都是基於 Promises/A+ 的標準(下文會介紹)。

很多第三方的 promise 庫都是相容 ES6 promise 的,比如 Q 。

二、Promise 進階


1、Promise 前世今生

(1)定義

They describe an object that acts as a proxy for a result that is initially unknown, usually because the computation of its value is not yet complete.

有道翻譯:他們描述了一個物件,該物件充當最初未知的結果的代理,通常是因為其值的計算尚未完成。

“代理”這個詞用的挺好的。

(2)歷史

promise 一詞由丹尼爾·福瑞得曼和 David Wise 在1976年提出。

後來演化出別稱:future

delaydeferred,通常可以互換使用。

promise 起源於函數語言程式設計和相關範例(如邏輯程式設計 ),目的是將值(future)與其計算方式(promise)分離,從而允許更靈活地進行計算。

應用場景:

  • 並行化計算

  • 分散式計算

  • 編寫非同步程式,避免回撥地獄

(3)各語言支援

現在主流的語言對 future/promise 都有支援。

  • Java 5 中的 FutureTask(2004年公佈)

  • .NET 4.5 中的 async / await

  • Dart(2014)

  • Python(2015)

  • Hack(HHVM)

  • ECMAScript 7(JavaScript)

  • Scala

  • C++ 草案

  • ……

2、Promises/A+

官方:https://promisesaplus.com/

介紹:An open standard for sound, interoperable JavaScript promises—by implementers, for implementers.

可以理解成 javascript 中 關於 promise 的實現標準。

3、 拓展 - jQuery 中的 Promise

(1)介紹

從 jQuery 1.5.0 版本開始引入的一個新功能 —— deferred 物件。

注意:Deferred 雖然也是一種 promise 的實現,但是跟 Promise/A+ 並不相容。

但可以將其轉為標準的 promise,例如:

var jsPromise = Promise.resolve($.ajax('/whatever.json'))
(2)用法

因為 jQuery 現如今很少用到了,僅簡單介紹下 deferred 的用法吧。


1、以 ajax 操作為例:

$.ajax() 操作完成後,如果使用的是低於1.5.0版本的 jQuery,返回的是 XHR 物件,你沒法進行鏈式操作;如果高於 1.5.0 版本,返回的是 deferred 物件,可以進行鏈式操作。

# old
$.ajax({

    url: "test.html",

    success: function(){
      alert("哈哈,成功了!");
    },

    error:function(){
      alert("出錯啦!");
    }

  });

# new 
$.ajax("test.html")

  .done(function(){ alert("哈哈,成功了!"); })

  .fail(function(){ alert("出錯啦!"); });


2、其它

  • $.when() 類似 promise.all()

  • deferred.resolve()deferred.reject() 類似 Promise.resolve()、Promise.reject()

  • ……

三、bluebird


1、介紹

英文文件:

http://bluebirdjs.com/docs/api-reference.html

中文文件:

https://itbilu.com/nodejs/npm/VJHw6ScNb.html

2、安裝

npm install bluebird

3、使用

const Promise = require('bluebird')

這樣寫會覆蓋原生的 Promise 物件。

4、早期原生效能問題

早期 js 標準庫裡並沒有包含 Promise,所以被迫只能用第三方的 Promise 庫,例如 bluebird。

後來 ES6 和 ES7 相繼推出了原生的 Promise 和 async/await ,但效能很差,大家還習慣用例如bluebird。

但到了 Node.js v8.x ,原生效能已經得到了很大的優化,可以不需要使用 bluebird 這樣的第三方 Promise 庫。(除非需要用到 bluebird 的更多 feature,而原生是不具備的。這個下面會詳細介紹)

詳情可以參考這篇文章:Node 8:迎接 async await 新時代

四、bluebird 用法


這一章,會結合 bluebird 用法 和 原生(主要以 ES7 的 async / wait) 探討出最優寫法。

1、回撥形式 -> Promise 形式

大部分 NodeJS 的標準庫 API 和不少第三方庫的 API 都使用了回撥方法的模式,也就是在執行非同步操作時,需要傳入一個回撥方法來接受操作的執行結果和可能出現的錯誤。

例如 NodeJS 的標準庫中的 fs 模組:

const fs = require('fs'),
 path = require('path');

fs.readFile(path.join(__dirname, 'sample.txt'), 'utf-8', (err, data) => {
 if (err) {
   console.error(err);
 } else {
   console.log(data);
 }
});
(1)bluebird

對於這樣的方法,bluebird 的 promisifyAll()promisify() 可以很容易的將它們轉換成使用 Promise 的形式。

// 覆蓋了原生的Promise
const Promise = require('bluebird'),
    fs = require('fs'),
    path = require('path');

// 1、promisifyAll
// Promise.promisifyAll 方法可以為一個物件的屬性中的所有方法建立一個對應的使用 Promise 的版本
Promise.promisifyAll(fs);
// 這些新建立方法的名稱在已有方法的名稱後加上"Async"字尾
// (除了 readFile 對應的 readFileAsync,fs 中的其他方法也都有了對應的 Async 版本,如 writeFileAsync 和 fstatAsync 等)
fs.readFileAsync(path.join(__dirname, 'sample.txt'), 'utf-8')
    .then(data => console.log(data))
    .catch(err => console.error(err));

// 2、promisify
// Promise.promisify 方法可以為單獨的方法建立一個對應的使用 Promise 的版本
let readFileAsync = Promise.promisify(fs.readFile)
readFileAsync(path.join(__dirname, 'sample.txt'), 'utf-8')
    .then(data => console.log(data))
    .catch(err => console.error(err));
(2)原生

在 node.js 8.x版本中,可以用 util.promisify() 實現 promisify() 一樣的功能。

在官方推出這個工具之前,民間已經有很多類似的工具了,除了bluebird.promisify,還有比如es6-promisify、thenify。

2、使用 promise —— .finally()

.finally() 可以避免同樣的語句需要在 then() 和 catch() 中各寫一次的情況。

(1)bluebird
Promise.reject(new TypeError('some error'))
  .catch(TypeError, console.error)
  .finally(() => console.log('done'));
(2)自己實現
Promise.prototype.finally = function (callback) {
  return this.then(function (value) {
    return Promise.resolve(callback()).then(function () {
      return value;
    });
  }, function (err) {
    return Promise.resolve(callback()).then(function () {
      throw err;
    });
  });
}; 
(3)async / await

用 try...catch...finally 的 finally 即可實現。

(4)原生

.finally() 是ES2018(ES9)的新特性。

3、使用 promise —— .cancel()

(1)bluebird

當一個 Promise 物件被 .cancel() 之後,只是其回撥方法都不會被呼叫,並不會取消正在進行的非同步操作。

// 先修改全域性配置,讓 promise 可被撤銷
Promise.config({
    cancellation: true, // 預設為 false
});

// 構造一個 promise 物件,並設定 1000 ms 延遲
let promise = Promise.resolve("hello").then((value) => {
    console.log("promise 的 async function 還是執行了……")
    return value
}).delay(1000)

// promise 物件上繫結回撥函式
promise.then(value => console.log(value))

// 取消這個 promise 物件的回撥
setTimeout(() => {
    promise.cancel();
}, 500);

輸出:
promise 的 async function 還是執行了……

這裡提到的 .delay() 方法下面會介紹。

(2)async / await

可以通過對 async / await 函式呼叫後的返回值,做 if 判斷,決定要不要執行接下來的邏輯。

4、處理 promise 集合

之前的程式碼示例都針對單個 Promise。在實際中,經常會處理與多個 Promise 的關係。

(1)bluebird

以 fs 模組分別讀取 sample1.txtsample2.txtsample3.txt 三個檔案的內容為例。他們的檔案內容分別為 “1”、“2”、“3”。


const Promise = require('bluebird'),
    fs = require('fs'),
    path = require('path');
Promise.promisifyAll(fs);

// 一、並行操作

// 1、Promise.all ,必須全部成功才通過 【保證返回順序】
Promise.all([
    fs.readFileAsync(path.join(__dirname, 'sample1.txt'), 'utf-8'),
    fs.readFileAsync(path.join(__dirname, 'sample2.txt'), 'utf-8'),
    fs.readFileAsync(path.join(__dirname, 'sample3.txt'), 'utf-8')
]).then(results => console.log(results.join(', '))).catch(console.error);

// 1.1、Promise.props ,約等於 Promise.all,但不同的在於: 返回的不是陣列而是物件 !
Promise.props({
    app1: fs.readFileAsync(path.join(__dirname, 'sample1.txt'), 'utf-8'),
    app2: fs.readFileAsync(path.join(__dirname, 'sample2.txt'), 'utf-8'),
    app3: fs.readFileAsync(path.join(__dirname, 'sample3.txt'), 'utf-8'),
}).then(results => console.log(results)).catch(console.error);

// 1.2 Promise.join,約等於 Promise.all 【保證返回順序】, 但不同的在於: 成功結果不是 array 而是多個引數 !
Promise.join(
    fs.readFileAsync(path.join(__dirname, 'sample1.txt'), 'utf-8'),
    fs.readFileAsync(path.join(__dirname, 'sample2.txt'), 'utf-8'),
    fs.readFileAsync(path.join(__dirname, 'sample3.txt'), 'utf-8'),
    (a, b, c) => console.log(a, b, c));

// 1.3、Promise.filter ,約等於 Promise.all 之後對成功結果的 Array 進行 filter 過濾 【保證返回順序】 
Promise.filter([
    fs.readFileAsync(path.join(__dirname, 'sample1.txt'), 'utf-8'),
    fs.readFileAsync(path.join(__dirname, 'sample2.txt'), 'utf-8'),
    fs.readFileAsync(path.join(__dirname, 'sample3.txt'), 'utf-8')
], value => value > 1).then(results => console.log(results.join(', '))).catch(console.error);

// ----------

// 2、Promise.map ,約等於 Promise.all 【保證返回順序】
Promise.map(['sample1.txt', 'sample2.txt', 'sample3.txt'],
    name => fs.readFileAsync(path.join(__dirname, name), 'utf-8')
).then(results => console.log(results.join(', '))).catch(console.error);

// 2.1 Promise.reduce,約等於 Promise.map 
Promise.reduce(['sample1.txt', 'sample2.txt', 'sample3.txt'],
 (total, name) => {
   return fs.readFileAsync(path.join(__dirname, name), 'utf-8').then(data => total + parseInt(data));
 }
, 0).then(result => console.log(`Total size: ${result}`)).catch(console.error);

// ----------

// 3、Promise.some 只要成功 N 個就通過 【不保證返回順序】
Promise.some([
    fs.readFileAsync(path.join(__dirname, 'sample1.txt'), 'utf-8'),
    fs.readFileAsync(path.join(__dirname, 'sample2.txt'), 'utf-8'),
    fs.readFileAsync(path.join(__dirname, 'sample3.txt'), 'utf-8')
   ], 3).then(results => console.log(results.join(', '))).catch(console.error);

// 3.1、Promise.any 只要成功 1 個就通過,約等於 Promise.some (N = 1),但不同的在於:返回的不是陣列而是單個值了!
Promise.any([
    fs.readFileAsync(path.join(__dirname, 'sample1.txt'), 'utf-8'),
    fs.readFileAsync(path.join(__dirname, 'sample2.txt'), 'utf-8'),
    fs.readFileAsync(path.join(__dirname, 'sample3.txt'), 'utf-8')
]).then(results => console.log(results)).catch(console.error);

// 3.2、Promise.race 只要成功 1 個就通過,約等於 Promise.any (N = 1),但不同的在於:如果成功返回前遇到了失敗,則會不通過!
Promise.race([
    fs.readFileAsync(path.join(__dirname, 'sample1.txt'), 'utf-8'),
    fs.readFileAsync(path.join(__dirname, 'sample2.txt'), 'utf-8'),
    fs.readFileAsync(path.join(__dirname, 'sample3.txt'), 'utf-8')
]).then(results => console.log(results)).catch(console.error);

// ----------

// 二、序列

// 4、Promise.mapSeries ,約等於 Promise.map 【保證返回順序】,但不同的在於: 這是序列不是並行!
Promise.mapSeries(['sample1.txt', 'sample2.txt', 'sample3.txt'],
    name => fs.readFileAsync(path.join(__dirname, name), 'utf-8').then(function(fileContents) {  
        return name + "!";
    })
).then(results => console.log(results.join(', '))).catch(console.error);
// 'sample1.txt!, sample2.txt!, sample3.txt!'

// 4.1、Promise.each ,約等於 Promise.mapSeries 【保證返回順序】, 但不同的在於: 只是單純的遍歷,每次迴圈的 return 毫無影響 !
Promise.each(['sample1.txt', 'sample2.txt', 'sample3.txt'],
    name => fs.readFileAsync(path.join(__dirname, name), 'utf-8').then(function(fileContents) { 
        return name + "!";  // 無效
    })
).then(results => console.log(results.join(', '))).catch(console.error);
// 'sample1.txt, sample2.txt, sample3.txt'

1、大多數函式都是並行的。其中 map、filter 還有 Concurrency coordination (併發協調)功能。

注意:

1、因為 Node.js 是單執行緒,這裡的併發只是針對 promise 而言,實際上底層還是序列。

2、併發數的多少,取決於你 promise 執行的具體功能,如網路請求、資料庫連線等。需根據實際情況來設定。

以 map 為例:

// 控制併發數
Promise.map(['sample1.txt', 'sample2.txt', 'sample3.txt'],
    name => fs.readFileAsync(path.join(__dirname, name), 'utf-8'),
    {concurrency: 2}
).then(results => console.log(results.join(', '))).catch(console.error);

2、mapSeries、each 是序列,也可以看成是 {concurrency: 1} 的特例。

(2)拓展 - promiseAll 實現原理
function promiseAll(promises) {
  return new Promise(function(resolve, reject) {
    if (!isArray(promises)) {
      return reject(new TypeError('arguments must be an array'));
    }
    var resolvedCounter = 0;
    var promiseNum = promises.length;
    var resolvedValues = new Array(promiseNum);
    for (var i = 0; i < promiseNum; i++) {
      (function(i) {
        Promise.resolve(promises[i]).then(function(value) {
          resolvedCounter++
          resolvedValues[i] = value
          if (resolvedCounter == promiseNum) {
            return resolve(resolvedValues)
          }
        }, function(reason) {
          return reject(reason)
        })
      })(i)
    }
  })
}

注意:Promise.resolve(promises[i])這段的意思,是防止 promises[i] 為非 promise 物件,而強制轉成 promise 物件。

此原始碼地址為: promise-all-simple

(3)async / await

對於上面的並行操作,建議用 bluebird (原生貌似現在只支援 Promise.all() ,太少了)。

對於上面的序列操作,可以用 迴圈 搭配 async / await 即可。

5、資源使用與釋放

如果在 Promise 中使用了需要釋放的資源,如資料庫連線,我們需要確保這些資源被應有的釋放。

(1)bluebird

方法1:finally() 中新增資源釋放的程式碼(上文有介紹)

方法2【推薦】:使用資源釋放器(disposer)和 Promise.using()。

(2)async / await

利用 async / await 中的 try...catch...finally 中的 finally 。

6、定時器

(1)bluebird
async function test() {
    try {
        let readFilePromise = new Promise((resolve, reject) => {resolve('result')})
        let result = await readFilePromise.delay(1000).timeout(2000, 'timed out') 
        console.log(result);
    } catch (err) {
        console.log("error", err);  
    }
}

test();

1、預設的, new Promise 會立即執行,但是加了 delay(),可以延遲執行。

2、timeout() 可以設定執行的 timeout 時間,超過即丟擲 TimeoutError 錯誤。

(2)async / await

暫時沒有方便的替代寫法。

7、實用方法

(1)bluebird

bluebird 的 Promise 中還包含了一些實用方法。taptapCatch 分別用來檢視 Promise 中的結果和出現的錯誤。這兩個方法中的處理方法不會影響 Promise 的結果,適合用來執行日誌記錄。call 用來呼叫 Promise 結果物件中的方法。get 用來獲取 Promise 結果物件中的屬性值。return 用來改變 Promise 的結果。throw 用來丟擲錯誤。catchReturn 用來在捕獲錯誤之後,改變 Promise 的值。catchThrow 用來在捕獲錯誤之後,丟擲新的錯誤。

(2)async / await

上面 bluebird 的實用方法,在 async / await 的寫法裡,顯得無足輕重了。

8、錯誤處理

(1)拓展 - then() 的多次指定與報錯

對一個 resolve 的 promise ,指定多個 then:

let promiseObj = new Promise((resolve, reject) => {resolve()})

// 第一次指定 then
promiseObj.then(function (data) {
    console.log("success1");
}, function (data) {
    console.log("fail1");
})
// 第二次指定 then
promiseObj.then(function (data) {
    console.log("success2");
}, function (data) {
    console.log("fail2");
})

// 第三次指定 then
promiseObj.then(function (data) {
    console.log("success3");
})

// 第四次指定 then(catch)
promiseObj.catch(function (data) {
    console.log("fail4");
})

輸出:
success1
success2
success3

對一個 reject 的 promise ,指定多個 then:

let promiseObj = new Promise((resolve, reject) => {reject()})

// 第一次指定 then
promiseObj.then(function (data) {
    console.log("success1");
}, function (data) {
    console.log("fail1");
})
// 第二次指定 then
promiseObj.then(function (data) {
    console.log("success2");
}, function (data) {
    console.log("fail2");
})

// 第三次指定 then
promiseObj.then(function (data) {
    console.log("success3");
})

// 第四次指定 then(catch)
promiseObj.catch(function (data) {
    console.log("fail4");
})

輸出:
fail1
fail2
fail4
Unhandled rejection undefined

結論:

1、對於一個 promise 物件,我們可以多次指定它的 then()。

2、當此 promise 狀態變為 resolve,即使沒有 then() 或者 有 then() 但是沒有 successCallback,也不會有問題。

3、當此 promise 狀態變為 reject, 如果沒有 then() 或者有 then() 但是沒有 failureCallback ,則會報錯(下面會介紹如何捕獲這個錯)。

(2)bluebird

1、本地錯誤處理

利用 then() 的 failureCallback(或 .catch() )。不贅述了。


2、全域性錯誤處理

bluebird 提供了 promise 被拒絕相關的兩個全域性事件,分別是 unhandledRejectionrejectionHandled

let promiseObj = new Promise((resolve, reject) => {reject('colin')})

setTimeout(() => {
    promiseObj.catch(function (data) {
        console.log("fail");
    })
}, 2000);


process.on('unhandledRejection', (reason, promise) => console.error(`unhandledRejection ${reason}`));

process.on('rejectionHandled', (reason, promise) => console.error(`rejectionHandled ${reason}`));

輸出:
unhandledRejection colin
rejectionHandled [object Promise]
fail

1、promise 的 reject 沒有被處理(即上面所述),則會觸發 unhandledRejection 事件

2、但可能 針對 reject 的處理延遲到了下一個事件迴圈才被執行,那就會觸發 rejectionHandled 事件

所以我們得多等等 rejectionHandled 事件,防止誤判,所以可以寫成下面全域性錯誤處理的程式碼:

let possiblyUnhandledRejections = new Map();
// 當一個拒絕未被處理,將其新增到 map
process.on("unhandledRejection", function(reason, promise) {
    possiblyUnhandledRejections.set(promise, reason);
});
process.on("rejectionHandled", function(promise) {
    possiblyUnhandledRejections.delete(promise);
});
setInterval(function() {
    possiblyUnhandledRejections.forEach(function(reason, promise) {
        // 做點事來處理這些拒絕
        handleRejection(promise, reason);
    });
    possiblyUnhandledRejections.clear();
}, 60000);
(3)async / await 的錯誤處理

async / await 的 try..catch 並不能完全捕獲到所有的錯誤。


1、本地錯誤處理

用 try...catch 即可。

注意:漏掉錯誤 情況:

run() 這個 promise 本身 reject 了

async function run() {
    try {
        // 注意這裡沒有 await
        return Promise.reject();
    } catch (error) {
        console.log("error",error)
        // 程式碼不會執行到這裡
    }
}
run().catch((error) => {
    // 可以捕獲
    console.log("error2", error)
});

解決方法:針對 run() 函式 (頂層函式)做好 catch 捕獲。


2、全域性錯誤處理

漏掉錯誤 情況:

run() 這個 promise 內部存在 reject 但沒有被處理的 promise

async function run() {
    try {
        // 注意這裡 即沒有 await 也沒有 return
        Promise.reject();
    } catch (error) {
        console.log("error", error)
        // 程式碼不會執行到這裡
    }
}
run().catch((error) => {
    // 不可以捕獲
    console.log("error2", error)
});

解決方法:

1、跟上面介紹的 bluebird 全域性錯誤處理一樣,用好unhandledRejectionrejectionHandled 全域性事件。

2、ES6 原生也支援 unhandledRejectionrejectionHandled 全域性事件。


參考資料

使用 bluebird 實現更強大的 Prom