今天要來談怎麼利用計時器(timer)函式setTimeout做出非同步執行的函數和用 Promise 包裝非同步函數。

回憶

昨天我們提到 Node.js 非阻塞的特色,其實可以說 Node.js 到處都是非同步執行。

目標

今天要來談怎麼利用計時器(timer)函式setTimeout做出非同步執行的函數和用 Promise 包裝非同步函數。

自己做的非同步執行的函數:由同步轉成非同步

我們可以由同步的程式,改寫成非同步函數。

  1. 考慮以下同步函數,addSum 是累加函數

    1// 同步
    2function addSum(numbers) {
    3    let sum = 0;
    4    numbers.forEach(number => sum = sum + number);
    5    return sum;
    6}
    7const numbers = [1, 2];
    8console.log(addSum(numbers)); // 3
    
  2. 先想介面:怎麼回傳?透過一個 callback function

     1// 同步
     2function addSum(numbers, callback) {
     3  let sum = 0;
     4  numbers.forEach(number => sum = sum + number);
     5  callback(sum);
     6}
     7const numbers = [1, 2];
     8addSum(numbers, sum => {
     9  console.log(sum);
    10})
    11console.log('done');
    

    結果:

    13
    2done
    

    addSum(numbers, callback)看起來是不是有非同步的影子了阿。但他其實還是同步函數,只是在內部呼叫 callback(sum)

  3. 調用系統計時器 setTimeout()

     1// 非同步
     2function addSum(numbers, callback) {
     3  setTimeout(() => {
     4    let sum = 0;
     5    numbers.forEach(number => sum = sum + number);
     6    callback(sum);
     7  }, 0);
     8}
     9const numbers = [1, 2];
    10addSum(numbers, sum => {
    11  console.log(sum);
    12})
    13console.log('done');
    

    結果:

    1done
    23
    

    雖然我們設定 0 秒,但不是指馬上執行,setTimeout() 會把裡面的 callback 放到 event loop 中的 queue (見 Day 12 - 二周目 - 準備起程深入後端)。當 addSum() 執行完後,console.log('done')印出 done,然後 event loop 就再迴圈一次,重新執行所有 queue 中的 callback function,發現我們有送入

    1() => {
    2  let sum = 0;
    3  numbers.forEach(number => sum = sum + number);
    4  callback(sum);
    5}
    

    就開始執行,callback 這變數因為箭頭函數產生閉包 (見:Day 5 - 一周目- 從VSCode debug 模式看作用域(Scope)、this、閉包(Closure)),如下圖:

    Screen Shot 2018-10-13 at 8.24.01 PM.png

    callback有值,值是我們送入的

    1sum => {
    2  console.log(sum);
    3}
    

    所以 callback(sum) 最後印出 3

這樣就完成了把同步函數 addSum(numbers) 轉成非同步函數 addSum(numbers, callback)

惡搞:你可以把 setTimeout() 改成 setInterval()setInterval() 會一直在指定時間內重做(直到你 clearInterval()),我們改成 1000 ms,就會一直印出 3。 Screen Shot 2018-10-13 at 8.29.22 PM.png 你可以想想為什麼程式停不了?因為 event loop 的 timer queue 的 callback 一直在的關係,queue 永遠非空。

需要自己做的非同步函數的情況,我還真的比較少發生,因為這表示你用到大量的CPU計算,這時候你應該開子行程(subprocess)處理,主行程改用非同步呼叫子行程處理,這個技巧未來會提。反而,大部分是要重新包裝非同步函數,或使用別人的非同步函數。

在 Node.js 中,也提供 nextTick, setImmediate,它們都可以用來做非同步函數,可以看以下文章 1. 詳解 setTimeout、setImmediate、process.nextTick 的區別 2. Node探秘之事件循環(2)–setTimeout/setImmediate/process.nextTick的差別

包裝非同步函數

非同步操作如下(截錄自從Promise開始的JavaScript異步生活)

  1. 使用計時器(timer)函式: setTimeout, setInterval
  2. 特殊的函式: nextTick, setImmediate
  3. 執行I/O: 監聽網路、資料庫查詢或讀寫外部資源
  4. 訂閱事件:常用 on('event name', callback) 訂閱

1,2 常用來引起非同步,而 3,4 常用需要包裝非同步函數,使用我們的後端更有可讀性。

包裝神器 Promise

Promise 是一個物件,它遵從Promises/A+標準,下圖截錄自 Promises/A+標準定義 IMAGE Promise 建立時是 pending 狀態,它可收到 value (透過resolve(value)) 就被鎖定成 fulfilled,不然就是收到 reason (透過reject(reason), reason 一般是 Error 物件)就被鎖定成 rejected。鎖定後再也動不了。

Promise物件的建立方法如下:

1new Promise((resolve, reject) => {
2});

(resolve, reject) => {} 這是 Promise 物件建立時就要立刻傳入的東西,也會立刻執行。 這裡我們可以用 resolve(value) 通知此 Promise resolve,也可以用 reject(error) 通知此 Promise reject。

用 Promse 包裝 addSum(number, callback):改變函數簽章 addSumPromise(numbers)

我們曾提過 Pomise 方便的地方在於可以用 then().catch() 鍊式語法,所以我們把 addSum(numbers, callback)包裝成 addSumPromise(numbers),它回傳 Promise 物件,就可以用鍊式語法

 1// 包裝非同步回傳 Promise
 2function addSum(numbers, callback) {
 3  setTimeout(() => {
 4    let sum = 0;
 5    numbers.forEach(number => sum = sum + number);
 6    callback(sum);
 7  }, 0);
 8}
 9function addSumPromise(numbers) {
10  return new Promise((resolve, reject) => {
11    addSum(numbers, sum => {
12      resolve(sum);
13    })
14  });
15}
16const numbers = [1, 2];
17addSumPromise(numbers)
18  .then(sum => {
19    console.log(sum);
20  })
21console.log('done');

我們利用回傳 Promise 物件,可以把 callback 參數拿掉,就可以享有鍊式語法。

強化 addSumPromise() 參數處理

有沒有發現我們的 addSum() 很脆弱,可以加入一些判斷使它強健一點。例如我們要確保 numbers 是 Array,若不是的話就回傳的 reject promise

 1// 包裝非同步回傳 Promise
 2function addSum(numbers, callback) {
 3  setTimeout(() => {
 4    let sum = 0;
 5    numbers.forEach(number => sum = sum + number);
 6    callback(sum);
 7  }, 0);
 8}
 9function addSumPromise(numbers) {
10  return new Promise((resolve, reject) => {
11    if(!Array.isArray(numbers)) {
12      reject(new Error('numbers is not a Array'));
13      return; // 這行要寫,雖然Promise 狀態不變,但下面的程式一樣會執行
14    }
15    addSum(numbers, sum => {
16      resolve(sum);
17    })
18  });
19}
20const numbers = {};
21addSumPromise(numbers)
22  .then(sum => {
23    console.log(sum);
24  })
25console.log('done');

就可以得到 Screen Shot 2018-10-13 at 9.21.54 PM.png addSumPromise({}) 會得到一個 reject promise。我們注意以下幾點:

  1. console.log('done') 一樣有執行,不會因為 reject promise。
  2. 新版的Node.js 現在會告訢你有一個未處理的 Promise,且這 Error 不是例外發生,不會引起程式插斷(interrupts),執行會一直下去。因此,你不處理的話就這錯誤就不見了。

所以改成下面

1addSumPromise(numbers)
2  .then(sum => {
3    console.log(sum);
4  })
5  .catch(error => {
6    console.error(error);
7  })

這麼一來 Promise 就處理完所有的非同步的情況了。

小總結

我們舉了 addSum(numbers, callback)為例子,再用 Promise 包裝改成像是 addSum(number)。早期非同步函數中,常常有 callback function 當參數,callback 的簽章一般是(err, value) => ...,常會利用上述方法包裝成 Promise 的版本方便使用。另外,Node.js 也提供 util.promisify() 快速轉換。

最近的套件有時也會同時提供兩種版本(callback版或回傳Promise),像是 Node.js MongoDB Driver API 或是 fs-extrafs-extra 我會拿來取代原生的 fs,它也提供好用的函數。另外,在Node.js 10 以後,fs.promises 也開始支援 Promise版。

then().catch() 鍊式語法:Promise 物件好用之處

then().catch() 可以說是 Promise 的核心之一,then()catch()函數被 Promises/A+ 規定要回傳 Promise,他可以讓我們的非同步操作串接起來。

then(callback)/catch(callback)的 callback 叫起與回傳

then(callback)/catch(callback) 中的 callback 被叫起:

  1. aPromise.then(callback):當 aPromise resolve時,resolve value 送入 callback(value)
  2. aPromise.catch(callback):當 aPromise reject,reject reason 送入 callback(reason)

then(callback)/catch(callback) 中的 callback 可以回傳:

  1. 回傳 Primitive值:如undefined, object, number, string…等,then() 會回傳 resolve promise
    1  .then(() => ({name: 'Billy'})) // resolve promise
    
  2. 回傳 Promise:then()會和回傳的 promise 同樣狀態
    1  .then(() => Promise.resolve()) // resolve promise
    
    1  .then(() => Promise.reject()) // reject promise
    

這裡的:Promise.resolve() 直接回傳 reolve promise 物件;Promise.reject() 直接回傳 reject promise 物件

其實:then()的完整簽章是:then(resoveCallback, rejectCallback),這個我比較少用,反而常用then(resoveCallback).catch(rejectCallback)

一個鍊式例子

例如,我們要查詢一個人的訂單要做兩個非同步的查詢:

  1. fetchPerson(name) - 查人
  2. fetchOrders(person) - 查此人的訂單
 1// then() 鍊式
 2function fetchOrders(person) {
 3  const orders = person.orderIds.map(id => ({ id }));
 4  return Promise.resolve(orders); // 直接回傳 reolve promise 物件
 5}
 6
 7function fetchPerson(name) {
 8  // return Promise.reject(new Error('name is not string')); // 直接回傳 reject promise 物件
 9  return Promise.resolve({
10    name,
11    orderIds: ['A', 'B']
12  });
13}
14
15fetchPerson('Billy')
16    .then(fetchOrders)
17    .then(orders => {
18      orders.forEach(order => {
19        console.log(order);
20      })
21    })
22    .catch(console.error);

Screen Shot 2018-10-13 at 10.20.34 PM.png

我們利用 then() 把非同步操作串接起來,且 fetchPerson()fetchOrders()任一個Promise 發生 reject 才會引起 .catch() 發生。若前面的任一個Promise reject,後面的 then() 都不會發生直到 catch()。(你可以把解開註解看看 // return Promise.reject(new Error('name is not string'));

.catch()常犯的錯:.catch(callback) 可能是回傳 resolve promise

在用鍊式時你可能會想要截斷某個reject,查看結果,常會寫以下的程式

1Promise.resolve(1)
2  .then(() => Promise.reject(new Error('error 1')))
3  .catch(console.error)
4  .then(() => Promise.resolve(2))
5  .then(console.log)
6  .catch(console.error)

這結果是

1Error: error 1
22

這是因為 .catch(console.error) 寫清楚一點就是

1function catch(error) => {
2  return console.error(error); // console.error() 回傳 undefined
3}

因為回傳 undefined,所以 .catch() 的回傳是 resolve promise,這使下一行的 .then(() => Promise.resolve(2))執行。因此,你若要保持 reject 往下傳,要用 Promise.reject()

1Promise.resolve(1)
2  .then(() => Promise.reject(new Error('error 1')))
3  .catch(error => {
4    console.error(error);
5    return Promise.reject(error);
6  })
7  .then(() => Promise.resolve(2))
8  .then(console.log)
9  .catch(console.error)

才會得到

1Error: error 1
2Error: error 1

Promise 的例發發生會導致 reject

一但進入 Promise後在內部的執行不論在哪丟出例外都會導致 promise reject

 1// Promise 的例外發生
 2function somePromise() {
 3  return new Promise((resolve, reject) => {
 4    // throw new Error('constructor error');
 5    resolve();
 6  });
 7}
 8somePromise()
 9  .then(sum => {
10    // throw new Error('resolve error');
11    console.log('done');
12  })
13  .catch(error => {
14    console.error(error);
15  });

你可以解開註解看看,都是會產生 reject promise。

這事實可以看成是,Promise 幫我們包住了所有例外,某方面降低了程式當掉的可能。

總結

今天如何利用 setTimeout() 做非同步函數,還用 Promise 包裝它,且介紹 then().catch()鍊式語法。