0%

JavaScript 权威指南 13:异步 JavaScript

有些计算机程序(例如科学模拟和机器学习模型)属于计算密集型,这些程序会持续不断地运行,不会暂停,直到计算出结果为止。不过,大多数现实中的计算机程序则明显是异步的,这意味着它们常常必须停止计算,等待数据到达或某个事件发生。

这种异步编程在 JavaScript 中是司空见惯的,这篇文章将介绍三种重要的语言特性,可以让编写异步代码更容易。

使用回调的异步编程

在最基本的层面上,JavaScript 异步编程是使用回调实现的。

  • 回调就是函数,可以传给其他函数
  • 而其他函数会在满足某个条件或发生某个(异步)事件时调用这个函数
  • 回调函数被调用,相当于通知你满足了某个条件或发生了某个事件,有时这个调用还会包含函数参数,能够提供更多细节

定时器

一种最简单的异步操作就是在一定时间过后运行某些代码,例如 setTimeout 函数就可以实现该目的。

1
setTimeout(checkForUpdates, 60 * 1000);

setTimeout() 只会调用一次指定的回调函数,而 setInteval() 会每隔一段时间重复调用注册的 回调函数

事件

客户端 JavaScript 编程几乎全都是事件驱动的。也就是说,不是运行某些预定义的计算,而是等待用户做一些事,然后响应用户的动作。

  • 用户在按下键盘按键、移动鼠标、单击鼠标或轻点触摸屏设备时,浏览器会生成事件
  • 事件驱动的 JavaScript 程序在特定上下文中为特定类型的事件注册回调函数
  • 浏览器在指定的事件发生时调用这些函数

这些回调函数叫作事件处理程序或者事件监听器,是通过 addEventListener() 注册的:

1
2
3
let okay = document.querySelector('#confirmUpdateDialog button.okay');
// 注册回调函数,回调函数为 applyUpdate
okay.addEventListener('click', applyUpdate);
  • addEventListener() 的第一个参数是一个字符串,指定要注册的事件类型
  • applyUpdate 则是所注册的事件回调函数
  • 如果用户单击或轻点了网页中指定的那个元素,浏览器就会调用 applyUpdate()` 回调函数,并给它传入一个对象,其中包含有关事件的详细信息

网络事件

JavaScript编程中另一个常见的异步操作来源是网络请求。浏览器中运行的 JavaScript 可以通过类似下面的代码从 Web 服务器获取数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function getCurrentVersionNumber(versionCheck) {
let request = new XMLHttpRequest();

request.open("GET", "http://www.example.com/api/version");
request.send();

request.onload = function() {
if (request.status == 200) {
let currentVersion = parseFloat(request.responseText);
versionCheck(null, currentVersion);
} else {
versionCheck(response.statusText, null);
}
};

request.onerror = request.ontimeout = function(e) {
versionCheck(e.type, null);
};
}
  • 客户端 JavaScript 代码可以使用 XMLHttpRequest 类及回调函数来发送 HTTP 请求并异步处理服务器返回的响应
  • 这个例子并没有像之前的示例一样调用 addEventListener()。对于大多数 Web API(包括XMLHttpRequest)来说:
    • 可以通过在生成事件的对象上调用 addEventListener() 并提供相关事件的名字、事件的回调函数来注册事件处理程序
    • 也可以通过将回调函数赋值给这个对象的一个属性来注册事件监听器(这个例子所采用的方法),按照惯例,像这样的事件监听器属性的名字总是以 on 开头
  • 因为发送的是异步请求,所以它不能同步返回调用者关心的值(当前版本号)​。为此,调用者给它传了一个回调函数(即参数 versionCheck),在结果就绪或错误发生时会被调用

Node 中的回调与事件

Node.js 服务器端 JavaScript 环境底层就是异步的,定义了很多使用回调和事件的 API。例如读取文件内容的默认 API 就是异步的,会在读取文件内容后调用一个回调函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const fs = require("fs");
let options = {

};

fs.readFile("config.json", "utf-8", (err, text) => {
if (err) {
console.warn("Could not read config file:", err);
} else {
Object.assign(options, JSON.parse(text));
}

startProgram(options);
});

Node 也定义一些基于事件的 API,例如如下的示例,展示了在 Node 中如何通过 HTTP 请求获取 URL 的内容:

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
const { request } = require("http");
const https = require("https");

function getText(url, callback) {
request = https.get(url);

request.on("response", response => {
let httpStatus = response.statusCode;

response.setEncoding("utf-8");
let body = "";

response.on("data", chunk => { body += chunk; });
response.on("end", () => {
if (httpStatus === 200) {
callback(null, body);
} else {
callback(httpStatus, null);
}
});
});

request.on("error", (err) => {
callback(err, null);
});
}

期约

期约(Promise)是一种为简化异步编程而设计的核心语言特性。期约是一个对象,表示异步操作的结果。这个结果可能就绪也可能未就绪。而期约 API 在这方面故意含糊:没有办法同步取得期约的值,只能要求期约在值就绪时调用一个回调函数。

在最简单的情况下,期约就是一种处理回调的不同方式,但是使用期约也有实际的好处。基于回调的异步编程有一个现实问题,就是经常会出现回调多层嵌套的情形,造成代码缩进过多以致难以阅读。期约可以让这种嵌套回调以一种更线性的期约链形式表达出来,因此更容易阅读和推断。

回调的另一个问题是难以处理错误:如果一个异步回调函数抛出异常,该异常没有办法传播到异步操作的发起者。一个补救方式是使用回调参数和返回值来严密跟踪和传播错误,但这种方式复杂且容易出错。期约则标准化了异步错误处理,通过期约链提供了一种让错误正确传播的途径。

期约表示的是一次异步计算的未来结果,不能用它们来表示重复的异步计算。

使用期约

假设有一个 getJSON() 函数,它不接收回调函数作为参数,而是把 HTTP 响应体解析成 JSON 格式并返回一个期约。那么可以这样使用这个函数:

1
2
3
4
getJSON(url).then(json => {
// 这是一个回调函数,在得到 JSON 值之后会被异步调用
// 它以 JSON 值作为参数
})
  • getJSON() 向指定的 URL 发送一个异步 HTTP 请求,然后在请求结果待定期间返回一个期约对象
  • 这个期约对象有一个实例方法叫 then(),回调函数被传给了 then() 方法
  • 当 HTTP 响应到达时,响应体会被解析为 JSON 格式,而解析后的值会被传给作为 then() 的参数的函数

如果多次调用一个期约对象的 then() 方法,则指定的每个函数都会在预期计算完成后被调用。但期约表示的是一次计算,每个通过 then() 方法注册的函数都只会被调用一次。而且,即便调用 then() 时异步计算已经完成,传给 then() 的函数也会被异步调用。

  • 直接在返回期约的函数调用上继续调用 .then() 方法是一种惯用操作,不需要先把期约对象赋值给某个中间变量
  • 以动词开头来命名返回期约的函数以及使用期约结果的函数也是一种惯例

异步操作,尤其是那些涉及网络的操作,通常都会有多种失败原因。健壮的代码必须处理各种无法避免的错误。对期约而言,可以通过给 then() 方法传第二个函数来实现错误处理:

1
getJSON("/api/user/profile").then(displayUserProfile, handleProfileError);

期约表示在期约对象被创建之后发生的异步计算的未来结果,因为计算是在返回期约对象之后执行的,所以没办法让该计算像以往那样返回一个值,或者抛出一个可以捕获的异常。我们传给 then() 的函数可以提供一个替代手段。基于期约的异步计算把异常(通常是某种 Error 对象,尽管不是必需的)传给作为 then() 的第二个参数的函数。

但实际开发中,很少看到给 then() 传两个函数的情况。因为在使用期约时,还有一种更好也更符合传统的错误处理方式。首先来看一下,假设 getJSON() 正常结束但 displayUserProfile() 中发生错误会怎么样,由于回调函数是异步是异步执行的,不能明确抛出一个异常(因为调用栈里没有处理这种异常的代码)。

处理这个代码中错误的更符合传统的方式如下:

1
getJSON("api/user/profile").then(displayUserProfile).catch(handleProfileError);

这意味着 getJSON()displayUserProfile() 在执行时发生的任何错误(包括 displayUserProfile() 抛出的任何异常)都会传递给 handleProfileError() 函数。这个 catch() 方法只是对调用 then() 时以 null 作为第一个参数,以指定的错误处理函数作为第二个参数的一种简写形式。

调用一个期约的then()方法时传入了两个回调函数:

  • 如果第一个回调被调用,我们说期约得到兑现(fulfill)
  • 而如果第二个回调被调用,我们说期约被拒绝(reject)
  • 如果期约既未兑现,也未被拒绝,那它就是待定(pending)
  • 而期约一旦兑现或被拒绝,我们说它已经落定(settle)

期约是一个对象,表示异步操作的结果。”关键是要记住期约不仅是在某些异步代码完成时注册回调的抽象方式,它还表示该异步代码的结果。任何已经落定的期约都有一个与之关联的值,而这个值不会再改变。

  • 如果期约兑现,那这个值会传给作为 then() 的第一个参数注册的回调函数
  • 如果期约被拒绝,那这个值是一个错误,会传给使用 catch() 注册的或作为 then() 的第二个参数注册的回调函数

期约链

期约有一个最重要的优点,就是以 线性 then() 方法调用链 的形式表达一连串异步操作,而无须把每个操作嵌套在前一个操作的回调函数内部。如下是一个示例:

1
2
3
4
5
6
7
8
9
10
11
fetch(documentURL)
.then(response => response.json())
.then(document => {
return render(document);
})
.then(rendered => {
cacheInDatabase(rendered);
})
.catch(error => {
handle(error);
});

在之前使用 XMLHttpRequest 对象发送 HTTP 请求的示例里,它的 API 是比较古老而捡漏的,现在已经基本上被更新的、基于期约的 Fetch API 所取代了。这个新 HTTP API 的最简单形式就是函数 fetch()。传给它一个 URL,它返回一个期约。这个期约会在 HTTP 响应开始到达且 HTTP 状态和头部可用时兑现:

  • 在 fetch() 返回的期约兑现时,传给它的then() 方法的函数会被调用,这个函数会收到一个Response 对象,通过这个响应对象可以访问请求状态和头部(响应体还未就绪)
  • Response 对象也定义了 text() 和 json() 等方法,通过它们分别可以取得文本和 JSON 格式的响应体。取得响应体的 text() 和 json() 方法本身也返回期约

我们可能会写出如下代码:

1
2
3
4
5
fetch("api/user/profile").then(response => {
response.json().then(profile => {
displayUserProfile(profile);
});
});

但这种风格的代码不是推荐做法,是因为我们像嵌套回调一样嵌套了它们,而这违背了期约的初衷。使用期约的首选方式是像以下代码这样写成一串期约链

1
2
3
4
5
6
7
fetch("api/user/profile")
.then(response => {
return response.json();
})
.then(profile => {
displayUserProfile(profile);
});

像这样在一个表达式中调用多个方法,我们称其为方法链。有时候,当 API 被设计为使用这种方法链时只会有一个对象,它的每个方法都返回对象本身,以便后续调用。然而这并不是期约的工作方式

  • 我们在写 .then() 调用链时,并不会在一个期约上注册多个回调。相反,每个 then() 方法调用都返回一个新期约对象。直到传递给其 then 方法的回调函数执行结束前,这个新的期约对象都不会兑现
  • 注意一点,每个 .then() 方法总是会返回一个新的期约对象,这与传递给 .then() 方法的回调函数的返回值无关(这一点在理解下文的 解决期约 中很重要)

解决期约(Resolving Promises)

对于上面的 HTTP 响应示例,我们简化为如下方法链:

1
2
3
fetch(URL)              // 任务 1,返回期约 1
.then(callback1) // 任务 2,返回期约 2
.then(callback2) // 任务 3,返回期约 3

这里看上去只有 3 个期约,其实不是,而是存在 4 个期约,因为 callback1 的实现如下:

1
response => return response.json();

由于读取响应体是以一个异步操作,所以 response.json() 本身的返回值也是一个期约。这就是第 4 个期约,即 callback1() 函数的返回值。我们使用如下方式重写这段代码,可以理解地更清晰:

1
2
3
4
5
6
7
8
9
10
11
12
function c1(response) {
let p4 = response.json();
return p4;
}

function c2(profile) {
displayUserProfile(profile);
}

let p1 = fetch("api/user/profile"); // 任务 1
let p2 = p1.then(c1); // 任务 2
let p3 = p2.then(c2); // 任务 3
  • 回调函数 c1 的返回值并不是一个 JSON 对象,而是该表示该 JSON 对象的期约 p4
  • 当 p1 兑现后,p1 的值会成为回调函数 c1 的输入,c1 被调用
  • 但 c1 调用结束后,并不意味着 p2 就兑现了,因为 c1 本身是一个异步操作,所以 c1 返回的是一个期约对象 p4。期约就是用于管理异步任务的

这里的关键是,当把回调函数 c 传给 then() 方法时,then() 总是返回一个期约 p,而回调函数 c 会在将来某个时刻被异步调用,回调函数 c 执行某些计算并返回值 v。当回调函数返回值 v 时,我们就说 p 得到了解决

  • 当期约 p 以非期约值解决时(即 v 本身不是一个期约),就会立刻以这个值兑现。因此如果 c 返回期约值,那个这个返回值就会变成 p 的值,然后 p 兑现,结束
  • 当期约 p 以期约值解决时(即 v 本身是一个期约),那么 p 就只是得到解决,但是未得到兑现。此时 p 要等到 v 落定后才能落定
    • 如果 v 兑现了,那么 p 也会以相同的值兑现
    • 如果 v 被拒绝了,那么 p 也会以相同的错误被拒绝

这就是期约 解决 状态的含义:

  • 即一个期约与另一个期约发生了关联(或 锁定 了另一个期约)​
  • 此时我们并不知道 p 将会兑现还是被拒绝。但回调 c 已经无法控制这个结果了
  • 说 p 得到了 解决 ​,意思就是现在它的命运完全取决于期约 v 会怎么样

回到具体例子:

  • c1 返回 p4 时,p2 得到解决。但解决并不等同于兑现,因此任务 3 还不会开始
  • 当 HTTP 响应体全部可用,.json 方法得到 JSON 格式的响应后,期约 p4 才得到兑现
  • p4 兑现后,p2 也会自动以该解析后的 JSON 值兑现
  • 此后,p2 所表示的 JSON 对象值才会作为 c2 回调函数的输入,任务 3 才开始执行

再谈期约和错误

上文说过,基于期约的错误一般是通过给期约链添加一个 .catch() 方法调用来处理的,给一个 then() 方法传两个回调函数反而是很少见的(甚至并非惯用方法)​。接下来我们再详细地讨论错误处理。细致的错误处理在异步编程中确实非常重要:

  • 在同步代码中,如果不编写错误处理逻辑,你至少会看到异常和栈追踪信息,从而能够查找出错的原因
  • 而在异步代码中,未处理的异常往往不会得到报告,错误只会静默发生,导致它们更难调试

期约的 .catch() 方法实际上是对以 null 为第一个参数、以错误处理回调为第二个参数的 .then() 调用的简写,以下两行代码是等价的:

1
2
p.then(null, c);
p.catch(c);

传统的异常处理在异步代码中并不适用。当同步代码出错时,我们可以说一个异常会 沿着调用栈向上冒泡​,直到碰上一个 catch 块。而对于异步期约链,类似的比喻可能是一个错误 沿着期约链向下流淌​,直到碰上一个 .catch() 调用

在 ES2018 中,期约对象还定义了一个 .finally() 方法,其用途类似 try/catch/finally 语句的 finally 子句:

  • 如果你在期约链中添加一个 .finally() 调用,那么传给 .finally() 的回调会在期约落定时被调用
  • 无论这个期约是兑现还是被拒绝,你的回调都会被调用,而且调用时不会给它传任何参数,因此你也无法知晓期约是兑现了还是被拒绝了
  • .finally() 的返回值也是一个期约对象
  • .finally() 中注册的回调函数的返回值,通常会被忽略。.finally() 所返回期约的值,通常会以调用 .finally() 的期约的值进行兑现或拒绝
  • 如果 .finally() 中回调函数抛出异常,则 .finally() 所返回的期约会以该值进行拒绝

如下修改了上述获取 HTTP 响应示例,增加了错误处理:

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
// fetch 返回期约 p1
fetch("/api/user/profile")
// then 返回期约 p2,回调函数为 c1
.then(response => {
if (!response.ok) {
return null;
}

let type = response.headers.get("content-type");
if (type != "application/json") {
throw new TypeError(`Expected JSON, got ${type}`);
}

return response.json();
})
// then 返回期约 p3,回调函数为 c2
.then(profile => {
if (profile) {
displayUserProfile(profile);
} else {
displayLoggedOutProfilePage();
}
})
.catch(e => {
if (e instanceof NetworkError) {
displayErrorMessage("check your internet connection");
} else if (e instanceof TypeError) {
displayErrorMessage("Somthing wrong with server");
} else {
console.error(e);
}
});
  • 假设 fetch 请求本身因为网络故障失败,那么期约 p1 会以一个 NetworkError 对象被拒绝
  • 由于我们并没有在 p1 的 then() 方法中传入第二个回调函数,因此 p2 也会以同一个 NetworkError 对象被拒绝(如果我们给第一个 .then() 调用传了错误处理程序,该程序就会被调用。如果它正常返回,p2 会以该处理程序返回的值解决或兑现)
  • 同样,p3 也是以同一个 NetworkError 对象被拒绝
  • catch 注册的错误处理函数会被调用,NetworkError 相关的错误处理代码被调用

.then(或者 .catch())的回调函数中如果抛出一个值,则这个 .then 方法返回的期约会以这个抛出的错误被拒绝:

  • 当 c1 的代码抛出 TypeError 时,其会导致 p2 以该 TypeError 对象值被拒绝
  • 因为我们没有给 p2 指定错误处理程序,所以 p3 也会被拒绝。此时不会调用 c2,TypeError 会直接传给 c3,其中包含显式检查和处理这种错误的代码

从上面的分析可以看出,在期约链的任何地方使用 .catch() 是完全有效的。如果期约链的某一环会因错误而失败,而该错误属于某种可恢复的错误,不应该停止后续环节代码的运行。例如:

1
2
3
4
5
6
startAsyncOperation()
.then(doStageTwo)
.catch(recoverFromStageTwoError)
.then(doStageThree)
.then(doStageFour)
.catch(logStageThreeAndFourErrors);
  • 此时无论 startAsyncOperation() 还是 doStageTwo() 抛出错误,都会调用 recoverFromStageTwoError() 函数
  • 如果 recoverFromStageTwoError() 正常返回,那么它的返回值会传给 doStageThree(),异步操作将正常继续
  • 如果 recoverFromStageTwoError() 不能恢复,它自己应该抛出一个错误(或者把传给它的错误再抛出来)。​此时,doStageThree()doStageFour() 都不会被调用, recoverFromStageTwoError() 抛出的错误会直接传给 logStageThreeAndFourErrors()

在期约链中,一个环节返回(或抛出)的值会成为下一个环节的输入。因此每个环节返回什么至关重要。实际开发中,忘记从回调函数中返回值是导致期约相关问题的常见原因。而箭头函数快捷语法会让这个问题更加隐蔽,对比如下两行代码:

1
2
3
.catch(e => wait(500).then(queryDatabase))

.catch(e => { wait(500).then(queryDatabase) })
  • 第一行代码中:函数体就是一个表达式,因此省略了包括函数体的大括号,此时表达式的值就是函数的返回值,即返回的是一个期约
  • 第二行代码中,由于加上了大括号,此时就无法利用自动返回了,这个函数的返回值就是 undefined,这可能不是所预期的

并行期约

上述讨论的期约链,主要针对的是一个较大异步操作里的多个顺序运行的异步环节。有时候,我们希望并行执行多个异步操作。函数 Promise.all() 可以做到这一点。

  • Promise.all() 接收一个期约对象的数组作为输入,返回一个期约
  • 如果输入期约中的任意一个拒绝,返回的期约也将拒绝
  • 否则,返回的期约会以每个输入期约兑现值的数组兑现

例如如下代码从多个 URL 获取内容:

1
2
3
4
5
const urls = [ /* 多个 URL */ ];
promises = urls.map(url => fetch(url).then(r => r.text()));
promises.all(promises)
.then(results => { /* 处理得到的字符串数组 */ })
.catch(e => console.error(e));

其实 Promise.all() 还要灵活:其输入数组可以包含期约对象和非期约值。如果这个数组的某个元素不是期约,那么它就会被当成一个已兑现期约的值,被原封不动地复制到输出数组中。

Promise.all() 返回的期约会在任何一个输入期约被拒绝时拒绝。这会在第一个拒绝发生时立即发生,此时其他期约的状态可能还是待定。在 ES2020 中,Promise.allSettled() 也接收一个输入期约的数组,与 但是,Promise.allSettled()永远不拒绝返回的期约,而是会等所有输入期约全部落定后兑现

  • 这个返回的期约解决为一个对象数组,其中每个对象都对应一个输入期约
  • 对象有一个status属性,值为 fulfilled 或 rejected。
    • 如果 status 属性值为 fulfilled,那么该对象还会有一个 value 属性,包含兑现的值
    • 如果 status 属性值为 rejected,那么该对象还会有一个 reason 属性,包含对应期约的错误或拒绝理由

你可能偶尔想同时运行多个期约,但只关心第一个兑现的值。此时,可以使用 Promise.race() 而不是 Promise.all()Promise.race() 返回一个期约,这个期约会在输入数组中的期约有一个兑现或拒绝时马上兑现或拒绝(如果输入数组中有非期约值,则直接返回其中第一个非期约值)。

创建期约

让函数返回期约是很有用的,接下来将展示如何创建你自己基于期约的 API。

基于其他期约的期约:

给定一个期约,调用 then 就可以创建并返回一个新的期约。

基于同步值的期约:

有时候我们的函数想返回一个期约,但函数执行的计算实际上并不涉及异步操作。在这种情况下,静态方法 Promise.resolve()Promise.reject() 可以帮你达成目的:

  • Promise.resolve() 接收一个值作为参数,并返回一个会立即(但异步)以该值解决的期约。
  • Promise.reject() 也接收一个参数,并返回一个以该参数作为理由拒绝的期约

需要明确,这两个静态方法返回的期约在被返回时并未兑现或拒绝,但它们会在当前同步代码块运行结束后立即兑现或拒绝(通常,这在很短的时间内就会发生,除非有很多待定的异步任务等待运行)。

1
2
3
4
Promise.resolve("10").then(x => console.log("promise resolved value", x));
console.log("sync code run 1")
console.log("sync code run 2")
console.log("sync code run done")
1
2
3
4
5
# node resolve.js
sync code run 1
sync code run 2
sync code run done
promise resolved value 10

解决期约并不等同于兑现期约。调用 Promise.resolve() 时,我们通常会传入兑现值,创建一个很快就兑现为该值的期约对象。但是这个方法的名字并不叫 Promise.fulfill()。如果把期约 p1 传给 Promise.resolve(),它会返回一个新期约 p2,p2 会立即解决,但要等到 p1 兑现或被拒绝时才会兑现或被拒绝。

有时候异步函数里会存在同步处理的特殊情况,此时就可以通过 Promise.resolve() 或者 Promise.reject() 来处理这种同步情况的值。例如在异步操作开始前,检查参数条件,如果失败,就可以直接通过 Promise.reject() 返回一个被拒绝的期约。

最后,Promise.resolve() 有时候也可以用来创建一个期约链的第一个期约。

从头开始创建期约:

如果想从头创建一个返回期约的函数,就可以使用 Promise 构造函数。使用 Promise() 构造函数来创建一个新期约对象,而且可以完全控制这个新期约。过程如下:

  • 调用 Promise() 构造函数,给它传一个函数作为唯一参数
  • 传的这个函数需要写成接收两个参数,按惯例要将它们命名为 resolve 和 reject
  • 构造函数同步调用你的函数并为 resolve 和 reject 参数传入对应的函数值。
  • 调用你的函数后,Promise() 构造函数返回新创建的期约。这个返回的期约由你传给 Promise()构造函数的函数控制:
    • 你传入的函数应该执行某些异步操作
    • 然后调用 resolve 函数解决或兑现返回的期约
    • 或者调用 reject 函数拒绝返回的期约

虽然你的函数可以不执行异步操作,而是同步地调用 resolve 或 reject。但是创建的期约仍然会异步地解决、兑现或拒绝。

如下是一个示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
function wait(duration) {
console.log("wait call start");

let p = new Promise((resolve, reject) => {
console.log("promise constructor call start");

if (duration < 0) {
reject(new Error("Time travel not yet implemented"))
}

console.log("promise constructor argument ok");

// setTimeout 在调用 resolve 时是不会传参的
// 这也意味着所返回的期约在兑现时,是以 undefined 兑现的
setTimeout(resolve, duration);

console.log("promise constructor call end");
});

console.log("wait call end");

return p;
}
1
2
3
4
5
6
7
8
9
// wait call start
// promise constructor call start
// promise constructor argument ok
// promise constructor call end
// wait call end
// wait promise resolve to undefined
wait(5 * 1000)
.then(x => { console.log(`wait promise resolve to ${x}`)})
.catch(x => { console.log(`wait promise reject to ${x}`)})
1
2
3
4
5
6
7
8
9
// wait call start
// promise constructor call start
// promise constructor argument ok
// promise constructor call end
// wait call end
// wait promise reject to Error: Time travel not yet implemented
wait(-1000)
.then(x => { console.log(`wait promise resolve to ${x}`)})
.catch(x => { console.log(`wait promise reject to ${x}`)})

用来控制 Promise() 构造函数创建的期约命运的那对函数叫 resolve()reject(),不是 fulfill()reject()。如果把一个期约传给 resolve(),返回的期约将会解决为传入的期约值。不过,通常在这里都会传一个非期约值,所返回的期约会兑现为该值。

如下是一个更复杂的示例,实现了在 Node 中所使用的 getJSON() 函数。它很好地示范了如何在其他异步编程风格基础上实现基于期约的 API:

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
const http = require("http");

function getJSON(url) {
return new Promise((resolve, reject) => {
request = http.get(url, response => {
if (response.statusCode !== 200) {
reject(new Error(`HTTP status ${response.statusCode}`));
response.resume(); // don't leak memory
} else if (response.headers["content-type"] !== "application/json") {
reject(new Error(`Invalid content-type`));
response.resume(); // don't leak memory
} else {
let body = "";
response.setEncoding("utf-8");
response.on("data", chunk => body += chunk);
response.on("end", () => {
try {
let parsed = JSON.parse(body);
resolve(parsed);
} catch(e) {
reject(e);
}
});
}
});

// 收到响应之前就请求失败,也会正确拒绝期约
request.on("error", error => {
reject(error);
});
});
}

串行期约

有时候我们需要按顺序运行任意数量的期约,由于数量未知,我们无法在期约链中硬编码这些期约,而是要像以下代码这样动态构建:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function fetchSequentially(urls) {
const bodies = [];

function fetchOne(url) {
return fetch(url)
.then(response => response.text)
.then(body => {
bodies.push(body);
});
}

let p = Promise.resolve(undefined);
for (let url of urls) {
p = p.then(() => fetchOne(url));
}

return p.then(() => bodies);
}
1
2
3
fetchSequentially(urls)
.then(bodies => { /* 处理抓到的字符串数组 */})
.catch(e => console.error(e));

这种实现方式创建了一串期约,第一个期约是一个能够能够立即兑现的特殊期约,之后每个期约按照创建顺序逐个落定,最后一个期约兑现时,会返回 bodies 数组,这种方式创建的是类似于多米诺骨牌形式的期约链。

还有一种实现思路,它并不事先创建期约链,不是事先创建期约,而是让每个期约的回调创建并返回下一个期约。我们的代码可以返回第一个(最外层的)期约,知道它最终会兑现(或拒绝)为序列中最后一个(最内层的)期约兑现(或拒绝)的值。

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
function promiseSequence(inputs, promiseMaker) {
inputs = [...inputs];

function handleNextInput(outputs) {
if (inputs.length === 0) {
return outputs;
} else {
let nextInput = inputs.shift();
return promiseMaker(nextInput)
.then(output => outputs.concat(output))
.then(handleNextInput);
}
}

// 从一个以空数组兑现的期约开始
// 使用上面的函数作为它的回调
return Promise.resolve([]).then(handleNextInput);
}

function fetchBody(url) {
return fetch(url).then(r => r.text());
}

promiseSequence(urls, fetchBody)
.then(bodies => { /* 处理字符串数组 */ })
.catch(console.error);

async 和 await

ES2017 新增了两个关键字:asyncawait,代表异步 JavaScript 编程范式的迁移。这两个新关键字极大简化了期约的使用。当等待网络响应或者其他异步事件时,它允许我们编写看上去像同步阻塞式代码一样的基于期约的异步代码。

异步代码不能像常规同步代码那样返回一个值或抛出一个异常,这也是期约会这么设计的原因所在。兑现期约的值就像一个同步函数返回的值,而拒绝期约的值就像一个同步函数抛出的值。async/await 使用基于期约的高效代码,同时又隐藏了期约的复杂性,让你的异步代码像同步代码(通常是阻塞式的、低效的)一样容易理解。

await 表达式

await 关键字接收一个期约并将其转换为一个返回值或一个抛出的异常,即给定一个期约 p,表达式await p 会一直等到 p 落定:

  • 如果 p 兑现,那么 await p 的值就是兑现 p 的值
  • 如果 p 被拒绝,那么 await p 表达式就会抛出拒绝 p 的值

我们通常并不会使用 await 来接收一个保存期约的变量,更多的是把它放在一个会返回期约的函数调用前面:

1
2
let response = await fetch("/api/user/profile");
let profile = await response.json();

这里的关键是,await 关键字并不会导致你的程序阻塞或者在指定的期约落定前什么都不做。你的代码仍然是异步的,而 await 只是掩盖了这个事实。这意味着任何使用 await 的代码本身都是异步的

async 函数

因为任何使用 await 的代码都是异步的,所以有一条重要的规则:只能在以 async 关键字声明的函数内部使用 await 关键字。使用 async/await 改写前面的 getHighScore() 函数:

1
2
3
4
5
async function getHighScore() {
let response = await fetch("/api/user/profile");
let profile = await response.json();
return profile.highScore;
}

把函数声明为 async 意味着该函数的返回值将是一个期约,即便函数体中不出现期约相关的代码

  • 如果 async 函数会正常返回,那么作为该函数真正返回值的期约对象将解决为这个显式的返回值
  • 如果 async 函数抛出异常,那么它返回的期约对象将以该异常值被拒绝

由于 getHightScore() 函数前面加上了 async 关键字,因此它返回一个期约。由于它返回期约,所以可以对它使用 await 关键字:

1
displayHighScore(await getHighScore());

由于这行代码使用了 await 关键字,因此只有在位于另一个 async 函数内部时才能运行。await 表达式可以嵌套出现在 async 函数中,多深都没有问题。但如果是在顶级或因为某种原因在一个非 async 函数内部,那么就不能使用 await 关键字,而是必须以常规方式来处理返回的期约:

1
getHighScore().then(displayHighScore).catch(console.error);

可以对任何函数使用 async 关键字。例如,可以在 function 关键字作为语句和作为表达式时使用,也可以对箭头函数和类及对象字面量中的简写方法使用。

等候多个期约

假设我们编写了如下代码:

1
2
let value1 = await getJSON(url1);
let value2 = await getJSON(url2);

这段代码的问题在于:必须等到抓取第一个 URL 的结果之后才会开始抓取第二个 URL。如果想同时抓取两个 URL,可以通过如下代码实现:

1
let [value1, value2] = await Promise.all([getJSON(url1), getJSON(url2)]);

因为 async 函数本质上是基于期约的,它也返回一个期约对象,因此要等候一组并发执行的 async 函数,可以像使用期约一样直接使用 Promise.all()。

实现细节

最后,为了理解 async 函数的工作原理,可以了解一下后台发生了什么。当编写了一个这样的 async 函数:

1
async function f(x) { /* 函数体 */ }

可以把这个函数想象成一个返回期约的包装函数,它包装了你原始函数的函数体:

1
2
3
4
5
6
7
8
9
10
function f(x) {
return new Promise((resolve, reject) => {
try {
resolve((function(x) { /* 函数体 */ })(x));
}
catch(e) {
reject(e);
}
});
}

像这样以语法转换的形式来解释 await 关键字比较困难。但可以把 await 关键字想象成分隔代码体的记号,它们把函数体分隔成相对独立的同步代码块。ES2017 解释器可以把函数体分割成一系列独立的子函数,每个子函数都将传递给该子函数之前的、以 await 所标记的那个期约的 then 方法。

异步迭代

在介绍期约时,强调过它只适合单次运行的异步计算,不适合与重复性异步事件来源一起使用,由于一个期约无法用于连续的异步事件,我们也不能使用常规的 async 函数和 await 语句来处理这些事件。

ES2018 提供了异步迭代器,它们是基于期约的,使用时需要配合 for/await 循环。

for/await 循环

Node 12 的可读流实现了异步可迭代。这意味着可以像下面这样使用 for/await 循环从一个流中读取连续的数据块:

1
2
3
4
5
6
7
8
const fs = require("fs");

async function parseFile(filename) {
let stream = fs.createReadStream(filename, { encoding: "utf-8"});
for await (let chunk of stream) {
parseChunk(chunk);
}
}

与常规的 await 表达式类似,for/await 循环也是基于期约的。大体上说:

  • 这里的异步迭代器会产生一个期约
  • for/await 循环等待该期约兑现,将兑现值赋给循环变量,然后再运行循环体
  • 之后再从头开始,从迭代器取得另一个期约并等待这个新期约兑现

例如,假设有如下 URL fetch 对应的期约:

1
2
3
4
5
6
7
const urls = [url1, url2, url3];
const promises = urls.map(url => fetch(url));

for (const promise of promises) {
response = await promise;
handle(response)
}

这个示例代码使用了常规的 for/of 循环和一个常规迭代器。但由于这个迭代器返回期约,所以我们也可以使用新的 for/await 循环让代码更简单:

1
2
3
for await (const response of promises) {
handle(response);
}

这两个示例都只能在以 async 声明的函数内部才能使用。从这方面来说,for/await 循环与常规的 await 表达式没什么不同。

我们对一个常规的迭代器使用了 for/await。如果是完全异步的迭代器,那么还会更有意思。

异步迭代器

可迭代对象是可以在 for/of 循环中使用的对象。它以一个符号名字 Symbol.iterator 定义了一个方法,该方法返回一个迭代器对象。这个迭代器对象有一个 next() 方法,可以反复调用它获取可迭代对象的值。迭代器对象的这个 next() 方法返回迭代结果对象。迭代结果对象有一个 value 属性或一个 done 属性。

异步迭代器与常规迭代器非常相似,但有两个重要区别:

  • 异步可迭代对象以符号名字 Symbol.asyncIterator 而非 Symbol.iterator 实现了一个方法。上面的例子我们可以看到,for/await 与常规迭代器兼容,但它更适合异步可迭代对象,因此会在尝试 Symbol.iterator 法前先尝试 Symbol.asyncIterator 方法
  • 异步迭代器的 next() 方法返回一个期约,解决为一个迭代器结果对象,而不是直接返回一个迭代器结果对象。

当我们对一个常规同步可迭代的期约数组使用 for/await 时,操作的是同步迭代器结果对象。其中,value 属性是一个期约对象,但 done 属性是一个同步值。真正的异步迭代器返回的是迭代结果对象的期约,因此 value 和 done 属性都是异步的。两者的区别很微妙:对于异步迭代器,关于迭代何时结束的选择可以异步实现

异步生成器

实现迭代器的最简单方式通常是使用生成器。同理,对于异步迭代器也是如此,我们可以使用声明为 async 的生成器函数来实现它。声明为 async 的异步生成器同时具有异步函数和生成器的特性

  • 可以像在常规异步函数中一样使用 await,也可以像在常规生成器中一样使用 yield
  • 但通过 yield 生成的值会自动包装到期约中
  • 异步生成器函数通过 async function* 来声明

如下是一个示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function elapsedTime(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}


async function* clock(interval, max=Infinity) {
for (let count = 1; count < max; count++) {
await elapsedTime(interval);
yield count;
}
}


async function test() {
for await (let tick of clock(300, 100)) {
console.log(tick);
}
}

test().then(() => {});

实现异步迭代器

除了使用异步生成器实现异步迭代器,还可以直接实现异步迭代器:

  • 需要定义一个包含 Symbol.asyncIterator() 方法的对象,该方法要返回一个包含 next() 方法的对象
  • 而这个 next() 方法要返回解决为一个 迭代器结果对象的期约

如下是一个示例,这里的 next() 方法并没有显式返回一个期约,而是通过将它声明为 async next 来返回一个期约:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function clock(interval, max=Infinity) {
function until(time) {
return new Promise(
resolve => setTimeout(resolve, time - Date.now())
);
}

return {
startTime: Date.now(),
count: 1,
async next() {
if (this.count > max) {
return {done: true};
}

let targetTime = this.startTime + this.count * interval;
await until(targetTime);
return {value: this.count++};
},
[Symbol.asyncIterator]() { return this; }
}
}

异步迭代器的优点的是它允许我们表示异步事件流或数据流。下面看一个更复杂的示例:

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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
class AsyncQueue {
constructor() {
this.values = [];
this.resolvers = [];
this.closed = false;
}

enqueue(value) {
if (this.closed) {
throw new Error("AsyncQueue closed");
}

if (this.resolvers.length > 0) {
const resolve = this.resolvers.shift();
// 期约解决为 value
resolve(value);
} else {
this.values.push(value);
}
}

dequeue() {
if (this.values.length > 0) {
// 队列中已经有值,
// 基于同步值产生期约
// 期约解决为队列中的第一个值
return Promise.resolve(this.values.shift());
} else if (this.closed) {
return Promise.resolve(AsyncQueue.EOS);
} else {
return new Promise(
(resolve) => { this.resolvers.push(resolve) }
);
}
}

close() {
while(this.resolvers.length > 0) {
this.resolvers.shift()(AsyncQueue.EOS);
}

this.closed = true;
}

[Symbol.asyncIterator]() { return this; }

next() {
return this.dequeue().then(
value => (value === AsyncQueue.EOS)
? { value: undefined, done: true }
: { value: value, done: false}
);
}
}

AsyncQueue.EOS = Symbol("end-of-stream");

function eventStream(elt, type) {
const q = new AsyncQueue();
elt.addEventListener(type, e => q.enqueue(e));
return q;
}

async function handleKeys() {
for await (const event of eventStream(document, "keypress")) {
console.log(event.key);
}
}
  • AsyncQueue类封装了期约队列的逻辑,基于这个类来编写异步迭代器会简单地多
  • AsyncQueue 类的 dequeue() 方法返回一个期约而不是一个实际值,这意味着在尚未调用 enqueue() 之前调用 dequeue() 是没有问题的
  • AsyncQueue 类也是一个异步迭代器,这样就能配合 for/await 循环使用了。其 next() 方法借助了 dequeue() 方法来返回 迭代器结果对象的期约
  • 基于 AsyncQueue 类,产生了一个浏览器事件流,可以通过 for/await 循环来处理