async/await 之于 Promise,正如 do 之于 monad(译文)

2,189 阅读5分钟

原文链接

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 个问题需要解决:

  1. 每一个地方都要对 err 进行了处理
  2. 用计数器来计算异步得来的值
  3. 不可避免的嵌套

几乎正确的方案:Promise


function getBalances() {
  return api.getAccounts()
    .then(accounts => 
        Promise.all(accounts.map(api.getBalance))
            .then(balances => Ramda.zipObject(accounts, balances))
    );
}

这个代码解决了上面的三个问题:

  1. 我们可以在最后一个 then 里统一处理 error
  2. Promise.all 使得我们不需要定义额外的计数器
  3. 我们可以最大程度地避免嵌套

但是还有一个问题没有解决,那就是 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 个踩。