首页 阅读DAY16 JavaScript高级程序设计 11章下 期约与异步函数
文章
取消

阅读DAY16 JavaScript高级程序设计 11章下 期约与异步函数

开始阅读JavaScript高级程序设计(第5版)学习JS,总共有1000+页,非常全面,短期看完不太现实,找到了一篇博客,花些时间跟着这篇博客过一下红宝书。

JavaScript高级程序设计(第5版)微信读书

红宝书《JavaScript高级程序设计(第5版)》学习大纲 - 大前端全栈开发 - SegmentFault 思否

全文概览:

重要度知识点理由
⭐⭐⭐async/await 基本语法现代异步编程的基石,面试必考,开发中每日必用
⭐⭐⭐await 暂停执行 + 微任务入队理解事件循环的核心,await 后面的代码会变成微任务
⭐⭐⭐async 函数始终返回 Promise新手最容易误解的点,面试高频追问
⭐⭐⭐并行执行(Promise.all + await)实际项目性能优化必会,串行 vs 并行是面试经典题
⭐⭐⭐for-await-of处理异步迭代器,大厂项目中流式处理、分页拉取常用
⭐⭐⭐串行执行期约(循环 + await)理解 async 函数链式调用,面试手写常用
⭐⭐async 函数中的错误处理(try/catch vs .catch())实际开发必会,面试问”await 怎么捕获错误”
⭐⭐await 对 thenable 的解包规则理解 await 背后的 Promise.resolve 包装机制
⭐⭐实现 sleep()面试常考题,考察对 Promise + setTimeout 的理解
⭐⭐安全处理拒绝(Promise.allSettled)实际项目处理部分失败场景,大厂必会
⭐⭐await 在 async 函数外的限制语法限制,面试可能追问”哪里可以用 await”
栈跟踪与内存管理(async vs Promise)偏底层,性能优化时偶有用,面试加分项
异步生成器(async function*)高级特性,处理异步流式数据,一般项目很少手写

异步函数:

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

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

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

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

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

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

1
2
3
4
5
function handler(x) { console.log(x); }

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

p.then(handler); // 3

这个改进其实也不大。这是因为任何需要访问这个期约所产生值的代码,都需要以处理程序的形式来接收这个值。也就是说,代码照样还是要放到处理程序里。而使用异步(async)函数来等待(await)值,也就是异步函数可以优雅地解决这个问题。

异步函数基础:

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

async关键字:

1
2
3
4
5
6
7
8
9
async function foo() {}

let bar = async function() {};

let baz = async () => {};

class Qux {
  async qux() {}
}

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

1
2
3
4
5
6
7
8
9
async function foo() {
  console.log(1);
}

foo();
console.log(2);

// 1
// 2

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

1
2
3
4
5
6
7
8
9
10
11
12
13
async function foo() {
  console.log(1);
  return 3;
}

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

console.log(2);

// 1
// 2
// 3

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

1
2
3
4
5
6
7
8
9
10
11
12
13
async function foo() {
  console.log(1);
  return Promise.resolve(3);
}

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

console.log(2);

// 1
// 2
// 3

异步函数的返回值最好是(但实际上并不要求)一个实现thenable接口的对象,但常规的值也可以。

如果返回的是实现thenable接口的对象,则这个对象可以由提供给then()的处理程序“解包”。如果不是,则返回值就被当作已经兑现的期约。下面的代码演示了这些情况:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// 返回一个原始值
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

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

1
2
3
4
5
6
7
8
9
10
11
12
async function foo() {
  console.log(1);
  throw 3;
}

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

// 1
// 2
// 3

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

1
2
3
4
5
6
7
8
9
10
11
12
async function foo() {
  console.log(1);
  Promise.reject(3);
}

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

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

await关键字:

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

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

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

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

1
2
3
4
5
6
7
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的一元操作一样。它可以单独使用,也可以在表达式中使用,如下面的例子所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 异步打印"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来“解包”。如果不是,则这个值就被当作已经兑现的期约。下面的代码演示了这些情况:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// 等待一个原始值
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

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

1
2
3
4
5
6
7
8
9
10
11
12
async function foo() {
  console.log(1);
  await (() => { throw 3; })();
}

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

// 1
// 2
// 3

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

1
2
3
4
5
6
7
8
9
10
11
12
13
async function foo() {
  console.log(1);
  await Promise.reject(3);
  console.log(4); // 这行代码不会执行
}

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

// 1
// 2
// 3

await的限制:

await关键字可以在异步函数或模块的顶级上下文中使用。如果需要在非异步函数中使用await,可以使用立即调用的异步函数。下面两段代码实际是相同的:

1
2
3
4
5
6
7
8
9
10
11
async function foo() {
  console.log(await Promise.resolve(3));
}
foo();
// 3

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

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// 不允许: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')))();
}

for-await-of循环提供了一种在JavaScript中方便、简捷地迭代异步数据流的方式。与传统的for-of循环类似,只不过不是迭代同步数组或其他可迭代对象,而是在迭代下一项之前先等待异步操作完成。for-await-of循环的语法如下所示:

1
2
3
for await (let variable of iterable) {
  // 要执行的代码
}

在这个语法中,可迭代对象可以是任何异步可迭代对象,比如期约、异步函数或者拥有Symbol.asyncIterator方法的对象。

异步函数是另一种可以在for-await-of循环中使用的可迭代对象。我们来看一个通过forawait-of迭代异步函数的例子。假设有一个异步函数返回解决为随机数值的期约。我们可以使用for-await-of等待这些期约并迭代得到的随机数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
async function getRandomNumber(i) {
  return new Promise(resolve => {
    console.log(i);
    setTimeout(resolve, 1000, Math.random());
  });
}

async function printRandomNumbers() {
  for await (const x of Array.from(Array(5).keys()).map(getRandomNumber)) {
    console.log(x);
  }
  console.log("loop has exited");
}

printRandomNumbers();

// 立即输出:
// 0
// 1
// 2
// 3
// 4

// 1000毫秒后输出:
// 0.8748458184008716(依次输出每个随机数)
// ...
// loop has exited

在这个例子中,我们定义了异步函数getRandomNumber(),它返回一个期约,在1000毫秒后解决为一个随机数。而printRandomNumbers()函数使用for-await-of循环迭代由getRandomNumber()返回的期约,并将得到的随机数输出到控制台。

如果我们在前面的代码中删除async关键字,并使用常规的for-of循环,那就会看到如下输出:

1
2
3
4
5
6
7
8
9
10
// 立即输出:
// 0
// 1
// 2
// 3
// 4

// Promise <pending>
// ...
// loop has exited

for-await-of循环既可以处理常规可迭代对象,也可以处理异步可迭代对象:

1
2
3
4
5
6
7
8
9
10
const myArray = [1, 2, 3];

for await (const item of myArray) {
  console.log(item);
}

// 立即输出:
// 1
// 2
// 3

下面我们重构上面的代码,让for-await-of消费由异步迭代器生成的值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
async function* asyncIterable(array) {
  for (const item of array) {
    yield item;
  }
}

const myArray = [1, 2, 3];

for await (const item of asyncIterable(myArray)) {
  console.log(item);
}

// 立即输出:
// 1
// 2
// 3

为观察for-await-of循环按顺序消费异步生成的值,可以让异步生成器延迟生成值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
async function* asyncIterable(array) {
  for (const item of array) {
    // 延迟1000毫秒
    await new Promise(resolve => setTimeout(resolve, 1000));
    yield item;
  }
}

const myArray = [1, 2, 3];

for await (const item of asyncIterable(myArray)) {
  console.log(item);
}

// 1000毫秒后输出:
// 1

// 2000毫秒后输出:
// 2

// 3000毫秒后输出:
// 3

关于生成器函数,可以参考第7章。

异步函数策略:

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

实现sleep():

很多人在刚开始学习JavaScript时,想找到一个类似Java中Thread.sleep()之类的函数,以便程序中加入非阻塞的暂停。有了异步函数之后,一个简单的箭头函数就可以实现sleep():

1
2
3
4
5
6
7
8
9
10
11
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

利用并行执行:

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
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循环重写,就是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
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

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
async function randomDelay(id) {
  // 延迟0~1000毫秒
  const delay = Math.random() * 1000;
  return new Promise((resolve) => setTimeout(() => {
    console.asyncLog(`${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;

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

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
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按顺序收到了每个兑现期约的值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
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

串行执行期约:

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

1
2
3
4
5
6
7
8
9
10
11
12
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直接传递了每个函数的返回值,结果通过迭代产生。当然,这个例子并没有使用期约,如果要使用期约,则可以把所有函数都改成异步函数。这样它们就都返回期约了:

1
2
3
4
5
6
7
8
9
10
11
12
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

栈跟踪与内存管理:

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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引擎会在创建期约时尽可能保留完整的调用栈。在抛出错误时,调用栈可以由运行时的错误处理逻辑获取,因而就会出现在栈跟踪信息中。当然,这意味着栈跟踪信息会占用内存,从而带来一些计算和存储成本。

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

1
2
3
4
5
6
7
8
9
10
11
12
13
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运行时可以简单地在嵌套函数中存储指向包含函数的指针,就跟对待同步函数调用栈一样。这个指针实际上存储在内存中,可用于在出错时生成栈跟踪信息。这样就不会像之前的例子那样带来额外的消耗,因此在重视性能的应用中是可以优先考虑的。

安全地处理拒绝及并行:

在处理不可靠的期约时,async/await还是稍微有点儿严格。假设一个比较少见的情况,比如我们想派发一系列网络请求,从某个想象的API加载数据。假设想象中的每个页面都是一个任意数据的数组,而连续的页面应该按顺序拼装起来。

以下代码是简单的实现:

1
2
3
4
5
6
7
8
9
10
11
12
async function getApiPages(pageCount) {
  const data = [];
  const pageUrls = Array.from(Array(pageCount).keys())
    .map(i => `https://example.com/api?page=${i}`);

  for (const url of pageUrls) {
    const response = await fetch(url);
    const pageData = await response.json();
    data.push(pageData);
  }
  return data.flat();
}

这个实现按顺序返回页面,但有一些问题。

​ 网络请求是串行的,因此比较慢。

​ 任何被拒绝的fetch()都会抛出未处理的拒绝。

要并行发送请求同时保持顺序,并处理任何拒绝,可以将代码重构为下面这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
async function getApiPages(pageCount) {
    const data = [];
    const pageUrls = Array.from(Array(pageCount).keys())
      .map(i => `https://example.com/api?page=${i}`);

    // 并行派发请求且将fetch()
    // 返回的期约放到一个数组中
    const pagePromises = pageUrls.map(async (url) => {
      const response = await fetch(url);
      return response.json();
    });

    // 为所有期约隐式添加拒绝处理程序
    Promise.allSettled(pagePromises);

    // 记录数据及页面索引
    for await (const [i, pageData] of pagePromises.entries()) {
      data[i] = pageData;
    }
    return data.flat();
}

当然,这个实现还不够完善,因为静默地忽略了某些页面无法加载的情况。如果想为这些期约添加拒绝处理逻辑,也可以补充添加而不影响整体行为。这里使用Promise.allSettled()简单地消除了未处理的拒绝,如果想处理加载失败的页面,只要额外添加onRejected处理程序即可。

这个例子受到了Jake Archibald博客中一篇精彩文章“The gotcha of unhandled promise rejections”的启发。

本文由作者按照 CC BY 4.0 进行授权

阅读DAY15 JavaScript高级程序设计 11章上 期约与异步函数

番外 事件循环