精讀《async/await 是把雙刃劍》

黃子毅發表於2018-05-07

本週精讀內容是 《逃離 async/await 地獄》

1 引言

終於,async/await 也被吐槽了。Aditya Agarwal 認為 async/await 語法讓我們陷入了新的麻煩之中。

其實,筆者也早就覺得哪兒不對勁了,終於有個人把實話說了出來,async/await 可能會帶來麻煩。

2 概述

下面是隨處可見的現代化前端程式碼:

(async () =>
{
const pizzaData = await getPizzaData();
// async call const drinkData = await getDrinkData();
// async call const chosenPizza = choosePizza();
// sync call const chosenDrink = chooseDrink();
// sync call await addPizzaToCart(chosenPizza);
// async call await addDrinkToCart(chosenDrink);
// async call orderItems();
// async call
})();
複製程式碼

await 語法本身沒有問題,有時候可能是使用者用錯了。當 pizzaDatadrinkData 之間沒有依賴時,順序的 await 會最多讓執行時間增加一倍的 getPizzaData 函式時間,因為 getPizzaDatagetDrinkData 應該並行執行。

回到我們吐槽的回撥地獄,雖然程式碼比較醜,帶起碼兩行回撥程式碼並不會帶來阻塞。

看來語法的簡化,帶來了效能問題,而且直接影響到使用者體驗,是不是值得我們反思一下?

正確的做法應該是先同時執行函式,再 await 返回值,這樣可以並行執行非同步函式:

(async () =>
{
const pizzaPromise = selectPizza();
const drinkPromise = selectDrink();
await pizzaPromise;
await drinkPromise;
orderItems();
// async call
})();
複製程式碼

或者使用 Promise.all 可以讓程式碼更可讀:

(async () =>
{
Promise.all([selectPizza(), selectDrink()]).then(orderItems);
// async call
})();
複製程式碼

看來不要隨意的 await,它很可能讓你程式碼效能降低。

3 精讀

仔細思考為什麼 async/await 會被濫用,筆者認為是它的功能比較反直覺導致的。

首先 async/await 真的是語法糖,功能也僅是讓程式碼寫的舒服一些。先不看它的語法或者特性,僅從語法糖三個字,就能看出它一定是侷限了某些能力。

舉個例子,我們利用 html 標籤封裝了一個元件,帶來了便利性的同時,其功能一定是 html 的子集。又比如,某個輪子哥覺得某個元件 api 太複雜,於是基於它封裝了一個語法糖,我們多半可以認為這個便捷性是犧牲了部分功能換來的。

功能完整度與使用便利度一直是相互博弈的,很多框架思想的不同開源版本,幾乎都是把功能完整度與便利度按照不同比例混合的結果。

那麼回到 async/await 它的解決的問題是回撥地獄帶來的災難:

a(() =>
{
b(() =>
{
c();

});

});
複製程式碼

為了減少巢狀結構太多對大腦造成的衝擊,async/await 決定這麼寫:

await a();
await b();
await c();
複製程式碼

雖然層級上一致了,但邏輯上還是巢狀關係,這不是另一個程度上增加了大腦負擔嗎?而且這個轉換還是隱形的,所以許多時候,我們傾向於忽略它,所以造成了語法糖的濫用。

理解語法糖

雖然要正確理解 async/await 的真實效果比較反人類,但為了清爽的程式碼結構,以及防止寫出低效能的程式碼,還是挺有必要認真理解 async/await 帶來的改變。

首先 async/await 只能實現一部分回撥支援的功能,也就是僅能方便應對層層巢狀的場景。其他場景,就要動一些腦子了。

比如兩對回撥:

a(() =>
{
b();

});
c(() =>
{
d();

});
複製程式碼

如果寫成下面的方式,雖然一定能保證功能一致,但變成了最低效的執行方式:

await a();
await b();
await c();
await d();
複製程式碼

因為翻譯成回撥,就變成了:

a(() =>
{
b(() =>
{
c(() =>
{
d();

});

});

});
複製程式碼

然而我們發現,原始程式碼中,函式 c 可以與 a 同時執行,但 async/await 語法會讓我們傾向於在 b 執行完後,再執行 c

所以當我們意識到這一點,可以優化一下效能:

const resA = a();
const resC = c();
await resA;
b();
await resC;
d();
複製程式碼

但其實這個邏輯也無法達到回撥的效果,雖然 ac 同時執行了,但 d 原本只要等待 c 執行完,現在如果 a 執行時間比 c 長,就變成了:

a(() =>
{
d();

});
複製程式碼

看來只有完全隔離成兩個函式:

(async () =>
{
await a();
b();

})();
(async () =>
{
await c();
d();

}
)();
複製程式碼

或者利用 Promise.all:

async function ab() { 
await a();
b();

}async function cd() {
await c();
d();

}Promise.all([ab(), cd()]);
複製程式碼

這就是我想表達的可怕之處。回撥方式這麼簡單的過程式程式碼,換成 async/await 居然寫完還要反思一下,再反推著去優化效能,這簡直比回撥地獄還要可怕。

而且大部分場景程式碼是非常複雜的,同步與 await 混雜在一起,想捋清楚其中的脈絡,並正確優化效能往往是很困難的。但是我們為什麼要自己挖坑再填坑呢?很多時候還會導致忘了填。

原文作者給出了 Promise.all 的方式簡化邏輯,但筆者認為,不要一昧追求 async/await 語法,在必要情況下適當使用回撥,是可以增加程式碼可讀性的。

4 總結

async/await 回撥地獄提醒著我們,不要過渡依賴新特性,否則可能帶來的程式碼執行效率的下降,進而影響到使用者體驗。同時,筆者認為,也不要過渡利用新特性修復新特性帶來的問題,這樣反而導致程式碼可讀性下降。

當我翻開 redux 剛火起來那段時期的老程式碼,看到了許多過渡抽象、為了用而用的程式碼,硬是把兩行程式碼能寫完的邏輯,拆到了 3 個檔案,分散在 6 行不同位置,我只好用字串搜尋的方式查詢線索,最後發現這個抽象程式碼整個專案僅用了一次。

寫出這種程式碼的可能性只有一個,就是在精神麻木的情況下,一口氣喝完了 redux 提供的全部雞湯。

就像 async/await 地獄一樣,看到這種 redux 程式碼,我覺得遠不如所謂沒跟上時代的老前端寫出的 jquery 程式碼。

決定程式碼質量的是思維,而非框架或語法,async/await 雖好,但也要適度哦。

5 更多討論

討論地址是:精讀《逃離 async/await 地獄》 · Issue #82 · dt-fe/weekly

如果你想參與討論,請點選這裡,每週都有新的主題,週末或週一釋出。

來源:https://juejin.im/post/5aefbb046fb9a07ab508cf25

相關文章