异步函数:使 Promise 友好

借助异步函数,您可以编写基于 Promise 的代码,就像它是同步代码一样。

Jake Archibald
Jake Archibald

异步函数在 Chrome、Edge、Firefox 和 Safari 中默认处于启用状态,而且效果非常出色。借助它们,您可以编写基于 Promise 的代码,就像它是同步代码一样,但不会阻塞主线程。它们可使异步代码更易于阅读,并减少“巧妙”的代码。

异步函数的运作方式如下:

async function myFirstAsyncFunction() {
  try {
    const fulfilledValue = await promise;
  } catch (rejectedValue) {
    // …
  }
}

如果您在函数定义之前使用 async 关键字,则可以在函数内使用 await。当您 await 一个 promise 时,该函数会以非阻塞方式暂停,直到 promise 得到解决。如果 promise 执行完毕,您将会收到相应值。如果 promise 被拒绝,则会抛出被拒绝的值。

浏览器支持

Browser Support

  • Chrome: 55.
  • Edge: 15.
  • Firefox: 52.
  • Safari: 10.1.

Source

示例:记录提取操作

假设您要提取网址并将响应记录为文本。使用 promise 时,代码如下所示:

function logFetch(url) {
  return fetch(url)
    .then((response) => response.text())
    .then((text) => {
      console.log(text);
    })
    .catch((err) => {
      console.error('fetch failed', err);
    });
}

下面是使用异步函数实现相同操作的代码:

async function logFetch(url) {
  try {
    const response = await fetch(url);
    console.log(await response.text());
  } catch (err) {
    console.log('fetch failed', err);
  }
}

行数相同,但所有回调都消失了。这样一来,代码就更容易阅读了,对于不太熟悉 Promise 的用户来说更是如此。

异步返回值

无论您是否使用 await,异步函数都始终返回 Promise。该 Promise 会使用异步函数返回的任何内容进行解析,或使用异步函数抛出的任何内容进行拒绝。因此,使用以下命令:

// wait ms milliseconds
function wait(ms) {
  return new Promise((r) => setTimeout(r, ms));
}

async function hello() {
  await wait(500);
  return 'world';
}

…调用 hello() 会返回一个 promise,该 promise 会使用 "world" 执行

async function foo() {
  await wait(500);
  throw Error('bar');
}

…调用 foo() 会返回一个 promise,该 promise 会使用 Error('bar') rejects

示例:流式传输响应

在更复杂的示例中,异步函数的好处会更加明显。假设您希望在记录分块的同时流式传输响应,并返回最终大小。

下面是使用 promise 的版本:

function getResponseSize(url) {
  return fetch(url).then((response) => {
    const reader = response.body.getReader();
    let total = 0;

    return reader.read().then(function processResult(result) {
      if (result.done) return total;

      const value = result.value;
      total += value.length;
      console.log('Received chunk', value);

      return reader.read().then(processResult);
    });
  });
}

我是“承诺大使”Jake Archibald。您看到了吗?我如何在 processResult() 自身内调用 processResult() 来设置异步循环?写作让我感觉很聪明。但与大多数“智能”代码一样,您必须盯着它看很长时间才能弄清楚它在做什么,就像 90 年代的魔眼图案一样。

我们再使用异步函数试一试:

async function getResponseSize(url) {
  const response = await fetch(url);
  const reader = response.body.getReader();
  let result = await reader.read();
  let total = 0;

  while (!result.done) {
    const value = result.value;
    total += value.length;
    console.log('Received chunk', value);
    // get the next result
    result = await reader.read();
  }

  return total;
}

所有“智能”功能都已消失。让我如此得意的异步循环被替换成了可靠但乏味的 while 循环。现在好多了。未来,您将获得异步迭代器,它会while 循环替换为 for-of 循环,使其更加简洁。

其他异步函数语法

我已经向您展示了 async function() {},但 async 关键字可以与其他函数语法搭配使用:

箭头函数

// map some URLs to json-promises
const jsonPromises = urls.map(async (url) => {
  const response = await fetch(url);
  return response.json();
});

对象方法

const storage = {
  async getAvatar(name) {
    const cache = await caches.open('avatars');
    return cache.match(`/avatars/${name}.jpg`);
  }
};

storage.getAvatar('jaffathecake').then();

类方法

class Storage {
  constructor() {
    this.cachePromise = caches.open('avatars');
  }

  async getAvatar(name) {
    const cache = await this.cachePromise;
    return cache.match(`/avatars/${name}.jpg`);
  }
}

const storage = new Storage();
storage.getAvatar('jaffathecake').then();

小心!避免过于顺序

虽然您编写的代码看起来是同步的,但请确保您不会错失并行执行操作的机会。

async function series() {
  await wait(500); // Wait 500ms…
  await wait(500); // …then wait another 500ms.
  return 'done!';
}

上述操作需要 1000 毫秒才能完成,而:

async function parallel() {
  const wait1 = wait(500); // Start a 500ms timer asynchronously…
  const wait2 = wait(500); // …meaning this timer happens in parallel.
  await Promise.all([wait1, wait2]); // Wait for both timers in parallel.
  return 'done!';
}

上述操作需要 500 毫秒才能完成,因为这两个等待操作会同时发生。我们来看一个实际示例。

示例:按顺序输出提取内容

假设您想提取一系列网址,并尽快按正确的顺序将其记录下来。

深呼吸 - 使用 Promise 时,情况如下所示:

function markHandled(promise) {
  promise.catch(() => {});
  return promise;
}

function logInOrder(urls) {
  // fetch all the URLs
  const textPromises = urls.map((url) => {
    return markHandled(fetch(url).then((response) => response.text()));
  });

  // log them in order
  return textPromises.reduce((chain, textPromise) => {
    return chain.then(() => textPromise).then((text) => console.log(text));
  }, Promise.resolve());
}

是的,没错,我使用 reduce 串联一系列 promise。我很聪明。不过,这种编码方式有些过于精巧,最好还是不要这样做。

但是,将上述代码转换为异步函数时,很容易陷入过于顺序的陷阱:

不推荐 - 过于顺序
async function logInOrder(urls) {
  for (const url of urls) {
    const response = await fetch(url);
    console.log(await response.text());
  }
}
看起来整洁多了,但第二次提取操作只有在第一次提取操作完全读取完毕后才会开始,以此类推。这比并行执行提取操作的 Promise 示例要慢得多。幸运的是,我们可以找到理想的中间地带。
推荐 - 并行处理
function markHandled(...promises) {
  Promise.allSettled(promises);
}

async function logInOrder(urls) {
  // fetch all the URLs in parallel
  const textPromises = urls.map(async (url) => {
    const response = await fetch(url);
    return response.text();
  });

  markHandled(...textPromises);

  // log them in sequence
  for (const textPromise of textPromises) {
    console.log(await textPromise);
  }
}
在此示例中,系统会并行提取和读取网址,但“智能”reduce 位已替换为标准的、乏味的、可读的 for 循环。

浏览器支持权宜解决方案:生成器

如果您定位的是支持生成器的浏览器(包括所有主流浏览器的最新版本),则可以对异步函数进行类似的 polyfill。

Babel 会为您完成此操作,以下是通过 Babel REPL 的示例

我建议使用转译方法,因为您只需在目标浏览器支持异步函数后关闭转译方法即可,但如果您真的不想使用转译器,可以使用 Babel 的 polyfill 自行使用。来替代:

async function slowEcho(val) {
  await wait(1000);
  return val;
}

…您需要添加polyfill 并编写以下代码:

const slowEcho = createAsyncFunction(function* (val) {
  yield wait(1000);
  return val;
});

请注意,您必须将生成器 (function*) 传递给 createAsyncFunction,并使用 yield 而非 await。除此之外,其运作方式与原来相同。

权宜解决方法:regenerator

如果您以旧版浏览器为目标平台,Babel 还可以转译生成器,让您能够在 IE8 等旧版浏览器中使用异步函数。为此,您需要 Babel 的 es2017 预设 es2015 预设

输出不太美观,因此请注意代码膨胀。

将所有操作都异步执行!

在所有浏览器都支持异步函数后,请在每个返回 Promise 的函数中使用它们!这不仅可以让代码更整洁,还可以确保函数始终返回一个 Promise。

早在 2014 年,我就对异步函数非常感兴趣,很高兴看到它们真的在浏览器中推出了。好耶!