目录
请支持本书:购买 (PDF, EPUB, MOBI)捐赠
(广告,请勿屏蔽。)

5. 异步函数

ECMAScript 2017 的特性“异步函数”是由 Brian Terlson 提出的。

5.1 概述

5.1.1 变体

异步函数存在以下几种变体。请注意,所有变体中都使用了关键字 async

5.1.2 异步函数始终返回 Promise

实现异步函数的 Promise

async function asyncFunc() {
    return 123;
}

asyncFunc()
.then(x => console.log(x));
    // 123

拒绝异步函数的 Promise

async function asyncFunc() {
    throw new Error('Problem!');
}

asyncFunc()
.catch(err => console.log(err));
    // Error: Problem!

5.1.3 通过 await 处理异步计算的结果和错误

运算符 await(仅允许在异步函数内部使用)会等待其操作数(一个 Promise)完成。

处理单个异步结果

async function asyncFunc() {
    const result = await otherAsyncFunc();
    console.log(result);
}

// Equivalent to:
function asyncFunc() {
    return otherAsyncFunc()
    .then(result => {
        console.log(result);
    });
}

顺序处理多个异步结果

async function asyncFunc() {
    const result1 = await otherAsyncFunc1();
    console.log(result1);
    const result2 = await otherAsyncFunc2();
    console.log(result2);
}

// Equivalent to:
function asyncFunc() {
    return otherAsyncFunc1()
    .then(result1 => {
        console.log(result1);
        return otherAsyncFunc2();
    })
    .then(result2 => {
        console.log(result2);
    });
}

并行处理多个异步结果

async function asyncFunc() {
    const [result1, result2] = await Promise.all([
        otherAsyncFunc1(),
        otherAsyncFunc2(),
    ]);
    console.log(result1, result2);
}

// Equivalent to:
function asyncFunc() {
    return Promise.all([
        otherAsyncFunc1(),
        otherAsyncFunc2(),
    ])
    .then([result1, result2] => {
        console.log(result1, result2);
    });
}

处理错误

async function asyncFunc() {
    try {
        await otherAsyncFunc();
    } catch (err) {
        console.error(err);
    }
}

// Equivalent to:
function asyncFunc() {
    return otherAsyncFunc()
    .catch(err => {
        console.error(err);
    });
}

5.2 理解异步函数

在解释异步函数之前,我需要解释如何结合 Promise 和生成器,通过类似同步的代码来执行异步操作。

对于异步计算其一次性结果的函数,ES6 中的 Promise 已变得流行起来。一个例子是 客户端 fetch API,它是 XMLHttpRequest 的替代方案,用于检索文件。使用它的方式如下所示

function fetchJson(url) {
    return fetch(url)
    .then(request => request.text())
    .then(text => {
        return JSON.parse(text);
    })
    .catch(error => {
        console.log(`ERROR: ${error.stack}`);
    });
}
fetchJson('http://example.com/some_file.json')
.then(obj => console.log(obj));

5.2.1 使用生成器编写异步代码

co 是一个使用 Promise 和生成器来实现更像同步编码风格的库,但其工作原理与上一个示例中使用的风格相同

const fetchJson = co.wrap(function* (url) {
    try {
        let request = yield fetch(url);
        let text = yield request.text();
        return JSON.parse(text);
    }
    catch (error) {
        console.log(`ERROR: ${error.stack}`);
    }
});

每次回调(一个生成器函数!)将 Promise 传递给 co 时,回调都会被挂起。一旦 Promise 完成,co 就会恢复回调:如果 Promise 被实现,则 yield 返回实现值;如果被拒绝,则 yield 抛出拒绝错误。此外,co 会将回调返回的结果 Promise 化(类似于 then() 的方式)。

5.2.2 使用异步函数编写异步代码

异步函数基本上是 co 所做工作的专用语法

async function fetchJson(url) {
    try {
        let request = await fetch(url);
        let text = await request.text();
        return JSON.parse(text);
    }
    catch (error) {
        console.log(`ERROR: ${error.stack}`);
    }
}

在内部,异步函数的工作方式与生成器非常相似。

5.2.3 异步函数同步启动,异步完成

以下是异步函数的执行方式

  1. 异步函数的结果始终是一个 Promise p。该 Promise 在开始执行异步函数时创建。
  2. 执行函数体。执行可以通过 returnthrow 永久结束。或者可以通过 await 暂时结束;在这种情况下,执行通常会在稍后继续。
  3. 返回 Promise p

在执行异步函数体时,return x 使用 x 实现 Promise p,而 throw err 使用 err 拒绝 p。完成通知是异步发生的。换句话说:then()catch() 的回调总是在当前代码执行完毕后执行。

以下代码演示了它是如何工作的

async function asyncFunc() {
    console.log('asyncFunc()'); // (A)
    return 'abc';
}
asyncFunc().
then(x => console.log(`Resolved: ${x}`)); // (B)
console.log('main'); // (C)

// Output:
// asyncFunc()
// main
// Resolved: abc

您可以依赖以下顺序

  1. 第 (A) 行:异步函数同步启动。异步函数的 Promise 通过 return 实现。
  2. 第 (C) 行:继续执行。
  3. 第 (B) 行:Promise 实现通知异步发生。

5.2.4 返回的 Promise 不会被包装

实现 Promise 是一个标准操作。return 使用它来实现异步函数的 Promise p。这意味着

  1. 返回非 Promise 值将使用该值实现 p
  2. 返回 Promise 意味着 p 现在反映了该 Promise 的状态。

因此,您可以返回一个 Promise,并且该 Promise 不会被包装在另一个 Promise 中

async function asyncFunc() {
    return Promise.resolve(123);
}
asyncFunc()
.then(x => console.log(x)) // 123

有趣的是,返回一个被拒绝的 Promise 会导致异步函数的结果被拒绝(通常,您会使用 throw 来实现这一点)

async function asyncFunc() {
    return Promise.reject(new Error('Problem!'));
}
asyncFunc()
.catch(err => console.error(err)); // Error: Problem!

这与 Promise 实现的工作原理一致。它使您能够转发另一个异步计算的实现和拒绝,而无需使用 await

async function asyncFunc() {
    return anotherAsyncFunc();
}

前面的代码与以下代码大致相似(但效率更高),后者解包 anotherAsyncFunc() 的 Promise 只是为了再次包装它

async function asyncFunc() {
    return await anotherAsyncFunc();
}

5.3 使用 await 的技巧

5.3.1 不要忘记 await

在异步函数中,一个容易犯的错误是在进行异步函数调用时忘记 await

async function asyncFunc() {
    const value = otherAsyncFunc(); // missing `await`!
    ···
}

在此示例中,value 被设置为 Promise,这通常不是您在异步函数中想要的。

即使异步函数没有返回任何内容,await 也有意义。然后,它的 Promise 只是用作信号,告诉调用者它已完成。例如

async function foo() {
    await step1(); // (A)
    ···
}

第 (A) 行中的 await 确保在执行 foo() 的其余部分之前,step1() 已完全完成。

5.3.2 如果您“触发即忘记”,则不需要 await

有时,您只想触发异步计算,而并不关心它何时完成。以下代码就是一个例子

async function asyncFunc() {
    const writer = openFile('someFile.txt');
    writer.write('hello'); // don’t wait
    writer.write('world'); // don’t wait
    await writer.close(); // wait for file to close
}

在这里,我们不关心单个写入何时完成,只关心它们是否按正确的顺序执行(API 必须保证这一点,但异步函数的执行模型鼓励这样做,正如我们所见)。

asyncFunc() 最后一行中的 await 确保仅在文件成功关闭后才实现该函数。

鉴于返回的 Promise 不会被包装,您也可以使用 return 而不是 await writer.close()

async function asyncFunc() {
    const writer = openFile('someFile.txt');
    writer.write('hello');
    writer.write('world');
    return writer.close();
}

这两种版本都有优缺点,await 版本可能更容易理解。

5.3.3 await 是顺序的,Promise.all() 是并行的

以下代码进行了两次异步函数调用,asyncFunc1()asyncFunc2()

async function foo() {
    const result1 = await asyncFunc1();
    const result2 = await asyncFunc2();
}

但是,这两个函数调用是顺序执行的。并行执行它们往往会加快速度。您可以使用 Promise.all() 来实现这一点

async function foo() {
    const [result1, result2] = await Promise.all([
        asyncFunc1(),
        asyncFunc2(),
    ]);
}

现在,我们不是等待两个 Promise,而是等待一个包含两个元素的数组的 Promise。

5.4 异步函数和回调

异步函数的一个限制是 await 只影响直接包围它的异步函数。因此,异步函数不能在回调中使用 await(但是,回调本身可以是异步函数,我们将在后面看到)。这使得基于回调的实用函数和方法难以使用。例如数组方法 map()forEach()

5.4.1 Array.prototype.map()

让我们从数组方法 map() 开始。在以下代码中,我们想下载 URL 数组指向的文件,并将它们返回到一个数组中。

async function downloadContent(urls) {
    return urls.map(url => {
        // Wrong syntax!
        const content = await httpGet(url);
        return content;
    });
}

这不起作用,因为 await 在普通箭头函数中是非法的语法。那么,使用异步箭头函数呢?

async function downloadContent(urls) {
    return urls.map(async (url) => {
        const content = await httpGet(url);
        return content;
    });
}

这段代码有两个问题

我们可以通过 Promise.all() 解决这两个问题,它将 Promise 数组转换为数组的 Promise(使用 Promise 实现的值)

async function downloadContent(urls) {
    const promiseArray = urls.map(async (url) => {
        const content = await httpGet(url);
        return content;
    });
    return await Promise.all(promiseArray);
}

map() 的回调对 httpGet() 的结果没有做太多处理,只是转发了它。因此,我们这里不需要异步箭头函数,普通箭头函数就可以了

async function downloadContent(urls) {
    const promiseArray = urls.map(
        url => httpGet(url));
    return await Promise.all(promiseArray);
}

我们还可以做一个小小的改进:这个异步函数效率有点低——它首先通过 await 解包 Promise.all() 的结果,然后再通过 return 包装它。鉴于 return 不会包装 Promise,我们可以直接返回 Promise.all() 的结果

async function downloadContent(urls) {
    const promiseArray = urls.map(
        url => httpGet(url));
    return Promise.all(promiseArray);
}

5.4.2 Array.prototype.forEach()

让我们使用数组方法 forEach() 来记录 URL 指向的多个文件的内容

async function logContent(urls) {
    urls.forEach(url => {
        // Wrong syntax
        const content = await httpGet(url);
        console.log(content);
    });
}

同样,这段代码会产生语法错误,因为您不能在普通箭头函数中使用 await

让我们使用异步箭头函数

async function logContent(urls) {
    urls.forEach(async url => {
        const content = await httpGet(url);
        console.log(content);
    });
    // Not finished here
}

这确实有效,但有一个需要注意的地方:httpGet() 返回的 Promise 是异步实现的,这意味着当 forEach() 返回时,回调还没有完成。因此,您不能等待 logContent() 的结束。

如果这不是您想要的,您可以将 forEach() 转换为 for-of 循环

async function logContent(urls) {
    for (const url of urls) {
        const content = await httpGet(url);
        console.log(content);
    }
}

现在,所有内容都在 for-of 循环结束后完成。但是,处理步骤是顺序发生的:只有在第一次调用完成后,才会第二次调用 httpGet()。如果您希望处理步骤并行发生,则必须使用 Promise.all()

async function logContent(urls) {
    await Promise.all(urls.map(
        async url => {
            const content = await httpGet(url);
            console.log(content);
        }));
}

map() 用于创建 Promise 数组。我们对它们实现的结果不感兴趣,我们只 await 直到它们都实现为止。这意味着我们在该异步函数结束时就完全完成了。我们也可以返回 Promise.all(),但这样函数的结果将是一个所有元素都为 undefined 的数组。

5.5 使用异步函数的技巧

5.5.1 了解您的 Promise

异步函数的基础是 Promise。这就是为什么理解后者对于理解前者至关重要。特别是当将未基于 Promise 的旧代码与异步函数连接时,您通常别无选择,只能直接使用 Promise。

例如,这是 XMLHttpRequest 的“Promise 化”版本

function httpGet(url, responseType="") {
    return new Promise(
        function (resolve, reject) {
            const request = new XMLHttpRequest();
            request.onload = function () {
                if (this.status === 200) {
                    // Success
                    resolve(this.response);
                } else {
                    // Something went wrong (404 etc.)
                    reject(new Error(this.statusText));
                }
            };
            request.onerror = function () {
                reject(new Error(
                    'XMLHttpRequest Error: '+this.statusText));
            };
            request.open('GET', url);
            xhr.responseType = responseType;
            request.send();
        });
}

XMLHttpRequest 的 API 基于回调。通过异步函数对其进行 Promise 化意味着您必须从回调内部实现或拒绝函数返回的 Promise。这是不可能的,因为您只能通过 returnthrow 来实现。而且您不能从回调内部 return 函数的结果。throw 也有类似的限制。

因此,异步函数的常见编码风格是

延伸阅读:“Exploring ES6”中的“用于异步编程的 Promise”一章。

5.5.2 立即调用的异步函数表达式

有时,如果能够在模块或脚本的顶层使用 await 会很方便。可惜的是,它只能在异步函数内部使用。因此,您有几种选择。您可以创建一个异步函数 main() 并在之后立即调用它

async function main() {
    console.log(await asyncFunction());
}
main();

或者,您可以使用立即调用的异步函数表达式

(async function () {
    console.log(await asyncFunction());
})();

另一种选择是立即调用的异步箭头函数

(async () => {
    console.log(await asyncFunction());
})();

5.5.3 使用异步函数进行单元测试

以下代码使用 测试框架 mocha 对异步函数 asyncFunc1()asyncFunc2() 进行单元测试

import assert from 'assert';

// Bug: the following test always succeeds
test('Testing async code', function () {
    asyncFunc1() // (A)
    .then(result1 => {
        assert.strictEqual(result1, 'a'); // (B)
        return asyncFunc2();
    })
    .then(result2 => {
        assert.strictEqual(result2, 'b'); // (C)
    });
});

但是,此测试始终成功,因为 mocha 不会等到第 (B) 行和第 (C) 行中的断言执行完毕。

您可以通过返回 Promise 链的结果来解决此问题,因为 mocha 会识别测试是否返回 Promise,然后等待该 Promise 完成(除非超时)。

return asyncFunc1() // (A)

方便的是,异步函数始终返回 Promise,这使得它们非常适合这种单元测试

import assert from 'assert';
test('Testing async code', async function () {
    const result1 = await asyncFunc1();
    assert.strictEqual(result1, 'a');
    const result2 = await asyncFunc2();
    assert.strictEqual(result2, 'b');
});

因此,在 mocha 中使用异步函数进行异步单元测试有两个优点:代码更简洁,并且也处理了返回 Promise 的问题。

5.5.4 无需担心未处理的拒绝

JavaScript 引擎在警告未处理的拒绝方面越来越出色。例如,以下代码在过去通常会静默失败,但现在大多数现代 JavaScript 引擎都会报告未处理的拒绝

async function foo() {
    throw new Error('Problem!');
}
foo();

5.6 延伸阅读

下一页:6. 共享内存和原子操作