理解Javascript的异步等待

目前async / await特性并没有被添加到ES2016标准中,但不代表这些特性将来不会被加入到Javascript中。在我写这篇文章时,它已经到达第三版草案,并且正迅速的发展中。这些特性已经被IE Edge支持了,而且它将会到达第四版,届时该特性将会登陆其他浏览器 -- 为加入该语言的下一版本而铺路(也可以看看:TC39进程)。

我们听说特性已经有一段时间了,现在让我们深入它,并了解它是如何工作的。为了能够了解这篇文章的内容,你需要对promise和生成器对象有深厚的理解。这些资源或许可以帮到你。

 

使用Promise

让我们假设我们有像下面这样的代码。在这里我将一个HTTP请求包装在一个Promise对象中。这个Promise在成功时会返回body对象,被拒绝时会将原因err返回。它每次都会在本博客(原作者博客)中为一篇随机文章拉取html内容。

var request = require('request');

function getRandomPonyFooArticle () {
  return new Promise((resolve, reject) => {
    request('https://ponyfoo.com/articles/random', (err, res, body) => {
      if (err) {
        reject(err); return;
      }
      resolve(body);
    });
  });
}

上述的promise代码的典型用法是像下面写的这样。 在那里,我们新建了一个promise链来将HTML页面中的DOM对象的一个子集转换成Markdown,然后再转换成对终端友好的输出, 最终再使用console.log输出它。 永远要记得为你的promise添加.catch处理器。

var hget = require('hget');
var marked = require('marked');
var Term = require('marked-terminal');

printRandomArticle();

function printRandomArticle () {
  getRandomPonyFooArticle()
    .then(html => hget(html, {
      markdown: true,
      root: 'main',
      ignore: '.at-subscribe,.mm-comments,.de-sidebar'
    }))
    .then(md => marked(md, {
      renderer: new Term()
    }))
    .then(txt => console.log(txt))
    .catch(reason => console.error(reason));
}

当代码运行后,这段代码将产生像以下截图所示的输出。

代码输出

上面那段代码就是“比用回调函数更好”的写法,它能让你感觉像在按顺序的阅读代码。

 

使用生成器(generator)

过去,通过探索,我们发现生成器可以用一种“同步”合成的方法来获得html。即使现在的代码有一些同步写法,其中还是涉及相当多的包装,而且生成器可能不是最直截了当的达到我们期望结果的方法,最终可能无论如何我们都会坚持改为使用promise。

function getRandomPonyFooArticle (gen) {
  var g = gen();
  request('https://ponyfoo.com/articles/random', (err, res, body) => {
    if (err) {
      g.throw(err); return;
    }
    g.next(body);
  });
}

getRandomPonyFooArticle(function* printRandomArticle () {
  var html = yield;
  var md = hget(html, {
    markdown: true,
    root: 'main',
    ignore: '.at-subscribe,.mm-comments,.de-sidebar'
  });
  var txt = marked(md, {
    renderer: new Term()
  });
  console.log(txt);
});

“请记住,在使用promise时,你应该将yield调用包装在try/catch块中来保留我们添加的错误处理器”

不说你也知道,像这样使用生成器并不容易扩展。除了涉及直观的语法的混入,你的迭代代码会高度耦合到生成器函数中,这将会降低扩展性。这表示你在添加新的await表达式到生成器中时需要经常修改它。一个更好的替代方案是使用即将到来的Async函数

 

使用async/await

当Async函数终于落地时,我们将可以采取基于Promise的实现方法并使用它的优点,即像写同步生成器一样写异步。这种做法的另一个好处是你完全不需要再去修改getRandomPonyFooArticle方法,在它返回一个承诺前,它会一直等待。

要注意的是,await只能在函数中用async关键字标记后才能使用 它的工作方式和生成器很相似,直到promise完成之前,会在你的上下文中暂停处理。如果等待表达式不是一个promise,它也会被改造成一个promise。

read();

async function read () {
  var html = await getRandomPonyFooArticle();
  var md = hget(html, {
    markdown: true,
    root: 'main',
    ignore: '.at-subscribe,.mm-comments,.de-sidebar'
  });
  var txt = marked(md, {
    renderer: new Term()
  });
  console.log(txt);
}

“再次, -- 跟生成器一样 -- 记住,你最好把`await`包装到`try/catch`中,这样你就可以在异步函数中对返回后的promise进行错误捕获和处理。”

此外,一个Async函数总是会返回一个Promise对象。 这个promise在出现无法捕获的异常时会被拒绝,否则它会处理async函数的返回值。这就允许我们调用一个async函数并混入常规的基于promise的扩展。以下例子展示了两个方法的结合(看看Babel的交互式解释器)。

 

async function asyncFun () {
  var value = await Promise
    .resolve(1)
    .then(x => x * 3)
    .then(x => x + 5)
    .then(x => x / 2);
  return value;
}
asyncFun().then(x => console.log(`x: ${x}`));
// <- 'x: 4'

回到前一个例子中,那表示我们可以从异步读取函数中返回文本,并且允许调用者使用promise或另一个Async函数进行扩展。 那样,你的读取函数将只需关注从Pony Foo上的随机文章中拉取终端可读的Markdown即可。

async function read () {
  var html = await getRandomPonyFooArticle();
  var md = hget(html, {
    markdown: true,
    root: 'main',
    ignore: '.at-subscribe,.mm-comments,.de-sidebar'
  });
  var txt = marked(md, {
    renderer: new Term()
  });
  return txt;
}

然后,你可以进一步在另一个Async函数中调用await read()

async function write () {
  var txt = await read();
  console.log(txt);
}

或者你可以只使用promise对象来进一步扩展。

read().then(txt => console.log(txt));

 

岔路

在异步代码流中,总是能遇到同时执行两个或更多任务的情况。当Async函数更容易编写异步代码后,它们也将自己依次传递给代码。 这就是说:代码在一个时刻只执行一个操作。一个包含多个await表达式的函数在promise对象执行完之前,在恢复执行和移动到下一个await表达式之前,会在每个await表达式处暂停一次, -- 就跟我们在生成器和yield关键字处观察到的情况一样。

你可以使用Promise.all来解决创建单个promise对象并进行等待的功能。 当然,最大的问题是从习惯于让所有东西都串行运行改成使用Promise.all, 否则这将给你的代码带来性能瓶颈。

下面的例子展示了你如何同时完成对三个不同的promise对象进行等待操作。特定的await操作符会暂停你的Async函数,和等待 Promise.all表达式一起,最终会被解析到一个结果数组中,我们可以使用析构函数逐个拉取该数组中的单个结果。

async function concurrent () {
  var [r1, r2, r3] = await Promise.all([p1, p2, p3]);
}

在某些情况下, 可以用 await *来改动上述代码片段,让你不必用Promise.all来包装你的promise对象。Babel 5依然支持这种特性,但它已经从规格说明中移除(也已经从Babel 6中移除) -- 因为这些原因

async function concurrent () {
  var [r1, r2, r3] = await* [p1, p2, p3];
}

你依然可以用类似all = Promise.all.bind(Promise)的代码来做些事情,来获得一个简洁的替代Promise.all的方法。在这之上的是,你可以对Promise.race做相同的事情,而这跟使用await*并不等价。

const all = Promise.all.bind(Promise);
async function concurrent () {
  var [r1, r2, r3] = `await all([p1, p2, p3])`;
}

 

错误处理

要注意的是,在异步函数中,错误会被“默默的”吞噬 -- 就像在普通的Promise对象中一样。 除非我们围绕await表达式添加try/catch块 -- 而不管在暂停时,它们会在你的异步函数体中发生还是在它暂停时发生 -- promise对象会被拒绝并通过Async函数返回错误。

自然,这可以看作是一个能力: 你可以利用try/catch代码块,有些东西你无法用回调函数实现-- 但可以用Promise对象实现。 在这种情况下,Async函数就类似生成器,得益于函数执行暂停特性,你可以利用try/catch将异步流代码写成同步代码的样子。

此外, 你可以在Async函数外捕获这些异常, 只需要简单的对它们返回的promise对象添加.catch()方法调用。在promise对象中尝试用.catch方法来将try/catch错误处理组合起来是一种比较灵活的方法,但该方法也可能导致混乱并最终导致错误无法处理。

read()
  .then(txt => console.log(txt))
  .catch(reason => console.error(reason));

我们要小心谨慎并时刻提醒自己用不同的方法来让我们可以发现错误、处理错误或预防错误。

 

如今如何使用async/await

如今,有一种在你的代码中使用Async函数的方法是通过Babel。这涉及一系列模块,但只要你愿意,你总是可以拿出一个模块来将全部这些代码包装进去。我包含npm-run作为一个有用的方法,用于保持本地的所有东西都用包进行安装。

npm i -g npm-run
npm i -D \
  browserify \
  babelify \
  babel-preset-es2015 \
  babel-preset-stage-3 \
  babel-runtime \
  babel-plugin-transform-runtime

echo '{
  "presets": ["es2015", "stage-3"],
  "plugins": ["transform-runtime"]
}' > .babelrc

在使用babelifyAsync函数提供支持时,以下命令会将example.js通过browserify进行编译。然后你就可以用管道将脚本传输给node执行,或将脚本保存到硬盘中。

npm-run browserify -t babelify example.js | node

 

深入阅读

Async函数规格草案出奇的短,并且应该能成为一个有趣的读物, 如果你热衷于学习更多这些即将到来的功能。

我已经粘贴了一段代码在下面, 它是为了帮助你理解async函数的内部是如何工作的。即使我们不可以填充新的关键字,它也可以帮助你理解在async/await的帷幕后面发生了什么事情。

“换句话说,它应该对学习异步函数内部原理非常有帮助,无论是对生成器还是promise。”

首先,下面的一小段代码展示了一个async函数如何通过常规的function关键字来简化声明过程,这将返回一个生成spawn 生成器函数的结果 -- 我们会认为await在语法上是和yield等价的。

async function example (a, b, c) {
  example function body
}

function example (a, b, c) {
  return spawn(function* () {
    example function body
  }, this);
}

spawn中,promise会被代码包装起来并传入生成器函数中,通过用户代码串行的执行,并将值传递到你的“生成器”代码中(async函数的函数体中)。 在这个意义上,我们可以注意Async函数真的是生成器和primose对象之上的语法糖,这对于让你理解其中每一个环节是如何工作来说非常重要,这是为了让你对于混合、匹配、合并不同的异步代码流的写法有一个更好的理解。

function spawn (genF, self) {
  return new Promise(function (resolve, reject) {
    var gen = genF.call(self);
    step(() => gen.next(undefined));
    function step (nextF) {
      var next;
      try {
        next = nextF();
      } catch(e) {
        // 执行失败,并拒绝promise对象
        reject(e);
        return;
      }
      if (next.done) {
        // 执行成功,处理promise对象
        resolve(next.value);
        return;
      }
      // 未完成,以yield标记的promise对象呗中断,并在此执行step方法
      Promise.resolve(next.value).then(
        v => step(() => gen.next(v)),
        e => step(() => gen.throw(e))
      );
    }
  });
}

“高亮部分的代码可以帮助你理解`async/await`如何对生成器序列进行迭代处理(通过`await`表达式),将每个对象按包装序列中并放入一个promise对象,通过一步步按顺序的链接起来。当**序列完成或其中一个promise对象被拒绝**时,promise对象的返回动作将由底层生成器函数完成”

 

特别鸣谢 @ljharb@jaydson@calvinf@ericclemmons@sherman3ero@matthewmolnar3以及@rauschma为这篇文章的草稿进行审阅。


第一次用博客园的markdown编辑器写博客,还不太顺手,对markdown也不够熟悉(其实我也感觉博客园的markdown解析怪怪的)。这篇文章的英语感觉比较难看懂,翻译不准确甚至错误的地方应该很多,希望大家不吝指正。

2016.09.05
谢谢观看。

posted @ 2016-09-07 09:27  最骚的就是你  阅读(10796)  评论(0编辑  收藏  举报