async, await and try catch

這篇想說一下asyncawait語法的一些小細節,首先從async來說吧,一般來說,async function 是在內部有需要用await等 Promise 結果的時候才使用,也由於這個特性,async function 的回傳值都會是個 Promise,意思就是你回傳非 Promise 的值,會自動被包成 Promise,所以像下面的程式:

async function wow () {
    return Promise.resolve(100);
}

wow().then(v => { console.log(v); });

就等同於:

async function wow () {
    return 100;
}

wow().then(v => { console.log(v); });

和直接回 Promise value 比起來,效能上不會有什麼顯著差異,從建議的實做方法來看就是多一個判斷。再來看看await吧,首先一樣,await一般是用來接 Promise 的,不過其實也是可以接非 Promise value 的

async function wow () {
  var r = await 1;
  console.log(1);
}

wow();
console.log(2);

所以這樣的程式碼也可以正確執行,不過 await 那邊的執行方式還是會維持非同步的(實際上應該是後面的東西都會用 Promise 包起來一次),所以這段程式碼的輸出會是先輸出2再輸出1

再來這點可能比較多人知道,就是連續的多個await不會讓這些非同步操作同時開始:

async function wow () {
  const a = await fetch('/a');
  const b = await fetch('/b');
  const c = await fetch('/c');

  return [a, b, c];
}

這樣其實三個請求會照順序執行,a有結果了才去要bb有結果了才去要c,而不是同時處理,如果要同時發出請求則還是需要用Promise.all,然後不用async了:

function wow () {
  return Promise.all([
    fetch('/a'),
    fetch('/b'),
    fetch('/c')
  ]);
}

不要await的話,也是可以先 assign 給變數的:

function wow () {
  const a = fetch('/a');
  const b = fetch('/b');
  const c = fetch('/c');

  return Promise.all([a, b, c]);
}

然後其實Promise.all是要所有的 Promise 都 fulfilled 時才會 resolve,另外一個角度來看,就是其中只要一個 rejected 的話,就不會 resolve,實際上使用起來變化有點少,而且要做忽略錯誤的fetch也有點麻煩,所以現在 TC39 還有個新的草案叫 Promise.allSettled,不管是 resolve 還是 reject,只要所有參數內的 Promise 都結束了,allSettled就會 resolve,目前這草案還在 stage 1,過幾天的會議有望升到 stage 2,不過這是題外話。

最後一個想說的就是await處理 rejected Promise 的問題,如果是從 jQuery 時期就開始寫 Deferred/Promise 的人,可能會很習慣的把 Promise 的兩種狀態拿來當成值的一部份,事實上這也是jQuery.ajax的設計,如果用這種想法來寫await接值的時候,就會覺得很難處理rejected的狀態,因為要用try...catch

async function wow () {
  try {
    const a = await fetch('/a');     
  } catch (error) {
    // deal with non-ok fetch
  }
}

要這樣寫還不如用舊的.then來接看起來還漂亮一點。不過實際上,這是錯誤的理解 Promise,Promise 不是用來取得兩種狀態用的,而是用來非同步取得單一個數值用的機制,而所謂rejected的狀態,其實就是發生非預期狀況(unexpected exception)的情形,這也就是為什麼 ECMAScript 版的 Promise 是用throw Error的方式來 reject Promise。

我一直覺得用 HTTP 請求來比較這兩種設計蠻好理解的,使用 jQuery 的ajax,server 端回非 200 的 status 的話,就會被當成是錯誤,然後回傳的 Promise 就會被 reject,但是在使用 ECMAScript Promise 的 fetch 中,不管 server 端回應的 status code,fetch 都會 resolve,而會 reject 的情形,就只有網路有問題的時候,像是網路斷線、存取被拒絕(CORS)等完全碰不到遠端主機的情形,也就是對於一個 HTTP 請求來說,真正的非預期狀況,所以如果你有兩種狀況要處理,那應該是回傳值的一部份,後面再用if...else來做分支。

回來看await的使用,究竟應該什麼時候來用try...catch呢,我自己有一個很簡單的初步判斷條件,就是這個取值的程式碼,如果不是非同步,沒有使用await的話,你會不會用try...catch包起來,不會的話,那改成非同步操作的程式碼應該也不用try...catch。不過現實世界當然還是比較難一點,非同步的取值風險和狀況還是比較多的,例如fetch遇到網路問題會 reject,但是還是需要處理這種狀況,不用try...catch的話,怎樣寫比較好呢?我的想法是,用.then/catch處理好需要處理的情形,然後把結果包起來傳回去,所以要處理fetch的非預期狀況的話,就可以改成:

async function wow () {
  const a = await fetch('/a').catch(error => {
    return {
      ok: false,
      status: -1,
      error: error,  
    };
  });
  
  if (a.status === -1) {
    // exception error handling
  }
}

這邊我設計成有非預期狀況時,status code 為-1,並且把 error 資訊也傳回去,然後後面就可以直接拿來判斷是不是非預期狀況,當然也可以把這個處理包成一個自己的myFetch

const myFetch = (url, options) =>
  fetch(url, options)
    .catch(error => {
      ok: false,
      status: -1,
      error: error,  
    });

然後原來的程式就可以直接拿myFetch取代fetch了。

如果要通用一點的,其實有一個叫 await-to-js 的套件我覺得蠻不錯的,直接拿官方的範例看吧:

import to from 'await-to-js';

async function asyncTaskWithCb(cb) {
  let [err, user] = await to(UserModel.findById(1));
  if (!user) return cb('No user found');
}

它可以包裝 Promise 物件,然後不管那個 Promise 成功還是失敗,它自己都會 resolve,resolve 的值就是[error, value]這樣形式的陣列,一來符合 node 的 error-first callbacks,再來就是配合 destructuring assignment 其實程式碼是蠻漂亮的。