[TOC]

11.1 异步编程
异步行为是为了优化因计算量大而时间长的操作。
如果在等待其他操作完成的同时,即使运行其他指令,系统也能保持稳定,那么这样做就是务实的。
重要的是,异步操作并不一定计算量大或要等很长时间。
只要你不想为等待某个异步操作而阻塞线程执行,那么任何时候都可以使用。
11.1.1 同步与异步
同步行为对应内存中顺序执行的处理器指令。每条指令都会严格按照它们出现的顺序来执行,而每条指令执行后也能立即获得存储在系统本地(如寄存器或系统内存)的信息。这样的执行流程容易分析程序在执行到代码任意位置时的状态(比如变量的值)。
同步操作的例子可以是执行一次简单的数学计算:

let x = 3;
x = x + 4;

在程序执行的每一步,都可以推断出程序的状态。
这是因为后面的指令总是在前面的指令完成后才会执行。
等到最后一条指定执行完毕,存储在x的值就立即可以使用。
这两行JavaScript代码对应的低级指令(从JavaScript到x86)并不难想象。首先,操作系统会在栈内存上分配一个存储浮点数值的空间,然后针对这个值做一次数学计算,再把计算结果写回之前分配的内存中。
所有这些指令都是在单个线程中按顺序执行的。
在低级指令的层面,有充足的工具可以确定系统状态。
相对地,异步行为类似于系统中断,即当前进程外部的实体可以触发代码执行。
异步操作经常是必要的,因为强制进程等待一个长时间的操作通常是不可行的(同步操作则必须要等)。
如果代码要访问一些高延迟的资源,比如向远程服务器发送请求并等待响应,那么就会出现长时间的等待。
异步操作的例子可以是在定时回调中执行一次简单的数学计算:

let x = 3;
setTimeout(() => x = x + 4, 1000);

这段程序最终与同步代码执行的任务一样,都是把两个数加在一起,但这一次执行线程不知道x值何时会改变,因为这取决于回调何时从消息队列出列并执行。
异步代码不容易推断。
虽然这个例子对应的低级代码最终跟前面的例子没什么区别,但第二个指令块(加操作及赋值操作)是由系统计时器触发的,这会生成一个入队执行的中断。到底什么时候会触发这个中断,这对JavaScript运行时来说是一个黑盒,因此实际上无法预知(尽管可以保证这发生在当前线程的同步代码执行之后,否则回调都没有机会出列被执行)。
无论如何,在排定回调以后基本没办法知道系统状态何时变化。为了让后续代码能够使用x,异步执行的函数需要在更新x的值以后通知其他代码。如果程序不需要这个值,那么就只管继续执行,不必等待这个结果了。设计一个能够知道x什么时候可以读取的系统是非常难的。JavaScript在实现这样一个系统的过程中也经历了几次迭代。
11.1.2 以往的异步编程模式
在早期的JavaScript中,只支持定义回调函数来表明异步操作完成。
串联多个异步操作是一个常见的问题,通常需要深度嵌套的回调函数(俗称“回调地狱”)来解决。
假设有以下异步函数,使用了setTimeout在一秒钟之后执行某些操作:

function double(value) {
  setTimeout(() => setTimeout(console.log, 0, value * 2), 1000);
}
double(3);  // 6(大概1000毫秒之后)

理解为什么它是一个异步函数:
setTimeout可以定义一个在指定时间之后,会被调度执行的回调函数。
对这个例子而言,1000毫秒之后,JavaScript运行时会把回调函数推到自己的消息队列上去等待执行。
推到队列之后,回调什么时候出列被执行对JavaScript代码就完全不可见了。
还有一点,double()函数在setTimeout成功调度异步操作之后会立即退出。
1.异步返回值
假设setTimeout操作会返回一个有用的值。有什么好办法把这个值传给需要它的地方?
广泛接受的一个策略是给异步操作提供一个回调,这个回调中包含要使用异步返回值的代码(作为回调的参数)。

function double(value, callback) {
  setTimeout(() => callback(value * 2), 1000);
}
double(3, (x) => console.log(`I was given: ${x}`));  
// I was given: 6(大概1000毫秒之后)

这里的setTimeout调用告诉JavaScript运行时,在1000毫秒之后,把一个函数推到消息队列上。
这个函数会由运行时负责异步调度执行。
而位于函数闭包中的回调及其参数,在异步执行时仍然是可用的。
2.失败处理
异步操作的失败处理在回调模型中也要考虑,因此自然就出现了成功回调和失败回调:

function double(value, success, failure) {
  setTimeout(() => {
    try {
      if (typeof value !== 'number') {
        throw '必须提供数字作为第一个参数';
      }
      success(2 * value);
    } catch (e) {
      failure(e);
    }
  }, 1000);
}
const successCallback = (x) => console.log(`Success: ${x}`);
const failureCallback = (e) => console.log(`Failure: ${e}`);
double(3, successCallback, failureCallback);
double('b', successCallback, failureCallback);
// Success: 6
// Failure: 必须提供数字作为第一个参数

这种模式已经不可取了,因为必须在初始化异步操作时定义回调。
异步函数的返回值只在短时间内存在,只有预备好,将这个短时间内存在的值作为参数的回调才能接收到它。
3.嵌套异步回调
如果异步返值又依赖另一个异步返回值,那么回调的情况还会进一步变复杂。
在实际的代码中,这就要求嵌套回调:

function double(value, success, failure) {
  setTimeout(() => {
    try {
      if (typeof value !== 'number') {
        throw '必须提供数字作为第一个参数';
      }
      success(2 * value);
    } catch (e) {
      failure(e);
    }
  }, 1000);
}
const successCallback = (x) => {
  double(x, (y) => console.log(`Success: ${y}`))
};
const failureCallback = (e) => console.log(`Failure: ${e}`);
double(3, successCallback, failureCallback);
// Success: 12(大约1000毫秒之后)

随着代码越来越复杂,回调策略是不具有扩展性的。
“回调地狱”这个称呼可谓名至实归。
嵌套回调的代码维护起来就是噩梦。
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);

async function bar() {
  return ['bar'];
}
bar().then(console.log);

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

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

// foo
// ["bar"]
// baz
// 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);
}
// 将拒绝处理程序附加到返回的期约
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();

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

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

// foo
// bar
// baz(1000毫秒后)

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

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

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

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

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

// foo
// ['bar']
// quz
// baz

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

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关键字必须在异步函数中使用,不能在顶级上下文如