去除 try/catch,實作簡潔的 Async 和 Await!
22 Jul 2020Async 和 Await 提供一種語法糖來撰寫非同步程式碼而看起來很像是同步的樣子,而在這之前若想實作非同步都是使用 callback 或 promise 的方式,產生的問題不外乎是難以閱讀的 callback hell。但,用了 Async 和 Await 就真的能讓程式碼更精簡流暢嗎? ( •́ _ •̀)?
來看一個實際範例,如下所示,doThings 裡面會依序做一些事情…
- 第一步,做 checkDataSourceExist。
- 第二步,做 fetchDataSource,並將結果存到變數 data。
- 第三步,做 renderGraph。
- 其中,若第一步或第二步有任何錯誤,都會進入到 catch 來執行 fetchAnotherDataSource,並顯示訊息「something goes wrong, need to get data from another source」;又 fetchAnotherDataSource 出錯就會再進入更內層的 catch 來執行 displayErrorPage 並顯示訊息「still something goes wrong, throw error to show page」。
async function doThings(id) {
let data = null;
try {
await checkDataSourceExist(id);
data = await fetchDataSource(id);
renderGraph();
} catch (error) {
console.log(`something goes wrong, need to get data from another source: ${error}`);
try {
data = await fetchAnotherDataSource(id);
} catch (err) {
console.log(`still something goes wrong, throw error to show page: ${error}`);
displayErrorPage(err);
throw error;
}
throw error;
}
return data;
}
承上,得到執行結果
"something goes wrong, need to get data from another source: data not exist"
"still something goes wrong, throw error to show page: data not exist"
到這裡,發現幾個問題…
- 針對錯誤處理,雖然沒有 callback hell 了,但卻可能造成巢狀
try...catch
區塊的結構。 - 由於區塊範疇(block scope)的緣故,若想儲存結果,則必須有個在 try…catch 區塊外宣告一個礙眼的變數 data。
- 與難以辨別是誰丟出的錯誤等問題,像是外層的 catch 若得到錯誤,就不知道是 checkDataSourceExist 還是 fetchDataSource 丟出來的。
有什麼解法嗎?
解法
「Cleaner async JavaScript code without the try/catch mess」這篇文章的作者提出一個解法,將 promise 重新包裝,然後將 error 與 data 用陣列 [error, data]
丟出,這樣在回傳的這陣列中,error 與 data 擇一有值,對稍後的邏輯和錯誤處理方便很多。
safeAwait 的原始碼解說如下,分為兩部份。第一部份是將已定義好的 native error 定義在陣列 nativeExceptions 裡面,稍等會將傳入的錯誤依次做檢查。
const nativeExceptions = [
EvalError,
RangeError,
ReferenceError,
SyntaxError,
TypeError,
URIError,
].filter((except) => typeof except === 'function')
檢查傳入的 error 是不是已定義好的 native error,如果是已定義好的 native error 就丟出錯誤。想知道有哪些 native error,可參考這裡。
function throwNative(error) {
for (const Exception of nativeExceptions) {
if (error instanceof Exception) throw error;
}
}
關於 for...of
迴圈將可迭代的物件迭代其中元素,可參考這裡
第二部份是用來包裝 promise 的 safeAwait,safeAwait 會做以下幾件事情
- 如果是已定義好的 native error,就會直接丟出 error;並且只要是 error 就會包在陣列裡面,當成陣列中的第一個元素回傳。
- 如果不是 error 就是資料,同樣也是包在陣列裡面,只是陣列會有兩個元素,第一個是 error,值是 undefined;第二個是資料。也就是說,回傳的結果一定是兩者擇一有值,另一個就會得到 undefined。
- safeAwait 提供第二的參數,可傳 callback 來做些事情。
function safeAwait(promise, finallyFunc) {
return promise
.then(data => {
if (data instanceof Error) {
throwNative(data);
return [data];
}
return [undefined, data];
})
.catch(error => {
throwNative(error);
return [error];
})
.finally(() => {
if (finallyFunc && typeof finallyFunc === 'function') {
finallyFunc();
}
});
}
使用範例如下,這裡 detectALotOfThings 會利用 safeAwait 包裝執行兩個非同步函式 detectSomethingA 和 detectSomethingB,第一個 safeAwait 還加上 callback sayHi。若有 error 則印出 error,沒有 error 就印出 data。
async function detectALotOfThings() {
const [errorA, dataA] = await safeAwait(detectSomethingA(), sayHi);
const [errorB, dataB] = await safeAwait(detectSomethingB());
errorA && console.log(errorA);
errorB && console.log(errorB);
dataA && console.log(`data:`, dataA);
dataB && console.log(`data:`, dataB);
}
const sayHi = () => console.log('Hi');
在這裡假設 detectSomethingA 會丟出「Error A」,detectSomethingB 會丟出「Success B!」。
function detectSomethingA() {
return new Promise((resolve, reject) =>
setTimeout(() => reject('Error A'), 100)
);
}
function detectSomethingB() {
return new Promise((resolve, reject) =>
setTimeout(() => resolve('Success B!'), 100)
);
}
因此會印出結果如下所示。
Hi
error: Error A
data: Success B!
「Hi」最早被丟出來不是因為 sayHi 最早被執行(執行順序是 return error A -> Hi -> return data Success B),而是因為我們最後才印出執行結果的 error 與 data,而 sayHi 當然早早就跑完了。
不過,實際上我們當然不太可能像這樣 error && console.log(error)
或 data && console.log(data)
的方式撰寫,將本文一開始的例子改寫如下,會比較符合現實的狀況…是不是更簡潔易懂了呢? ヽ(∀゚ )人(゚∀゚)人( ゚∀)人(∀゚ )人(゚∀゚)人( ゚∀)ノ
async function doThings(id) {
await safeAwait(checkDataSourceExist());
const [fetchDataErr, fetchDataResult] = await fetchDataSource(detectSomethingB());
if (fetchDataErr) {
console.log(`something goes wrong, need to get data from another source: ${fetchDataErr}`);
const [err, data] = await fetchAnotherDataSource(id);
if (err) {
console.log(`still something goes wrong, throw error to show page: ${err}`);
displayErrorPage(err);
} else {
return data;
}
} else {
renderGraph();
return fetchDataResult;
}
return null;
}
參考資料
- Cleaner async JavaScript code without the try/catch mess.
- 7 Reasons Why JavaScript Async/Await Is Better Than Plain Promises (Tutorial):這篇文章寫了很多作者認為 Async / Await 的優點,除了眾所皆知的同步與非同步錯誤處理和 callback hell 外,有些優點是除錯或是很細微的細節,有興趣的可以參考。只是其中在第二點「2. Error handling」這一段我認為例子是有錯的,因為在 promise 的 then 裡面有錯誤時,可以統一做同步與非同步的錯誤處理,而在 promise 外有錯誤時,也就是同層的時候,才是無法統一做同步與非同步的錯誤處理的。
關於這塊我在下面描述正確的範例。
在 promise 的 then 裡面有錯誤時
若 promise 得到 reject 時,錯誤會由 promise 的 catch 接住;在 promise 的 then 裡面有錯誤時,例如:呼叫某個不存在的 function,則這個錯誤同樣要由 promise 的 catch 接住。因此,以上兩種狀況可以在同一地方統一做 error handling。
與 promise 同層有錯誤時
在 promise 外有錯誤時,也就是同層的時候,需要統一做錯誤處理就必須使用兩個 catch 來分別處理,無法統一在同一個地方。如下範例所示,同步 document.getElementByID
與非同步 fetchDataSource
必須要分開做錯誤處理
function doThings(id) {
try {
fetchDataSource()
.then((data) => {
console.log(data);
})
.catch((e) => {
console.log('in the inner catch')
console.log(e.message); // 處理 fetchDataSource 的錯誤
});
document.getElementByID('foo'); // 應為 getElementById
} catch(e) {
console.log('in the outer catch');
console.log(e.message); // 處理 document.getElementByID 的錯誤
}
}
以上狀況,可改用 Async / Await 來處理,這樣就能藉由 try…catch 統一處理同步與非同步的錯誤。
async function doThings(id) {
try {
const result = await fetchDataSource()
document.getElementByID('foo'); // 應為 getElementById
} catch(e) {
console.log(e.message); // "should be getElementById" 或 "data is not here"
}
}