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 个踩。