CertSimple 網站最近釋出了一篇文章,說 ES2017 裡的 async 和 await 是 JS 最好的特性。我非常贊同。
基本上來說,JS 為數不多的幾個優點之一就是對非同步請求的處理得當。這得益於它從 Scheme 那裡繼承來的函式和閉包。
然而這也是 JS 的最大的問題之一,因為這導致了回撥地獄(callback hell),這個看起來無法迴避的問題導致非同步的 JS 程式碼可讀性非常差。為了解決回撥地獄,大家嘗試了很多方案,但大都失敗了。Promise 方案差點解決了這個問題,但還是失敗了。
最終,我們看到了 async/await 與 Promise 聯合的方案,這個方案非常好地解決了問題。在這篇文章裡,我將解釋為什麼會這樣,以及 Promise、async/await 和 do 語法、monad 之間的關係。
首先,我們嘗試用三種不同風格的程式碼來獲取讀取使用者所有賬戶裡的餘額。(一個使用者有多個賬戶 accout,每個賬戶裡都有餘額 balence)
錯誤的方案:回撥地獄
function getBalances(callback) {
api.getAccounts(function (err, accounts) { // 回撥
if (err) {
callback(err);
} else {
var balances = {}; // 餘額
var balancesCount = 0;
accounts.forEach(function(account, i) {
api.getBalance(function (err, balance) { // 回撥
if (err) {
callback(err);
} else {
balances[account] = balance;
if (++balancesCount === accounts.length) {
callback(null, balances);
}
}
});
});
}
});
};
複製程式碼
這是一種很容易想到的方法,但是它有兩層回撥,這份程式碼醜陋中有 3 個問題需要解決:
- 每一個地方都要對 err 進行了處理
- 用計數器來計算非同步得來的值
- 不可避免的巢狀
幾乎正確的方案:Promise
function getBalances() {
return api.getAccounts()
.then(accounts =>
Promise.all(accounts.map(api.getBalance))
.then(balances => Ramda.zipObject(accounts, balances))
);
}
複製程式碼
這個程式碼解決了上面的三個問題:
- 我們可以在最後一個 then 裡統一處理 error
- Promise.all 使得我們不需要定義額外的計數器
- 我們可以最大程度地避免巢狀
但是還有一個問題沒有解決,那就是 then 還是巢狀了,第二個 then 在第一個 then 的回撥裡,因為第二個 then 需要用到第一個then 的 accounts 變數。所以對程式碼進行正確的縮排非常重要。
不過解決方法也是有的,那就是讓第一個 then 把 accounts 傳給第二個 then:
function getBalances() {
return api.getAccounts()
.then(accounts => Promise.all(accounts.map(api.getBalance)
.then(balances => [accounts, balances])))
.then(([accounts, balances]) => Ramda.zipObject(accounts, balances));
}
複製程式碼
但是這樣會導致又多了一個 then。可以看到 Promise 基本上解決了回撥低於,但是並沒有完全解決。
正確的方案:async/await
async function getBalances() {
const accounts = await api.getAccounts();
const balances = await Promise.all(accounts.map(api.getBalance));
return Ramda.zipObject(balances, accounts);
}
複製程式碼
async 函式裡可以出現 await 關鍵字,await 會得到 Promise 物件完成任務,然後再執行下一句話。
有了這些我們就不用再蛋疼地縮排了。這是如何做到的呢?我們需要追根溯源。
回撥地獄的起源
很多人都認為回撥地獄只有在非同步任務中才有,實際上只要我們用回撥來處理被包裹的值,就會出現回撥地獄。
假設你想列印出 [1,2,3] [4,5,6] [7,8,9]
的所有排列組合,比如 [1,4,7] [1,4,8]
等等:
[1,2,3].map((x) => {
[4,5,6].map((y) => {
[7,8,9].map((z) => {
console.log(x,y,z);
})
})
});
複製程式碼
看,我們熟悉的回撥地獄出現了。這是完全同步的程式碼,但是 async 和 await 只能處理非同步……
假設我們為同步程式碼也建立類似的關鍵字叫做 multi/pick,那麼上面的程式碼就可以寫成
multi function () {
x = pick [1, 2, 3];
y = pick [4, 5, 6];
z = pick [7, 8, 9];
console.log(x, y, z);
}
複製程式碼
當然,這個語法是不存在的。
Monad 和 do
語法有些語言擁有一些特效能處理所有的這類需求,並且不區分非同步還是同步。
譯註:中間的過程需要一些 TS 和 Haskell 知識,能看懂的請自行閱讀。程式碼是大概是這樣的:
getBalances :: Promise (Map String String) -- 這是型別宣告
getBalances = do
accounts <- getAccounts
balances <- getBalance accounts
return (Map.fromList (zip accounts balances))
複製程式碼
這個語法叫做 do 標記或者 do 語法。它要求 Promise 滿足 Monad 的一些規則。
do 語法和 Monad 是在 1995 年被用在 Haskell 裡的(譯註:JS 在 2015 年,也就是 20 年後才把 Promise 引入)。
這兩個特性從此解決了回撥地獄。如果把 JS 的 Promise、await/async 與 Haskell 的 Monad、do 語法做對比的話,你會發現
await/async 之於 Promise,正如 do 語法之於 Monad
既然 Haskell 上已經驗證了 Monad 能夠有效避免回撥地獄,那麼 JS 就可以直接放心用 await 了。
總結
回撥地獄沒了,JS is great again。但是為什麼花了這麼久時間 JS 才去借鑑 Monad 呢?要是 2013 年,社群裡的人聽從了『那個瘋狂的傢伙』的建議 就好了。
全文完。
譯註:那個瘋狂的傢伙說了什麼呢?開啟連結你可以看到一個 GitHub Issues 頁面,那個傢伙的名字叫做 Brian Mckenna(布萊恩)。
布萊恩提議使用函數語言程式設計的方案來優化 Promise。
然而提案的維護者 domenic 卻並不領情。
domenic 說
我們不會這樣做的。這種方案不切實際,為了滿足某些人自己的審美偏好創造出了奇怪而又無用的 API,無法應用在 JS 裡。你沒有理解 Promise 要解決的問題是在指令式程式設計語言裡提供非同步流程控制模型。 這種方案是非常不嚴密的(hilariously inaccurate),因為沒有滿足我們的 spec,應該只能通過我們 1/500 的測試用例。
這個回覆得到了 16 贊和 254 個踩。