第13章 异步JavaScript
一些计算机程序,如科学模拟和机器学习模型,是受计算限制的:它们不停地连续运行,直到计算出结果为止。然而,大多数现实世界的计算机程序都是异步的。这意味着,在等待数据到达或某些事件发生时,它们常常不得不停止计算。web浏览器中的JavaScript程序通常是事件驱动的,这意味着它们等待用户单击或点击,然后才真正执行任何操作。基于JavaScript的服务器通常会等待客户端请求通过网络到达,然后再做任何事情。
这种异步编程在JavaScript中很常见,本章介绍了三个重要的语言特性,这些特性有助于简化异步代码的使用。在ES6中,Promise是表示异步操作尚未获得结果的对象。关键字async和await是在ES2017中引入的,它提供了简化异步编程的新语法,它允许您将基于Promise的代码构造为与同步的代码一样。最后,在ES2018中引入了异步迭代器和for/await循环,允许您使用看似同步的简单循环处理异步事件流。
具有讽刺意味的是,尽管JavaScript为处理异步代码提供了这些强大的功能,但核心语言中本身不包含异步特性。因此,为了描述Promise、async、await和for/await,我们将首先浏览一下客户端和服务器端JavaScript,解释web浏览器和Node的一些异步特性。(您可以在第15章和第16章中了解有关客户端和服务器端JavaScript的更多信息。)
13.1 使用回调进行异步编程
在最底层,JavaScript中的异步编程是通过回调完成的。回调是一个函数,你编写它,然后传递给其他函数。当某个条件满足或某个(异步)事件发生时,另一个函数就会调用(“回调”)您的函数。对您提供的回调函数的调用会通知您某个条件满足或某个事件发生,有时调用将包含提供附加详细信息的函数参数。通过一些具体的例子,这一点更容易理解,下面的小节将演示使用客户端JavaScript和Node的各种形式的基于回调的异步编程。
13.1.1 定时器
最简单的异步方式之一是,当您希望在经过一定时间后运行某些代码。正如我们在§11.10中看到的,您可以使用setTimeout()函数来完成此操作:
setTimeout(checkForUpdates, 60000);
setTimeout()的第一个参数是一个函数,第二个参数是以毫秒为单位的时间间隔。在前面的代码中,演示用的checkForUpdates()函数将在setTimeout()调用后的60000毫秒(1分钟)之后被调用。checkForUpdates()是程序可能定义的回调函数,而setTimeout()是您调用的函数,用于注册回调函数并指定在哪些异步条件下应该调用它。
setTimeout()调用指定的回调函数一次,不传递任何参数,然后忽略它。如果您正在编写一个真正检查更新的函数,您可能希望它重复运行。可以通过使用setInterval()而不是setTimeout()来完成此操作:
// 一分钟后调用checkForUpdates,之后每分钟再调用一次
let updateIntervalId = setInterval(checkForUpdates, 60000);
// setInterval()返回一个值,我们可以通过调用clearInterval()来停止重复调用。
//(类似地,setTimeout()返回一个可以传递给clearTimeout()的值)
function stopCheckingForUpdates() {
clearInterval(updateIntervalId);
}
13.1.2 事件
客户端JavaScript程序几乎是统一的事件驱动程序:它们通常等待用户做某件事,然后对用户的操作做出响应,而不是运行某种预定的计算。当用户按下键盘上的键、移动鼠标、单击鼠标按钮或触摸触摸屏设备时,web浏览器将生成一个事件。事件驱动的JavaScript程序在指定的上下文中为指定类型的事件注册回调函数,当指定的事件发生时,web浏览器就会调用这些函数。这些回调函数称为事件处理程序或事件侦听器,并使用addEventListener()注册:
// 要求web浏览器返回一个对象,该对象表示与此CSS选择器匹配的HTML<button>元素
let okay = document.querySelector('#confirmUpdateDialog button.okay');
// 现在注册一个回调函数,当用户单击该按钮时将被调用。
okay.addEventListener('click', applyUpdate);
在本例中,applyUpdate()是一个假设的回调函数,我们假设它是在其他地方实现的。号召document.querySelector()返回表示网页中单个指定元素的对象。我们对该元素调用addEventListener()来注册回调。然后addEventListener()的第一个参数是一个字符串,它指定我们感兴趣的事件类型—在本例中是鼠标单击或触摸屏点击。如果用户单击或点击网页的特定元素,浏览器将调用applyUpdate()回调函数,传递一个包含事件详细信息(如时间和鼠标指针坐标)的对象。
13.1.3 网络事件
JavaScript编程中异步的另一个常见来源是网络请求。在浏览器中运行的JavaScript可以使用如下代码从web服务器获取数据:
function getCurrentVersionNumber(versionCallback) {
// 注释回调参数
// 向后端版本API发出脚本化的HTTP请求
let request = new XMLHttpRequest();
request.open("GET", "http://www.example.com/api/version");
request.send();
// 注册将在响应到达时调用的回调
request.onload = function () {
if (request.status === 200) {
// 如果HTTP状态良好,则获取版本号并调用回调。
let currentVersion = parseFloat(request.responseText);
versionCallback(null, currentVersion);
} else {
// 否则向回调报告错误
versionCallback(response.statusText, null);
}
};
// 注册另一个将为网络错误调用的回调
request.onerror = request.ontimeout = function (e) {
versionCallback(e.type, null);
};
}
客户端JavaScript代码可以使用XMLHttpRequest类和回调函数发出HTTP请求,并在服务器到达时异步处理服务器的响应。1这里定义的getCurrentVersionNumber()函数(我们可以想象它是由我们在§13.1.1中讨论的假设的checkForUpdates()函数使用的)发出一个HTTP请求并定义事件处理程序,当接收到服务器的响应或超时或其他错误导致请求失败时,将调用这些处理程序。
请注意,上面的代码示例没有像前面的示例那样调用addEventListener()。对于大多数web API(包括本API),可以通过对生成事件的对象调用addEventListener()来定义事件处理程序,并将感兴趣事件的名称与回调函数一起传递。但是,通常,也可以通过将单个事件侦听器直接分配给对象的属性来注册它。这就是我们在这个示例代码中所做的,将函数分配给onload、onerror和ontimeout属性。按照惯例,像这样的事件侦听器属性的名称总是以on开头。addEventListener()是更灵活的技术,因为它允许多个事件处理程序。但是,如果您确定没有其他代码需要为同一对象和事件类型注册侦听器,那么只需为回调设置适当的属性会更简单。
关于这个示例代码中的getCurrentVersionNumber()函数,另一件需要注意的事情是,由于它发出异步请求,它不能同步返回调用者感兴趣的值(当前版本号)。相反,调用者传递一个回调函数,当结果准备就绪或发生错误时调用该函数。在本例中,调用者提供一个回调函数,该函数需要两个参数。如果XMLHttpRequest工作正常,那么getCurrentVersionNumber()将调用第一个参数为空、版本号作为第二个参数的回调。或者,如果发生错误,则getCurrentVersionNumber()调用回调,第一个参数中包含错误详细信息,第二个参数中包含null。
13.1.4 Node中的回调和事件
Node.js的服务器端JavaScript环境是高度异步的,它定义了许多使用回调和事件的API。例如,读取文件内容的默认API是异步的,并在读取文件内容后调用回调函数:
const fs = require("fs"); // “fs”模块具有与文件系统相关的API
let options = {
// 保存程序选项的对象
// 默认选项在这里
};
// 读取配置文件,然后调用回调函数
fs.readFile("config.json", "utf-8", (err, text) => {
if (err) {
// 如果出现错误,则显示警告,但继续进行下一步
console.warn("Could not read config file:", err);
} else {
// 否则,解析文件内容并分配给options对象
Object.assign(options, JSON.parse(text));
}
// 不管是哪种情况,我们现在都可以开始运行程序了
startProgram(options);
});
Node的fs.readFile()函数的最后一个参数是双参数回调。它异步读取指定的文件,然后调用回调。如果文件读取成功,它将文件内容作为第二个回调参数传递。如果有错误,它会将错误作为第一个回调参数传递。在本例中,我们将回调表示为箭头函数,这是这种简单操作的简洁自然的语法。
Node还定义了许多基于事件的API。下面的函数演示如何对Node中的URL的内容发出HTTP请求。它有两层用事件侦听器处理的异步代码。请注意,Node使用on()方法而不是addEventListener()注册事件侦听器:
const https = require("https");
// 读取URL的文本内容,并异步地将其传递给回调。
function getText(url, callback) {
// 启动URL的http get请求
const request = https.get(url);
// 注册一个函数来处理“response”事件。
request.on("response", response => {
// response事件表示已收到响应头
let httpStatus = response.statusCode;
// 尚未收到HTTP响应的正文。
// 因此,我们注册了更多的事件处理程序,以便在事件到达时调用。
response.setEncoding("utf-8"); // 我们需要Unicode文本
let body = ""; // 我们将在这里累加。
// 此事件处理程序在正文块准备就绪时调用
response.on("data", chunk => {
body += chunk; });
// 此事件处理程序在响应完成时调用
response.on("end", () => {
if (httpStatus === 200) {
// 如果HTTP响应良好
callback(null, body); // 将响应正文传递给回调
} else {
// 否则传递错误
callback(httpStatus, null);
}
});
});
// 我们还为低级网络错误注册一个事件处理程序
request.on("error", (err) => {
callback(err, null);
});
}
13.2 Promise
现在我们已经看到了客户端和服务器端JavaScript环境中回调和基于事件的异步编程的示例,我们可以介绍Promise了,一个旨在简化异步编程的核心语言特性。
Promise是表示异步计算结果的对象。这个结果可能已经准备好了,也可能还没有准备好,Promise API故意对此含糊其辞:无法同步获取Promise的值;只有在值准备好时,才能要求Promise调用回调函数。如果您正在定义一个异步API(如上一节中的getText()函数),但希望使其基于Promise,请忽略回调参数,而是返回Promise对象。然后调用者可以在这个Promise对象上注册一个或多个回调,当异步计算完成时将调用它们。
所以,在最简单的层面上,Promise只是处理回调的另一种方式。然而,使用它们也有实际的好处。基于回调的异步编程的一个真正的问题是,通常在回调内部的回调中结束回调,代码行高度缩进,难以阅读。Promise允许这种嵌套回调被重新表示为一个更线性的Promise链,它更易于阅读和推理。
回调的另一个问题是它们会使处理错误变得困难。如果异步函数(或异步调用的回调)抛出异常,则该异常无法传播回异步操作的发起方。这是关于异步编程的一个基本事实:它打破了异常处理。另一种方法是使用回调参数和返回值仔细地跟踪和传播错误,但这很乏味,而且很难纠正。这里的Promise通过标准化处理错误的方法和提供一种通过Promise链正确传播错误的方法来帮助您。
注意,Promise表示单个异步计算的未来结果。但是,它们不能用来表示重复的异步计算。例如,在本章后面,我们将编写一个基于Promise的setTimeout()函数的替代方法。但是我们不能用Promise来代替setInterval(),因为这个函数会反复调用回调函数,而这正是Promise不打算做的事情。类似地,我们可以使用Promise而不是XMLHttpRequest对象的“load”事件处理程序,因为该回调只被调用一次。但是我们通常不会使用Promise来代替html button对象的“click”事件处理程序,因为我们通常希望允许用户多次单击一个按钮。
以下各小节将:
- 解释Promise术语并展示基本的Promise用法
- 展示Promise是如何链接起来的
- 演示如何创建自己的基于Promise的API
Promise一开始看起来很简单,事实上,Promise的基本用例是简单明了的。但是除了最简单的用例之外,它们可能会变得令人惊讶地混乱。Promise是异步编程的一个强大的习惯用法,但是您需要深入理解它们才能正确和自信地使用它们。不过,花点时间深入了解这一点是值得的,我敦促你们仔细研究这一章。
13.2.1 使用Promise
随着JavaScript核心语言Promises的出现,web浏览器已经开始实现基于Promise的API。在上一节中,我们实现了一个getText()函数,该函数发出一个异步HTTP请求,并将HTTP响应的主体作为字符串传递给指定的回调函数。想象一下这个函数的一个变体getJSON(),它将HTTP响应的主体解析为JSON,并返回一个Promise而不是接受回调参数。我们将在本章后面实现一个getJSON()函数,但现在,让我们看看如何使用这个返回Promise的实用函数:
getJSON(url).then(jsonData => {
// 这是一个回调函数,当解析后的JSON值可用时,它将被异步调用。
});
getJSON()为您指定的URL启动一个异步HTTP请求,然后,当该请求处于挂起状态时,它返回一个Promise对象。Promise对象定义了一个then()实例方法。我们没有将回调函数直接传递给getJSON(),而是将其传递给then()方法。当HTTP响应到达时,该响应的主体被解析为JSON,解析后的结果值被传递给我们传递给then()的函数。
您可以将then()方法看作是回调注册方法,如addEventListener()方法,用于在客户端Java脚本中注册事件处理程序。如果多次调用Promise对象的then()方法,则在Promise的计算完成时将调用您指定的每个函数。
但是,与许多事件侦听器不同,Promise表示一个单独的计算,每个用then()注册的函数将只被调用一次。值得注意的是,传递给then()的函数是异步调用的,即使在调用then()时异步计算已经完成。
在简单的语法层次上,then()方法是Promise的独特特性,它的惯用用法是将.then()直接附加到返回Promise的函数调用中,而不需要将Promise对象分配给变量。
将返回Promise的函数和使用Promise结果的函数命名时使用动词也是惯用做法,这些习惯用法会使代码特别容易阅读:
// 假设您有这样一个函数来显示用户配置文件
function displayUserProfile(profile) {
/* 省略实现 */ }
// 下面是如何使用带有Promise的函数。
// 注意这一行代码读起来就像一个英语句子:
getJSON("/api/user/profile").then(displayUserProfile);
Promise处理错误
异步操作,特别是那些涉及网络的操作,通常会以多种方式失败,必须编写健壮的代码来处理不可避免地发生的错误。
对于Promise,我们可以通过向then()方法传递第二个函数来实现:
getJSON("/api/user/profile").then(displayUserProfile, handleProfileError);
Promise表示在创建Promise对象之后发生的异步计算的未来结果。因为计算是在Promise对象返回给我们之后执行的,所以传统的计算无法返回值或抛出我们可以捕获的异常。传递给then()的函数提供了替代方法。当同步计算正常完成时,它只将结果返回给调用者。当基于Promise的异步计算正常完成时,它将其结果传递给作为then()的第一个参数的函数。
当同步计算中出现错误时,它会抛出一个异常,该异常会向上传播给调用堆栈,直到有catch子句来处理它为止。当一个异步计算运行时,它的调用者不再在堆栈上,所以如果出现问题,就不可能将异常返回给调用方。
相反,基于Promise的异步计算将异常(通常作为某种错误对象,尽管这不是必需的)传递给我们之前传递给then()的第二个函数。因此,在上面的代码中,如果getJSON()正常运行,它会将结果传递给displayUserProfile()。如果出现错误(用户未登录、服务器关闭、用户的internet连接断开、请求超时等),则getJSON()会将错误对象传递给handleProfileError()。
实际上,很少有两个函数传递给then()。在处理承诺时,有一种更好、更惯用的处理错误的方法。要理解它,首先考虑如果getJSON()正常完成,但displayUserProfile()中出现错误,会发生什么情况。当getJSON()返回时,该回调函数是异步调用的,因此它也是异步的,不能有意义地抛出异常(因为调用堆栈上没有代码来处理它)。
处理此代码中错误的惯用方法如下所示:
getJSON("/api/user/profile").then(displayUserProfile).catch(handleProfileError);
使用此代码,getJSON()的正常结果仍将传递给displayUserProfile(),但getJSON()或displayUserProfile()中的任何错误(包括displayUserProfile引发的任何异常)都将传递给handleProfileError()。catch()方法等于调用then()的一种简写形式,相当于第一个参数为null,指定的错误处理程序函数作为第二个参数。
在下一节讨论Promise链时,我们将对catch()和这个错误处理习惯用法有更多的了解。
Promise术语
在我们进一步讨论Promise之前,有必要停下来定义一些术语。当我们不编程时,当我们谈论人类的Promise时,我们会说Promise是“遵守”或“违背”。当讨论JavaScript Promise时,等价术语是“兑现(fulfilled)”和“拒绝(rejected)”。假设您调用了Promise的then()方法并向其传递了两个回调函数。我们说,如果第一个回调被调用,那么这个Promise已经兑现了。我们说,如果第二个回调被调用,那么这个Promise就被拒绝了。如果一个Promise既没有兑现也没有被拒绝,那么它就是待定的(pending)。一旦一个Promise兑现或被拒绝,我们就说它已经结束了。请注意,一个Promise永远不会既兑现又被拒绝。一个Promise一旦结束了就永远不会从兑现状态变成拒绝状态,反之亦然。
还记得我们在本节开始时是如何定义Promise的:“Promise是一个表示异步操作结果的对象。”重要的是要记住,Promise不仅仅是注册回调以在某些异步代码完成时运行的抽象方式,它们表示异步代码的结果。如果异步代码正常运行(并且兑现了Promise),那么结果本质上就是代码的返回值。如果异步代码不能正常完成(并且Promise被拒绝),那么结果是一个错误对象或代码在非异步时可能抛出的其他值。任何已经结束的Promise都有一个与之相关的值,而且这个值不会改变。如果兑现了Promise,则该值是一个返回值,该值将传递给注册为then()的第一个参数的任何回调函数。如果Promise被拒绝,那么该值就是某种类型的错误,它会传递给任何使用catch()注册的回调函数或作为then()的第二个参数。
我之所以要对Promise的术语进行精确的描述,是因为Promise也可以被解决(resolved)。很容易将这个已解决的状态与已兑现的状态或已结束的状态混淆,但两者都不完全相同。理解已解决的状态是深入理解Promise的关键之一,在我们讨论完下面的Promise链之后,我将再次讨论它。
13.2.2 Promise链
Promise最重要的好处之一是,它们提供了一种自然的方法,可以将异步操作序列表示为then()方法调用的线性链,而不必将每个操作嵌套在前一个调用的回调中。例如,这里有一个假设的Promise链: