第十一章——期约与异步函数(下)——异步函数

11.3 异步函数

        异步函数,也称为“async/await”(语法关键字),是ES6期约模式在ECMAScript函数中的应用。async/await是ES8规范新增的。这个特性从行为和语法上都增强了JavaScript,让以同步方式写的代码能够异步执行。下面来看一个最简单的例子,这个期约在超时之后会解决为一个值:

let p = new Promise((resolve, reject) => setTimeout(resolve, 1000, 3));

        这个期约在1000毫秒之后解决为数值3。如果程序中的其他代码要在这个值可用时访问它,则需要写一个解决处理程序:

let p = new Promise((resolve, reject) => setTimeout(resolve, 1000, 3));

p.then((x) => console.log(x)); // 3

        这其实是很不方便的,因为其他代码都必须塞到期约处理程序中。不过可以把处理程序定义为一个函数:

function handler(x) { console.log(x); }

let p = new Promise((resolve, reject) => setTimeout(resolve, 1000, 3));

p.then(handler); // 3

        这个改进其实也不大。这是因为任何需要访问这个期约所产生值的代码,都需要以处理程序的形式来接收这个值。也就是说,代码照样还是要放到处理程序里。ES8为此提供了async/await关键字。

11.3.1 异步函数

       ES8的async/await旨在解决利用异步结构组织代码的问题。为此,ECMAScript对函数进行了扩展,为其增加了两个新关键字:async和await。

1.async

        async关键字用于声明异步函数。这个关键字可以用在函数声明、函数表达式、箭头函数和方法上:

async function foo() {}

let bar = async function() {};

let baz = async () => {};

class Qux {
    async qux() {}
}

        使用async关键字可以让函数具有异步特征,但总体上其代码仍然是同步求值的。而在参数或闭包方面,异步函数仍然具有普通JavaScript函数的正常行为。正如下面的例子所示,foo()函数仍然会在后面的指令之前被求值:

async function foo() {
    console.log(1);
}

foo();
console.log(2);

// 1
// 2

        不过,异步函数如果使用return关键字返回了值(如果没有return则会返回undefined),这个值会被Promise.resolve()包装成一个期约对象。异步函数始终返回期约对象。在函数外部调用这个函数可以得到它返回的期约:

async function foo() {
    console.log(1);
    return 3;
}

// 给返回的期约添加一个解决处理程序
foo().then(console.log);

console.log(2);

// 1
// 2
// 3

        当然,直接返回一个期约对象也是一样的:

async function foo() {
    console.log(1);
    return Promise.resolve(3);
}

// 给返回的期约添加一个解决处理程序
foo().then(console.log);

console.log(2);

// 1
// 2
// 3

        异步函数的返回值期待(但实际上并不要求)一个实现thenable接口的对象,但常规的值也可以。如果返回的是实现thenable接口的对象,则这个对象可以由提供给then()的处理程序“解包”。如果不是,则返回值就被当作已经解决的期约。下面的代码演示了这些情况:

// 返回一个原始值
async function foo() {
    return 'foo';
}
foo().then(console.log);
// foo

// 返回一个没有实现thenable接口的对象
async function bar() {
    return ['bar'];
}
bar().then(console.log);
// ['bar']

// 返回一个实现了thenable接口的非期约对象
async function baz() {
    const thenable = {
        then(callback) { callback('baz'); }
    };
    return thenable;
}
baz().then(console.log);
// baz

// 返回一个期约
async function qux() {
    return Promise.resolve('qux');
}
qux().then(console.log);
// qux

        与在期约处理程序中一样,在异步函数中抛出错误会返回拒绝的期约:

async function foo() {
    console.log(1);
    throw 3;
}

// 给返回的期约添加一个拒绝处理程序
foo().catch(console.log);
console.log(2);

// 1
// 2
// 3

        不过,拒绝期约的错误不会被异步函数捕获:

async function foo() {
    console.log(1);
    Promise.reject(3);
}

// Attach a rejected handler to the returned promise
foo().catch(console.log);
console.log(2);

// 1
// 2
// Uncaught (in promise): 3

2.await

        因为异步函数主要针对不会马上完成的任务,所以自然需要一种暂停和恢复执行的能力。使用await关键字可以暂停异步函数代码的执行,等待期约解决。来看下面这个本章开始就出现过的例子:

let p = new Promise((resolve, reject) => setTimeout(resolve, 1000, 3));

p.then((x) => console.log(x)); // 3

        使用async/await可以写成这样:

async function foo() {
    let p = new Promise((resolve, reject) => setTimeout(resolve, 1000, 3));
    console.log(await p);
}

foo();
// 3

        注意,await关键字会暂停执行异步函数后面的代码,让出JavaScript运行时的执行线程。这个行为与生成器函数中的yield关键字是一样的。await关键字同样是尝试“解包”对象的值,然后将这个值传给表达式,再异步恢复异步函数的执行。

        await关键字的用法与JavaScript的一元操作一样。它可以单独使用,也可以在表达式中使用,如下面的例子所示:

// 异步打印"foo"
async function foo() {
    console.log(await Promise.resolve('foo'));
}
foo();
// foo

// 异步打印"bar"
async function bar() {
    return await Promise.resolve('bar');
}
bar().then(console.log);
// bar

// 1000毫秒后异步打印"baz"
async function baz() {
    await new Promise((resolve, reject) => setTimeout(resolve, 1000));
    console.log('baz');
}
baz();
// baz(1000毫秒后)

        await关键字期待(但实际上并不要求)一个实现thenable接口的对象,但常规的值也可以。如果是实现thenable接口的对象,则这个对象可以由await来“解包”。如果不是,则这个值就被当作已经解决的期约。下面的代码演示了这些情况:

// 等待一个原始值
async function foo() {
    console.log(await 'foo');
}
foo();
// foo

// 等待一个没有实现thenable接口的对象
async function bar() {
    console.log(await ['bar']);
}
bar();
// ['bar']

// 等待一个实现了thenable接口的非期约对象
async function baz() {
    const thenable = {
        then(callback) { callback('baz'); }
    };
    console.log(await thenable);
}
baz();
// baz

// 等待一个期约
async function qux() {
    console.log(await Promise.resolve('qux'));
}
qux();
// qux

        等待会抛出错误的同步操作,会返回拒绝的期约:

async function foo() {
    console.log(1);
    await (() => { throw 3; })();
}

// 给返回的期约添加一个拒绝处理程序
foo().catch(console.log);
console.log(2);

// 1
// 2
// 3

        如前面的例子所示,单独的Promise.reject()不会被异步函数捕获,而会抛出未捕获错误。不过,对拒绝的期约使用await则会释放(unwrap)错误值(将拒绝期约返回):

async function foo() {
    console.log(1);
    await Promise.reject(3);
    console.log(4); // 这行代码不会执行
}

// 给返回的期约添加一个拒绝处理程序
foo().catch(console.log);
console.log(2);

// 1
// 2
// 3

3.await的限制

        await关键字必须在异步函数中使用,不能在顶级上下文如<script>标签或模块中使用。不过,定义并立即调用异步函数是没问题的。下面两段代码实际是相同的:

async function foo() {
    console.log(await Promise.resolve(3));
}
foo();
// 3

// 立即调用的异步函数表达式
(async function() {
    console.log(await Promise.resolve(3));
})();
// 3

        此外,异步函数的特质不会扩展到嵌套函数。因此,await关键字也只能直接出现在异步函数的定义中。在同步函数内部使用await会抛出SyntaxError。

        下面展示了一些会出错的例子:

// 不允许:await出现在了箭头函数中
function foo() {
    const syncFn = () => {
        return await Promise.resolve('foo');
    };
    console.log(syncFn());
}

// 不允许:await出现在了同步函数声明中
function bar() {
    function syncFn() {
        return await Promise.resolve('bar');
    }
    console.log(syncFn());
}

// 不允许:await出现在了同步函数表达式中
function baz() {
    const syncFn = function() {
        return await Promise.resolve('baz');
    };
    
console.log(syncFn());
}
// 不允许:IIFE使用同步函数表达式或箭头函数
function qux() {
    (function () { console.log(await Promise.resolve('qux')); })();
    (() => console.log(await Promise.resolve('qux')))();
}

11.3.2 停止和恢复执行

        使用await关键字之后的区别其实比看上去的还要微妙一些。比如,下面的例子中按顺序调用了3个函数,但它们的输出结果顺序是相反的:

async function foo() {
    console.log(await Promise.resolve('foo'));
}

async function bar() {
    console.log(await 'bar');
}

async function baz() {
    console.log('baz');
}

foo();
bar();
baz();

// baz
// bar
// foo

        async/await中真正起作用的是await。async关键字,无论从哪方面来看,都不过是一个标识符。毕竟,异步函数如果不包含await关键字,其执行基本上跟普通函数没有什么区别:

async function foo() {
    console.log(2);
}

console.log(1);
foo();
console.log(3);

// 1
// 2
// 3

        要完全理解await关键字,必须知道它并非只是等待一个值可用那么简单。JavaScript运行时在碰到await关键字时,会记录在哪里暂停执行。等到await右边的值可用了,JavaScript运行时会向消息队列中推送一个任务,这个任务会恢复异步函数的执行。

        因此,即使await后面跟着一个立即可用的值,函数的其余部分也会被异步求值。下面的例子演示了这一点:

async function foo() {
    console.log(2);
    await null;
    console.log(4);
}

console.log(1);
foo();
console.log(3);

// 1
// 2
// 3
// 4

        控制台中输出结果的顺序很好地解释了运行时的工作过程:        

(1) 打印1;
(2) 调用异步函数foo();
(3)(在foo()中)打印2;
(4)(在foo()中)await关键字暂停执行,为立即可用的值null向消息队列中添加一个任务;
(5) foo()退出;
(6) 打印3;
(7) 同步线程的代码执行完毕;
(8) JavaScript运行时从消息队列中取出任务,恢复异步函数执行;
(9)(在foo()中)恢复执行,await取得null值(这里并没有使用);
(10)(在foo()中)打印4;
(11) foo()返回。

        如果await后面是一个期约,则问题会稍微复杂一些。此时,为了执行异步函数,实际上会有两个任务被添加到消息队列并被异步求值。下面的例子虽然看起来很反直觉,但它演示了真正的执行顺序:

async function foo() {
    console.log(2);
    console.log(await Promise.resolve(8));
    console.log(9);
}

async function bar() {
    console.log(4);
    console.log(await 6);
    console.log(7);
}

console.log(1);
foo();
console.log(3);
bar();
console.log(5);
// 1
// 2
// 3
// 4
// 5
// 6
// 7
// 8
// 9

        运行时会像这样执行上面的例子:

(1) 打印1;
(2) 调用异步函数foo();
(3)(在foo()中)打印2;
(4)(在foo()中)await关键字暂停执行,向消息队列中添加一个期约在
落定之后执行的任务;
(5) 期约立即落定,把给await提供值的任务添加到消息队列;
(6) foo()退出;
(7) 打印3;
(8) 调用异步函数bar();
(9)(在bar()中)打印4;
(10)(在bar()中)await关键字暂停执行,为立即可用的值6向消息队列中添加一个任务;
(11) bar()退出;
(12) 打印5;
(13) 顶级线程执行完毕;
(14) JavaScript运行时从消息队列中取出解决await期约的处理程序,并将解决的值8提供给它;
(15) JavaScript运行时向消息队列中添加一个恢复执行foo()函数的任务;
(16) JavaScript运行时从消息队列中取出恢复执行bar()的任务及值6;
(17)(在bar()中)恢复执行,await取得值6;
(18)(在bar()中)打印6;
(19)(在bar()中)打印7;
(20) bar()返回;
(21) 异步任务完成,JavaScript从消息队列中取出恢复执行foo()的任务及值8;
(22)(在foo()中)打印8;
(23)(在foo()中)打印9;
(24) foo()返回。

11.3.3 异步函数策略

        因为简单实用,所以异步函数很快成为JavaScript项目使用最广泛的特性之一。不过,在使用异步函数时,还是有些问题要注意。

1.实现sleep()

        很多人在刚开始学习JavaScript时,想找到一个类似Java中Thread.sleep()之类的函数,好在程序中加入非阻塞的暂停。以前,这个需求基本上都通过setTimeout()利用JavaScript运行时的行
为来实现的。

        有了异步函数之后,就不一样了。一个简单的箭头函数就可以实现sleep():

async function sleep(delay) {
    return new Promise((resolve) => setTimeout(resolve, delay));
}

async function foo() {
    const t0 = Date.now();
    await sleep(1500); // 暂停约1500毫秒
    console.log(Date.now() - t0);
}
foo();
// 1502

2.利用平行执行

        如果使用await时不留心,则很可能错过平行加速的机会。来看下面的例子,其中顺序等待了5个随机的超时:

async function randomDelay(id) {
    // 延迟0~1000毫秒
    const delay = Math.random() * 1000;
    return new Promise((resolve) => setTimeout(() => {
        console.log(`${id} finished`);
        resolve();
    }, delay));
}

async function foo() {
    const t0 = Date.now();
    await randomDelay(0);
    await randomDelay(1);
    await randomDelay(2);
    await randomDelay(3);
    await randomDelay(4);
    console.log(`${Date.now() - t0}ms elapsed`);
}
foo();
// 0 finished
// 1 finished
// 2 finished
// 3 finished
// 4 finished
// 877ms elapsed

        用一个for循环重写,就是:

async function randomDelay(id) {
    // 延迟0~1000毫秒
    const delay = Math.random() * 1000;
    return new Promise((resolve) => setTimeout(() => {
        console.log(`${id} finished`);
        resolve();
    }, delay));
}

async function foo() {
    const t0 = Date.now();
    for (let i = 0; i < 5; ++i) {
        await randomDelay(i);
    }

    console.log(`${Date.now() - t0}ms elapsed`);
}
foo();

// 0 finished
// 1 finished
// 2 finished
// 3 finished
// 4 finished
// 877ms elapsed

        就算这些期约之间没有依赖,异步函数也会依次暂停,等待每个超时完成。这样可以保证执行顺序,但总执行时间会变长。

        如果顺序不是必需保证的,那么可以先一次性初始化所有期约,然后再分别等待它们的结果。比如:

async function randomDelay(id) {
    // 延迟0~1000毫秒
    const delay = Math.random() * 1000;
    return new Promise((resolve) => setTimeout(() => {
        setTimeout(console.log, 0, `${id} finished`);
        resolve();
    }, delay));
}

async function foo() {
    const t0 = Date.now();
    const p0 = randomDelay(0);
    const p1 = randomDelay(1);
    const p2 = randomDelay(2);
    const p3 = randomDelay(3);
    const p4 = randomDelay(4);

    await p0;
    await p1;
    await p2;
    await p3;
    await p4;

    setTimeout(console.log, 0, `${Date.now() - t0}ms elapsed`);
}
foo();

// 1 finished
// 4 finished
// 3 finished
// 0 finished
// 2 finished
// 877ms elapsed

        用数组和for循环再包装一下就是:

async function randomDelay(id) {
    // 延迟0~1000毫秒
    const delay = Math.random() * 1000;
    return new Promise((resolve) => setTimeout(() => {
        console.log(`${id} finished`);
        resolve();
    }, delay));
}

async function foo() {
    const t0 = Date.now();

    const promises = Array(5).fill(null).map((_, i) => randomDelay(i));

    for (const p of promises) {
        await p;
    }

    console.log(`${Date.now() - t0}ms elapsed`);
}
foo();

// 4 finished
// 2 finished
// 1 finished
// 0 finished
// 3 finished
// 877ms elapsed

        注意,虽然期约没有按照顺序执行,但await按顺序收到了每个期约的值:

async function randomDelay(id) {
    // 延迟0~1000毫秒
    const delay = Math.random() * 1000;
    return new Promise((resolve) => setTimeout(() => {
        console.log(`${id} finished`);
        resolve(id);
    }, delay));
}

async function foo() {
    const t0 = Date.now();

    const promises = Array(5).fill(null).map((_, i) => randomDelay(i));

    for (const p of promises) {
        console.log(`awaited ${await p}`);
    }

    console.log(`${Date.now() - t0}ms elapsed`);
}
foo();

// 1 finished
// 2 finished
// 4 finished
// 3 finished
// 0 finished
// awaited 0
// awaited 1
// awaited 2
// awaited 3
// awaited 4
// 645ms elapsed

3.串行执行期约

        在11.2节,我们讨论过如何串行执行期约并把值传给后续的期约。使用async/await,期约连锁会变得很简单:

function addTwo(x) {return x + 2;}
function addThree(x) {return x + 3;}
function addFive(x) {return x + 5;}

async function addTen(x) {
    for (const fn of [addTwo, addThree, addFive]) {
        x = await fn(x);
    }
    return x;
}

addTen(9).then(console.log); // 19

        这里,await直接传递了每个函数的返回值,结果通过迭代产生。

        当然,这个例子并没有使用期约,如果要使用期约,则可以把所有函数都改成异步函数。这样它们就都返回期约了:

async function addTwo(x) {return x + 2;}
async function addThree(x) {return x + 3;}
async function addFive(x) {return x + 5;}

async function addTen(x) {
    for (const fn of [addTwo, addThree, addFive]) {
        x = await fn(x);
    }
    return x;
}

addTen(9).then(console.log); // 19

4、栈追踪与内存管理

        期约与异步函数的功能有相当程度的重叠,但它们在内存中的表示则差别很大。看看下面的例子,它展示了拒绝期约的栈追踪信息:

function fooPromiseExecutor(resolve, reject) {
    setTimeout(reject, 1000, 'bar');
}

function foo() {
    new Promise(fooPromiseExecutor);
}

foo();
// Uncaught (in promise) bar
// setTimeout
// setTimeout (async)
// fooPromiseExecutor
// foo

        根据对期约的不同理解程度,以上栈追踪信息可能会让某些读者不解。栈追踪信息应该相当直接地表现JavaScript引擎当前栈内存中函数调用之间的嵌套关系。在超时处理程序执行时和拒绝期约时,我们看到的错误信息包含嵌套函数的标识符,那是被调用以创建最初期约实例的函数。可是,我们知道这些函数已经返回了,因此栈追踪信息中不应该看到它们。

        答案很简单,这是因为JavaScript引擎会在创建期约时尽可能保留完整的调用栈。在抛出错误时,调用栈可以由运行时的错误处理逻辑获取,因而就会出现在栈追踪信息中。当然,这意味着栈追踪信息会占用内存,从而带来一些计算和存储成本。

        如果在前面的例子中使用的是异步函数,那又会怎样呢?比如:

function fooPromiseExecutor(resolve, reject) {
    setTimeout(reject, 1000, 'bar');
}

async function foo() {
    await new Promise(fooPromiseExecutor);
}
foo();

// Uncaught (in promise) bar
// foo
// async function (async)
// foo

        这样一改,栈追踪信息就准确地反映了当前的调用栈。fooPromiseExecutor()已经返回,所以它不在错误信息中。但foo()此时被挂起了,并没有退出。JavaScript运行时可以简单地在嵌套函数中存储指向包含函数的指针,就跟对待同步函数调用栈一样。这个指针实际上存储在内存中,可用于在出错时生成栈追踪信息。这样就不会像之前的例子那样带来额外的消耗,因此在重视性能的应用中是可以优先考虑的。

11.4 小结

        长期以来,掌握单线程JavaScript运行时的异步行为一直都是个艰巨的任务。随着ES6新增了期约和ES8新增了异步函数,ECMAScript的异步编程特性有了长足的进步。通过期约和async/await,不仅可以实现之前难以实现或不可能实现的任务,而且也能写出更清晰、简洁,并且容易理解、调试的代码。

        期约的主要功能是为异步代码提供了清晰的抽象。可以用期约表示异步执行的代码块,也可以用期约表示异步计算的值。在需要串行异步代码时,期约的价值最为突出。作为可塑性极强的一种结构,期约可以被序列化、连锁使用、复合、扩展和重组。

        异步函数是将期约应用于JavaScript函数的结果。异步函数可以暂停执行,而不阻塞主线程。无论是编写基于期约的代码,还是组织串行或平行执行的异步代码,使用异步函数都非常得心应手。异步函数可以说是现代JavaScript工具箱中最重要的工具之一。

  • 7
    点赞
  • 13
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值