第 13 章 异步 JavaScript

一些计算机程序,如科学模拟和机器学习模型,是计算受限的:它们不停地运行,没有停顿,直到计算出结果。然而,值得注意的是现实世界中的大多数计算机程序都是异步的。这意味着在等待数据到达或某些事件发生时,它们常常不得不停止计算。web 浏览器中的 JavaScript 程序是典型地事件驱动的,这意味着它们在实际执行任何操作之前等待用户单击或点击。基于 javascript 的服务器通常在执行任何操作之前等待客户机请求通过网络到达。

这种异步编程在 JavaScript 中很常见,本章将介绍三种重要的语言特性,它们有助于简化异步代码的使用。Promise 是 ES6 中的新特性,是表示目前不可用结果的异步操作对象。关键字 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() 的第一个实参是一个函数,第二个实参是一个以毫秒为单位的时间间隔。前面的代码中,在 setTimeout() 调用后 60,000 毫秒(1分钟)后,将调用一个假定的 checkForUpdates() 函数。checkForUpdates() 是程序定义的一个回调函数,而 setTimeout() 是用于注册回调函数并指定应该在什么异步条件下调用它的函数。

setTimeout() 调用一次指定的回调函数,不传递任何实参,然后忘记它。如果正在编写一个检查更新的函数,可能希望它重复运行。可以使用 setInterval() 来代替 setTimeout():

// Call checkForUpdates in one minute and then again every minute after that
let updateIntervalId = setInterval(checkForUpdates, 60000);

// setInterval() returns a value that we can use to stop the repeated
// invocations by calling clearInterval(). (Similarly, setTimeout()
// returns a value that you can pass to clearTimeout())
function stopCheckingForUpdates() {
    clearInterval(updateIntervalId);
}

13.1.2 事件

客户端 JavaScript 程序几乎都是由事件驱动的:它们通常不等待用户执行某种预定的计算,而是等待用户执行某些操作,然后响应用户的操作。当用户按下键盘上的键,移动鼠标,单击鼠标按钮或触摸触摸屏设备时,Web 浏览器会发生事件。事件驱动的 JavaScript 程序在指定的上下文中为指定类型的事件注册回调函数,并且只要指定事件发生,Web 浏览器就会调用这些函数。这些回调函数称为事件句柄或事件监听器,并且使用 addEventListener() 注册:

// Ask the web browser to return an object representing the HTML
// <button> element that matches this CSS selector
let okay = document.querySelector('#confirmUpdateDialog button.okay');

// Now register a callback function to be invoked when the user
// clicks on that button.
okay.addEventListener('click', applyUpdate);

在此示例中,假设 applyUpdate() 是我们在某个地方实现的回调函数。调用 document.querySelector() 返回一个对象,该对象表示网页中的单个指定元素。我们在该元素上调用 addEventListener() 来注册我们的回调。然后,addEventListener() 的第一个实参是一个字符串,该字符串指定了事件的类型(在这种情况下是单击鼠标或触摸屏)。如果用户单击或点击网页上的特定元素,则浏览器将调用我们的 applyUpdate() 回调函数,并传递一个包含事件详细信息(例如时间和鼠标指针坐标)的对象。

13.1.3 网络事件

JavaScript 编程中异步的另一个常见来源是网络请求。在浏览器中运行的 JavaScript 可以使用以下代码从 Web 服务器获取数据:

function getCurrentVersionNumber(versionCallback) { // Note callback argument
    // Make a scripted HTTP request to a backend version API
    let request = new XMLHttpRequest();
    request.open("GET", "http://www.example.com/api/version");
    request.send();

    // Register a callback that will be invoked when the response arrives
    request.onload = function() {
        if (request.status === 200) {
            // If HTTP status is good, get version number and call callback.
            let currentVersion = parseFloat(request.responseText);
            versionCallback(null, currentVersion);
        } else {
            // Otherwise report an error to the callback
            versionCallback(response.statusText, null);
        }
    };
    // Register another callback that will be invoked for network errors
    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() 会使用 null 为第一个实参和版本号为第二个实参调用回调函数。或者,如果发生异常,则 getCurrentVersionNumber() 会在第一个实参中带有异常详细信息,而在第二个参数中使用 null。

13.1.4 Callbacks and Events in Node

The Node.js server-side JavaScript environment is deeply asynchronous and defines many APIs that use callbacks and events. The default API for reading the contents of a file, for example, is asynchronous and invokes a callback function when the contents of the file have been read:

const fs = require("fs"); // The "fs" module has filesystem-related APIs
let options = {           // An object to hold options for our program
    // default options would go here
};

// Read a configuration file, then call the callback function
fs.readFile("config.json", "utf-8", (err, text) => {
    if (err) {
        // If there was an error, display a warning, but continue
        console.warn("Could not read config file:", err);
    } else {
        // Otherwise, parse the file contents and assign to the options object
        Object.assign(options, JSON.parse(text));
    }

    // In either case, we can now start running the program
    startProgram(options);
});

Node’s fs.readFile() function takes a two-parameter callback as its last argument. It reads the specified file asynchronously and then invokes the callback. If the file was read successfully, it passes the file contents as the second callback argument. If there was an error, it passes the error as the first callback argument. In this example, we express the callback as an arrow function, which is a succinct and natural syntax for this kind of simple operation.

Node also defines a number of event-based APIs. The following function shows how to make an HTTP request for the contents of a URL in Node. It has two layers of asynchronous code handled with event listeners. Notice that Node uses an on() method to register event listeners instead of addEventListener():

const https = require("https");

// Read the text content of the URL and asynchronously pass it to the callback.
function getText(url, callback) {
    // Start an HTTP GET request for the URL
    request = https.get(url);

    // Register a function to handle the "response" event.
    request.on("response", response => {
        // The response event means that response headers have been received
        let httpStatus = response.statusCode;

        // The body of the HTTP response has not been received yet.
        // So we register more event handlers to to be called when it arrives.
        response.setEncoding("utf-8");  // We're expecting Unicode text
        let body = "";                  // which we will accumulate here.

        // This event handler is called when a chunk of the body is ready
        response.on("data", chunk => { body += chunk; });

        // This event handler is called when the response is complete
        response.on("end", () => {
            if (httpStatus === 200) {   // If the HTTP response was good
                callback(null, body);   // Pass response body to the callback
            } else {                    // Otherwise pass an error
                callback(httpStatus, null);
            }
        });
    });

    // We also register an event handler for lower-level network errors
    request.on("error", (err) => {
        callback(err, null);
    });
}

13.2 Promise

现在,我们已经见过了客户端和服务器端 JavaScript 环境中基于回调和基于事件的异步编程的示例,我们接着介绍 Promise,这是一种旨在简化异步编程的核心语言特性。

Promise 是描述异步计算结果的对象。该结果可能准备好或尚未准备好,Promise API 故意对此含糊其词:无法同步获取 Promise 的值;只能要求 promise 在值准备好时调用回调函数。如果要像上一节中的 getText() 函数那样定义异步 API,但想使其基于 Promise,则省略 callback 参数,而返回 Promise 对象。然后,调用者可以在此 Promise 对象上注册一个或多个回调,并且在异步计算完成后将调用它们。

因此,最简单的说,promise 只是使用回调的另一种方式。但是,使用它有实际的好处。基于回调的异步编程的一个真正的问题是,通常在回调内部嵌套多层回调,并且代码行缩进程度很高,以至于很难阅读。Promise 允许将这种嵌套的回调作为更线性的 Promise 链重新表达,该链往往更易于阅读和推理。

回调的另一个问题是,它们会使处理异常变得困难。如果异步函数(或异步调用的回调)引发异常,则该异常无法传播回异步操作的发起者。这是关于异步编程的基本事实:它破坏了异常处理。替代方法是使用回调实参和返回值来精心跟踪和传播异常,但这很繁琐且难以正确处理。Promise 通过标准化处理异常的方式以及为异常通过 Promise 链正确传播的方式提供帮助。

请注意,promise 表示单个异步计算的未来结果。但是,它不能用于表示重复的异步计算。例如,在本章的后面,我们将写一个基于 Promise 的 setTieout() 函数替代方法。但是,我们不能使用 Promise 来代替 setInterval(),因为该函数会反复调用回调函数,而 Promise 并不是。同样,我们可以使用 Promise 代替 XMLHttpRequest 对象的“load”事件处理程序,因为该回调仅被调用一次。但是我们通常不会使用 Promise 来代替 HTML 按钮对象的“click”事件处理程序,因为我们通常希望允许用户多次单击按钮。

接下来的小节将:

  • 解释 Promise 术语并演示 Promise 的基本用法
  • 展示如何将 Promise 链接起来
  • 演示如何创建自己的基于 Promise 的 API

重点
Promise 一开始似乎很简单,并且 Promise 的基本用例实际上也是简单明了的。但是,除了最简单的用例之外,它们还会使其他任何事情变得令人困惑。对于异步编程,Promise 是一个强大的习惯用法,但是需要深刻理解它们,才能正确、自信地使用它们。但是,它值得花时间来深入理解,我建议仔细阅读这一长篇章。

13.2.1 使用 Promise

随着核心 JavaScript 语言中 Promise 的出现,Web 浏览器已经开始实现基于 Promise 的 API。在上一节中,我们实现了一个 getText() 函数,该函数发出一个异步 HTTP 请求,并将 HTTP 响应的主体作为字符串传递给指定的回调函数。想象一下该函数的一个变体 getJSON(),它可以解析主体 HTTP 响应的形式为 JSON,并返回 Promise 而不是接受回调参数。我们将在本章稍后实现 getJSON() 函数,但现在,让我们看一下如何使用返回 Promise 功能程序函数:

getJSON(url).then(jsonData => {
    // This is a callback function that will be asynchronously
    // invoked with the parsed JSON value when it becomes available.
});

getJSON() 对指定的 URL 启动一个异步 HTTP 请求,当该请求待定时,它返回一个 Promise 对象。Promise 对象定义了 then() 实例方法。我们没有将回调函数直接传递给 getJSON(),而是将其传递给 then() 方法。当 HTTP 响应时,该响应的主体将解析为 JSON,并将所解析的结果值传给我们传递给 then() 的回调函数。

可以将 then() 方法视为回调注册方法,例如用于在客户端 JavaScript 中注册事件处理程序的 addEventListener() 方法。如果多次调用 Promise 对象的 then() 方法,则在完成 promise 的计算后将调用指定的每个函数。

但是,与许多事件侦听器不同,Promise 表示单个计算,并且 then() 注册的每个函数仅被调用一次。值得注意的是,传递给 then() 的函数是异步调用的,即使调用 then() 时异步计算已经完成。

在简单的语法级别上,then() 方法是 Promise 独有的特性,编码中习惯于将 .then() 直接跟随返回 Promise 的函数,而无需将 Promise 对象分配给变量的中间步骤。

常用带有动词命名返回 Promise 的函数和使用 Promise 结果的函数,这些常用语使代码特别容易阅读:

// Suppose you have a function like this to display a user profile
function displayUserProfile(profile) { /* implementation omitted */ }

// Here's how you might use that function with a Promise.
// Notice how this line of code reads almost like an English sentence:
getJSON("/api/user/profile").then(displayUserProfile);

PROMISE 异常处理

异步操作,尤其是涉及网络的异步操作,通常会以多种方式失败,并且必须编写健壮的代码来处理不可避免地会发生的异常。

对于 Promise,我们可以通过将第二个函数传递给 then() 方法来实现:

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

Promise 描述在 Promise 对象创建之后发生的异步计算的未来结果。由于计算是在 Promise 对象返回给我们之后执行的,因此该计算无法传统地返回值或引发我们可以捕获的异常。我们传递给 then() 的函数提供了替代方案。当同步计算正常完成时,它仅将其结果返回给其调用者。当基于 Promise 的异步计算正常完成时,它将其结果传递给 then() 的第一个实参函数。

当同步计算中出现问题时,它将引发一个异常,该异常会沿调用堆栈传播,直到有一个 catch 子句来处理它为止。当异步计算运行时,它的调用者不再在堆栈上,因此,如果出现问题,则根本不可能将异常抛出给调用者。

而基于 Promise 的异步计算将异常(通常是某种 Error 对象,尽管这不是必需的)传递给 then() 的第二个函数。因此,在上面的代码中,如果 getJSON() 正常运行,它将其结果传递给 displayUserProfile()。如果出现异常(用户未登录,服务器关闭,用户的 Internet 连接断开,请求超时等),则 getJSON() 会将 Error 对象传递给 handleProfileError()。

实践中,很少有两个函数传递给 then()。在处理 Promise 时,有一种更好更常用的异常处理方式。为了理解它,首先考虑如果 getJSON() 正常完成但 displayUserProfile() 中发生异常该怎么办。当 getJSON() 返回时,该回调函数将异步调用,因此它也是异步的,并且无法有意义地引发异常(因为调用堆栈上没有代码可以处理该异常)。

如下所示,处理此代码中异常的更常用方式:

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

使用此代码,getJSON() 的正常结果仍会传递给 displayUserProfile(),但是 getJSON() 或 displayUserProfile() 中的任何异常(包括 displayUserProfile 抛出的任何异常)都将传递给 handleProfileError()。调用 then() 第一个实参为空,指定的异常处理函数为第二个实参,catch() 方法只是其简写。

在下一节中讨论 Promise 链时,我们将对 catch() 和这个处理异常常用方法作更多的说明。

PROMISE 专业术语

在我们进一步讨论 Promises 之前,需要暂停定义一些术语。现实生活中,我们讨论下人类的诺言,我们说“信守”或“违背”诺言。在讨论 JavaScript Promise 时,用“已兑现(fulfilled)”和“已拒绝(rejected)”。想象一下,已经调用了 Promise 的 then() 方法,并向其传递了两个回调函数。当调用第一个回调,那么我们说 Promise 已兑现。当调用第二个回调,我们则说 Promise 已被拒绝。如果一个 Promise 既不是已兑现也不是已拒绝,那么它就是待定(pending)。一旦 Promise 已兑现或已拒绝,我们就说它已敲定(settled)。请注意,一个 Promise 永远不会同时已兑现和已拒绝。Promise 一旦敲定,就永远不会从已兑现变为已拒绝,反之亦然。

记住我们在本节开始时如何定义 Promise:“Promise 是描述异步计算结果的对象。”重要的是要记住,Promise 不仅仅是注册在某些异步代码完成时运行的回调的抽象方式,它们还描述了异步代码的结果。如果异步代码正常运行(并且 Promise 已兑现),那么该结果实质上就是代码的返回值。而且,如果异步代码无法正常完成(并且 Promise 已拒绝),那么结果将是 Error 对象或其他不是异步的代码可能会抛出的值。任何已敲定的 Promise 都有与其相关的值,并且该值不会改变。如果 Promise 已兑现,则该值是一个返回值,该值将传递给注册为 then() 第一个实参的回调函数。如果 Promise 已拒绝,则该值是某种异常,该异常会传递给使用 catch() 或 then() 的第二个实参注册的回调函数。

我希望对 Promise 术语保持精确的原因是 Promise 还可以被决议。将已决议状态与已兑现状态或已敲定状态混淆是很容易的,但是三者都不完全相同。理解已决议状态是深入了解 Promise 的关键之一,在下面讨论了 Promise 链之后,我将再次介绍它。

13.2.2 Promise 链

Promise 的最重要的好处之一是,它们提供了一种自然的方式来表达一系列异步操作,表示 then() 方法调用的线性链,而不必将每个操作嵌套在前一个回调中。例如,一个假设的 Promise 链:

fetch(documentURL)                      // Make an HTTP request
    .then(response => response.json())  // Ask for the JSON body of the response
    .then(document => {                 // When we get the parsed JSON
        return render(document);        // display the document to the user
    })
    .then(rendered => {                 // When we get the rendered document
        cacheInDatabase(rendered);      // cache it in the local database.
    })
    .catch(error => handle(error));     // Handle any errors that occur

此代码表明了 Promise 链如何简化一系列异步操作。我们不会讨论这个特殊的 Promise 链。但是,我们将继续探索使用 Promise 链发出 HTTP 请求的想法。

在本章的前面,我们看到了 XMLHttpRequest 对象,该对象用于在 JavaScript 中发出 HTTP 请求。这个奇怪命名的对象具有一个旧且笨拙的 API,并且在很大程度上已被较新的基于 Promise 的 Fetch API(§15.11.1)所取代。以最简单的形式,这个新的 HTTP API 只是 fetch() 函数。给它传递一个 URL,然后返回一个 Promise。当 HTTP 开始收到响应并且 HTTP 状态和标头可用时,这个 promise 已兑现:

fetch("/api/user/profile").then(response => {
    // When the promise resolves, we have status and headers
    if (response.ok &&
        response.headers.get("Content-Type") === "application/json") {
        // What can we do here? We don't actually have the response body yet.
    }
});

当 fetch() 返回的 Promise 已兑现时,它将 Response 对象传递给传递给 then() 方法的函数。此响应对象可以访问请求状态和标头,并且还定义了诸如 text() 和 json() 之类的方法,这些方法可以分别以文本和 JSON 的形式访问响应的正文。但是,尽管最初的 Promise 已兑现,但响应的主体可能尚未到达。因此,这些用于访问响应正文的 text() 和 json() 方法本身返回 Promise。这是使用 fetch() 和 response.json() 方法获取 HTTP Response 响应正文的一种简单方法:

fetch("/api/user/profile").then(response => {
    response.json().then(profile => {  // Ask for the JSON-parsed body
        // When the body of the response arrives, it will be automatically
        // parsed as JSON and passed to this function.
        displayUserProfile(profile);
    });
});

这是 Promise 一种没经验的使用方式,因为我们像回调一样嵌套了它们,这违背了目的。首选常用方法是在顺序链中使用 Promise,其代码如下所示:

fetch("/api/user/profile")
    .then(response => {
        return response.json();
    })
    .then(profile => {
        displayUserProfile(profile);
    });

让我们看一下这段代码中的方法调用,忽略传递给方法的参数:

fetch().then().then()

当像这样在单个表达式中调用多个方法时,我们将其称为方法链。我们知道 fetch() 函数返回一个 Promise 对象,并且可以看到该链中的第一个 .then() 作为返回的 Promise 对象的方法调用。但是链中还有第二个 .then(),这意味着 then() 方法的第一次调用本身一定返回 Promise。

有时,当一个 API 设计为使用这种方法链接时,只有一个对象,并且该对象的每个方法都返回该对象本身以便于链接。但是,这不是 Promise 的工作方式。当我们编写一系列的 .then() 调用时,我们并未在单个 Promise 对象上注册多个回调。而是,对 then() 方法的每次调用都会返回一个新的 Promise 对象。在传递给 then() 的函数完成之前,新的 Promise 对象不会被兑现。

让我们回到上面原始 fetch() 链的简化形式。如果我们在其他地方定义传递给 then() 调用的函数,则可以将代码重构为如下形式:

fetch(theURL)          // task 1; returns promise 1
    .then(callback1)   // task 2; returns promise 2
    .then(callback2);  // task 3; returns promise 3

让我们详细介绍这段代码:

  1. 在第一行,使用 URL 调用 fetch()。它针对该 URL 发起 HTTP GET请求并返回 Promise。我们将这个 HTTP 请求称为“task 1”,将 Promise 称为“promise 1”。
  2. 在第二行,我们调用 promise 1 的 then() 方法,并传递 promise 1 在已兑现时要调用的 callback1 函数。then() 方法将回调函数存储在某个位置,然后返回一个新的 Promise。我们将在这一步返回的新 Promise 称为“promise 2”,并且我们说当 callback1 被调用时“task 2”开始。
  3. 在第三行,我们调用 promise 2 的 then() 方法,并传递 promise 2 在已兑现时要调用的 callback2 函数。这个 then() 方法会记住我们的回调并返回另一个 Promise。我们说“task 3”是在调用 callback2 时开始的。我们可以将最新的 Promise 称为“promise 3”,但是我们并不需要它的名称,因为我们根本不会使用它。
  4. 最初执行表达式时,前三个步骤都是同步发生的。现在,在第 1 步中启动的 HTTP 请求通过 Internet 发送时,我们有了异步暂停。
  5. 最终,HTTP 响应开始到达。fetch() 调用的异步部分将 HTTP 状态和标头包装在 Response 对象中,并以该 Response 对象作为值来兑现 promise 1。
  6. promise 1 已兑现后,其值(Response 对象)将传递到我们的 callback1() 函数,task 2 开始。以 Response 对象作为输入,此任务的工作是获得响应主体转化为 JSON 对象。
  7. 假设 task 2 正常完成,并且能够解析 HTTP 响应主体以生成 JSON 对象。此 JSON 对象用于兑现 promise 2。
  8. 兑现 promise 2 的值在传递给 callback2() 函数时成为 task 3 的输入。现在,第三个任务以某种未指定的方式向用户显示数据。当 task 3 完成时(假设它正常完成),则 promise 3 将被兑现。但是,因为我们从未对 promise 3 做任何事情,所以当 promise 3 敲定时,什么也没有发生,并且异步计算链到此结束。

13.2.3 Promise 决议

在上一部分中用列表解释 URL-fetching Promise 链时,我们讨论了 promise 1、2 和 3。但是实际上也涉及第四个 Promise 对象,并且这为我们带来重要的讨论————什么是 Promise 的“已决议(resolved)”状态 。

请记住,fetch() 返回一个 Promise 对象,当其已兑现时,它将 Response 对象传递给我们注册的回调函数。此 Response 对象具有 .text()、.json() 和其他方法,以各种形式请求 HTTP 响应的主体。但是由于主体可能尚未到达,因此这些方法必须返回 Promise 对象。在我们一直在研究的示例中,“task 2”调用 .json() 方法并返回其值。这是第四个 Promise 对象,它是 callback1() 函数的返回值。

让我们以冗长且非常用方式再次重写 URL-fetching 代码,使回调和 Promise 明确化:

function c1(response) {               // callback 1
    let p4 = response.json();
    return p4;                        // returns promise 4
}

function c2(profile) {                // callback 2
    displayUserProfile(profile);
}

let p1 = fetch("/api/user/profile");  // promise 1, task 1
let p2 = p1.then(c1);                 // promise 2, task 2
let p3 = p2.then(c2);                 // promise 3, task 3

为了使 Promise 链有效地工作,task 2 的输出必须成为 task 3 的输入。在此示例中,我们在这里考虑的是,task 3 的输入是从 URL 所获取的的主体,将其解析为 JSON 对象。但是,正如我们刚刚讨论的那样,回调 c1 的返回值不是 JSON 对象,而是该 JSON 对象的 Promise p4。这似乎有矛盾,但并非如此:当 p1 已兑现时,将调用 c1,并且 task 2 开始。当 p2 已兑现时,c2 被调用,task 3 开始。但是,仅仅因为 c1 被调用时开始 task 2 ,可这并不意味着 task 2 必须在 c1 返回时结束。毕竟,Promise 是关于管理异步任务的,如果 task 2 是异步的(在本例中为异步),则在回调返回时该任务将不会完成。

现在我们准备讨论最后的细节,需要了解这些才能真正掌握 Promise。当将回调 c 传递给 then() 方法时,then() 返回 Promise p 并安排在以后的某个时间异步调用 c。调执行一些计算并返回值 v。当回调返回时,p 用值 v 决议。当 Promise 使用不是本身的 Promise 值决议时,立即用该值兑现。因此,如果 c 返回一个非 Promise,则返回值成为 p 的值,则 p 已兑现并且任务完成。但是,如果返回值 v 本身是一个 Promise,则 p 已决议但尚未兑现。在此阶段,直到 Promise v 敲定,p 才能敲定。如果 v 已兑现,则 p 将被兑现为相同的值。如果 v 已拒绝,则 p 将因相同的原因而被拒绝。这就是一个 Promise 的“已决议(resolved)”状态的含义:Promise 已与另一个 Promise 关联或“锁定”。我们尚不知道 p 是已兑现还是拒绝,但是我们的回调 c 对此不再具有任何控制权。p 是“已决议”的,从这一方面来说它命运现在完全取决于 Promise v 会发生什么。

让我们回到我们的 URL-fetching 示例中。 当 c1 返回 p4 时,p2 已决议。但是已决议与已兑现并不相同,因此 task 3 尚未开始。当 HTTP 响应的全文可用时,.json() 方法可以对其进行解析,并使用该解析后的值来已兑现 p4。 当 p4 已兑现时,也会使用相同的已解析 JSON 值自动已兑现 p2。此时,已解析的 JSON 对象将传递给 c2,然后 task 3 开始。

这可能是 JavaScript 最难理解的部分之一,可能需要多次阅读本节。图 13-1 以可视形式显示了该过程,可能有助于对其进行说明。

图 13-1 用 Promise 获取 URL
fg13-1

13.2.4 更多关于 Promise 和异常

在本章的前面,我们看到可以将第二个回调函数传递给 .then() 方法,并且如果 Promise 被拒绝,则将调用该第二个函数。发生这种情况时,第二个回调函数的实参是一个值(通常是一个 Error 对象),它表示拒绝的原因。我们还了解到,将两个回调传递给 .then() 方法并不常见(甚至是单例的)。相反,通常通过向 Promise 链添加 .catch() 方法调用来处理与 Promise 相关的异常。现在我们已经检查了 Promise 链,我们可以返回异常处理并更详细地讨论它。在开始讨论之前,我想强调指出,进行异步编程时,仔细的异常处理非常重要。使用同步代码,如果省略了异常处理代码,则至少会得到一个异常和一个堆栈跟踪,可用于找出问题所在。对于异步代码,未处理的异常通常不会报告,异常可以静默发生,从而使调试更加困难。好消息是,使用 .catch() 方法可以更轻松地处理 Promise 的异常。

CATCH 和 FINALLY 方法

.then() 可以处理异常,使用 null 为第一个实参,而异常处理回调为第二个实参,Promise 的 .catch() 方法只是这种 .then() 调用的一种简便写法。给定 Promise p 和回调 c,以下两行代码是等效的:

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

首选 .catch() 速记,因为它更简单,并且名称与 try/catch 异常处理语句中的 catch 子句匹配。正如我们所讨论的,普通例外不适用于异步代码。Promise 的 .catch() 方法是一种适用于异步代码的替代方法。当同步代码中出现问题时,我们“使调用堆栈冒泡”描述一个异常,直到找到 catch 块为止。对于异步的 Promise 链,则是“向链下滴”,直到找到 .catch() 调用为止。

在 ES2018 中,Promise 对象还定义了一个 .finally() 方法,其目的类似于 try/catch/finally 语句中的 finally 子句。如果将 .finally() 调用添加到 Promise 链中,那么 .finally() 的调用者 Promise 的敲定的时候,传递给 .finally() 的回调将被调用。如果 Promise 已兑现或已拒绝,则将调用回调,并且它不会再作为实参传递,因此无法确定它是已兑现还是已拒绝。但是,无论哪种情况,如果都需要运行某种清理代码(例如关闭打开的文件或网络连接),则 .finally() 回调是实现此目的的理想方法。与 .then() 和 .catch() 一样,.finally() 返回一个新的 Promise 对象。.finally() 回调的返回值通常被忽略,.finally() 返回的 Promise 通常将以调用 .finally() 的 Promise 相同的值来决议或拒绝。但是,如果 .finally() 回调引发异常,则 .finally() 返回的 Promise 以该异常值拒绝。

我们在上一节中研究的 URL-fetching 代码没有任何异常处理。现在,使用更可行的代码版本进行更正:

fetch("/api/user/profile")    // Start the HTTP request
    .then(response => {       // Call this when status and headers are ready
        if (!response.ok) {   // If we got a 404 Not Found or similar error
            return null;      // Maybe user is logged out; return null profile
        }

        // Now check the headers to ensure that the server sent us JSON.
        // If not, our server is broken, and this is a serious error!
        let type = response.headers.get("content-type");
        if (type !== "application/json") {
            throw new TypeError(`Expected JSON, got ${type}`);
        }

        // If we get here, then we got a 2xx status and a JSON content-type
        // so we can confidently return a Promise for the response
        // body as a JSON object.
        return response.json();
    })
    .then(profile => {        // Called with the parsed response body or null
        if (profile) {
            displayUserProfile(profile);
        }
        else { // If we got a 404 error above and returned null we end up here
            displayLoggedOutProfilePage();
        }
    })
    .catch(e => {
        if (e instanceof NetworkError) {
            // fetch() can fail this way if the internet connection is down
            displayErrorMessage("Check your internet connection.");
        }
        else if (e instanceof TypeError) {
            // This happens if we throw TypeError above
            displayErrorMessage("Something is wrong with our server!");
        }
        else {
            // This must be some kind of unanticipated error
            console.error(e);
        }
    });

让我们通过发生异常情况情况来分析此代码。我们将使用之前使用的命名方案:p1 是 fetch() 调用返回的 Promise。p2 是第一个 .then() 调用返回的 Promise,而 c1 是我们传递给该 .then() 的回调。p3 是第二个 .then() 调用返回的 Promise,而 c2 是我们传递给该调用的回调。最后,c3 是我们传递给 .catch() 的回调。(该调用返回一个 Promise,但我们不需要按名称引用它。)

第一个可能失败的是 fetch() 请求本身。如果网络连接断开(或由于某些其他原因而无法发出 HTTP 请求),则 Promise p1 将被 NetworkError 对象拒绝。我们没有将异常处理回调函数作为 .then() 调用的第二个实参传递,因此 p2 同样会被相同的 NetworkError 对象拒绝。(如果将异常处理程序传递给第一个 .then() 调用,则将调用该异常处理程序,并且如果该异常处理程序正常返回,伴随处理的返回值,p2 变为已决议和或或已兑现。)但是,出于相同的原因,p2 被拒绝,然后 p3 被拒绝。此时,将调用 c3 异常处理回调,并在其中运行特定于 NetworkError 的代码。

代码失败的另一种方式是,如果我们的 HTTP 请求返回 404 Not Found 或另一个 HTTP 异常。这些是有效的 HTTP 响应,因此 fetch() 调用不会将其视为异常。fetch() 在 Response 对象中封装了一个 404 Not Found,并用该对象已兑现 p1,从而导致 c1 被调用。我们在 c1 中的代码检查 Response 对象的 ok 属性,以检测它没有收到正常的 HTTP 响应,并通过简单地返回 null 来处理这种情况。因为此返回值不是 Promise,所以它立即已兑现 p2,并使用该值调用 c2。 我们在 c2 中的代码显式检查并处理错误值,并向用户显示不同的结果。在这种情况下,我们将异常情况视为非异常,并在不使用异常处理程序的情况下对其进行处理。

如果我们获得正常的 HTTP 响应代码,但 Content-Type 标头设置不正确,则 c1 中会发生更严重的异常。我们的代码需要一个 JSON 格式的响应,因此,如果服务器发送给我们的是 HTML、XML 或纯文本,那么我们将会遇到问题。c1 包含用于检查 Content-Type 标头的代码。如果标头错误,则将其视为不可恢复的问题,并引发 TypeError。当传递给 .then()(或 .catch())的回调引发一个值时,作为 .then() 调用的返回的 Promise 将被该抛出的值拒绝。在这种情况下,c1 中的代码引发 TypeError 导致带有 TypeError 对象的 p2 一起被拒绝。由于我们没有为 p2 指定异常处理程序,因此 p3 也将被拒绝。c2 将不会被调用,并且 TypeError 将传递给 c3,后者具有显式检查和处理此类异常的代码。

关于此代码有些得注意。首先,请注意用常规的同步 throw 语句引发的异常对象最终会通过 Promise 链中的 .catch() 方法调用进行异步捕获。这清楚表明为什么与将第二个实参传递给 .then() 相比更偏向使用这种速记方法,以及为什么习惯以 .catch() 调用结束 Promise 链。

在我们结束异常处理主题之前,我想指出,尽管习惯于在每个 Promise 链中添加一个 .catch() 来清理(或至少记录日志)该链中发生的任何异常,但是在 Promise 链中的其他位置使用 .catch() 也完全有效。如果 Promise 链中的某一阶段可能因异常而失败,并且该异常是某种可恢复的异常,并且该异常不会阻止链的其余部分运行,则可以在链中插入 .catch() 调用,代码可能如下所示:

startAsyncOperation()
    .then(doStageTwo)
    .catch(recoverFromStageTwoError)
    .then(doStageThree)
    .then(doStageFour)
    .catch(logStageThreeAndFourErrors);

请记住,仅当前一阶段的回调引发异常时,才会调用传递给 catch() 的回调。如果该回调正常返回,则将跳过 catch() 回调,并且前一个回调的返回值将成为下一个 then() 回调的输入。还请记住,catch() 回调不仅用于异常报告,而且用于异常处理并从异常中恢复。将异常传递给 catch() 回调后,它将停止沿 Promise 链传播。catch() 回调可以引发新的异常,但是如果它正常返回,则该返回值用于决议和或或兑现关联的 Promise,并且异常停止传播。

让我们具体地讲一下:在前面的代码示例中,如果 startAsyncOperation() 或 doStageTwo() 引发异常,则将调用 recoveryFromStageTwoError() 函数。如果 restoreFromStageTwoError() 正常返回,则其返回值将传递给 doStageThree(),并且异步操作将正常继续。另一方面,如果 recoverFromStageTwoError() 无法恢复,则它本身将引发异常(或者重新抛出传入的异常)。在这种情况下,不会调用 doStageThree() 或 doStageFour(),并且 recoverFromStageTwoError() 引发的异常将传递给 logStageThreeAndFourErrors()。

有时,在复杂的网络环境中,异常可能会或多或少地随机发生,并且可以通过简单地重试异步请求来处理这些异常。假设编写了一个基于 Promise 的操作来查询数据库:

queryDatabase()
    .then(displayTable)
    .catch(displayDatabaseError);

现在,假设大约 1% 的概率瞬态网络负载问题导致其失败。一个简单的解决方案可能是使用 .catch() 调用重试查询:

queryDatabase()
    .catch(e => wait(500).then(queryDatabase))  // On failure, wait and retry
    .then(displayTable)
    .catch(displayDatabaseError);

如果假设的失败确实是随机的,那么添加这一行代码应使错误率从 1% 降低到 .01%。

从 PROMISE 回调函数返回

让我们最后一次返回前面的 URL-fetching 示例,并考虑传递给第一个 .then() 调用的回调 c1。注意,c1 可以通过三种方式终止。它可以通过 .json() 调用返回的 Promise 正常返回。这将导致 p2 变为已决议,但是该 Promise 是已兑现还是拒绝取决于新返回的 Promise 发生了什么。c1 也可以正常返回 null 值,这将导致 p2 立即变成已兑现。最后,c1 可以通过引发异常来终止,从而导致 p2 变成已拒绝。这是 Promise 的三个可能结果,而 c1 中的代码演示了回调如何导致每个结果。

在 Promise 链中,在链的一个阶段返回(或抛出)的值成为链的下一阶段的输入,因此正确实现这一点至关重要。实际上,回调函数忘记返回值是与 Promise 相关的常见错误,而 JavaScript 的箭头函数快捷语法会加剧这种情况。回想一下我们之前看到的以下代码行:

.catch(e => wait(500).then(queryDatabase))

回顾第 8 章,箭头函数提供了许多快捷方式。由于仅存在一个参数(异常值),因此我们可以省略括号。由于函数的主体是单个表达式,因此我们可以省略函数主体周围的花括号,并且表达式的值成为函数的返回值。由于这些快捷方式,前面的代码是正确的。但是考虑一下这种无害的变化:

.catch(e => { wait(500).then(queryDatabase) })

通过添加花括号,我们不再获得自动返回。现在,此函数返回 undefined 而不是返回 Promise,这意味着将以 undefined 作为输入而不是重试查询的结果来调用此 Promise 链中的下一个阶段。这是一个细微的错误,可能不容易调试。

13.2.5 并行 Promise

我们花了很多时间讨论 Promise 链,这些链可按顺序运行较大的异步的操作步骤。但是,有时我们想并行执行许多异步操作。函数 Promise.all() 可以做到这一点。Promise.all() 将 Promise 对象数组作为其输入,并返回 Promise。如果任何输入的 Promise 中有一个是已拒绝状态,则返回的 Promise 将被拒绝。否则,它将使用每个输入 Promise 的兑现值组成的数组来被兑现。因此,例如,如果要获取多个 URL 的文本内容,则可以使用如下代码:

// We start with an array of URLs
const urls = [ /* zero or more URLs here */ ];
// And convert it to an array of Promise objects
promises = urls.map(url => fetch(url).then(r => r.text()));
// Now get a Promise to run all those Promises in parallel
Promise.all(promises)
    .then(bodies => { /* do something with the array of strings */ })
    .catch(e => console.error(e));

Promise.all() 比前面描述的要灵活一些。输入数组可以包含 Promise 对象和非 Promise 值。如果数组的元素不是 Promise,则将其视为已兑现的 Promise 的值,并原封不动地复制到输出数组中。

当任何输入的 Promise 被拒绝时,Promise.all() 返回的 Promise 也会被拒绝。这在第一次拒绝时立即发生,可能其他输入 Promise 仍是待定状态。在 ES2020 中,Promise.allSettled() 接受输入的 Promise 数组,并返回 Promise,就像 Promise.all() 一样。但是 Promise.allSettled() 永远不会拒绝返回的 Promise,并且不会兑现这个 Promise,直到所有输入 Promise 全部已敲定。Promise 解析为一组对象,每个输入 Promise 都有一个对象。每个返回的对象中有一个状态属性设置为“已兑现”或“已拒绝”。如果状态为“已兑现”,则对象还将具有一个 value 属性,该属性提供兑现值。并且,如果状态为“已拒绝”,则对象还将具有一个 reason 属性,该属性给出相应的 Promise 的异常或拒绝值:

Promise.allSettled([Promise.resolve(1), Promise.reject(2), 3]).then(results => {
    results[0]  // => { status: "fulfilled", value: 1 }
    results[1]  // => { status: "rejected", reason: 2 }
    results[2]  // => { status: "fulfilled", value: 3 }
});

有时,可能想一次运行多个 Promise,但可能只关心第一个要兑现的值。在这种情况下,可以使用 Promise.race() 代替 Promise.all()。当输入数组中的第一个 Promise 是已兑现或已拒绝状态时,它返回一个已兑现或已拒绝的 Promise。(或者,如果输入数组中有任何非 Promise 值,则只返回其中的第一个。)

13.2.6 构造 Promise

在之前的许多示例中,我们都使用了 Promise 返回函数 fetch(),因为它是内置于 Web 浏览器中的最简单的返回 Promise 的函数之一。我们对 Promise 的讨论还依赖于假设的 Promise 返回函数 getJSON() 和 wait()。编写用于返回 Promise 的函数确实非常有用,本节说明如何创建自己的基于 Promise 的 API。特别是,我们将展示 getJSON() 和 wait() 的实现。

PROMISE 基于别的 PROMISE

如果以 Promise 返回函数作为开头来写一个返回 Promise 的函数是很容易的。有了 Promise,总是可以通过调用 .then() 创建(并返回)一个新的 Promise。因此,如果我们使用现有的 fetch() 函数作为起点,则可以这样编写 getJSON():

function getJSON(url) {
    return fetch(url).then(response => response.json());
}

该代码很简单,因为 fetch() API 的 Response 对象具有预定义的 json() 方法。我们从回调(该回调是带有单个表达式主体的箭头函数,因此返回值是隐式的)中通过 json() 方法返回一个 Promise,因此 getJSON() 返回的 Promise 解析为 response.json()。当该 Promise 兑现时,由 getJSON() 返回的 Promise 将兑现为相同的值。请注意,此 getJSON() 实现中没有异常处理。如果不能将 response 主体解析为 JSON,则无需检查 response.ok 和 Content-Type 标头,而只需允许 json() 方法拒绝它的 Promise 并返回 SyntaxError。

让我们编写另一个 Promise 返回函数,这次使用 getJSON() 作为初始 Promise 的来源:

function getHighScore() {
    return getJSON("/api/user/profile").then(profile => profile.highScore);
}

我们假设此函数是某种基于网络的游戏的一部分,并且 URL“/api/user/profile”返回的是包含 highScore 属性的 JSON 格式的数据结构。

PROMISE 基于同步值

有时,即使要执行的计算实际上不需要任何异步操作,也可能需要实现现有的基于 Promise 的 API 并从函数返回 Promise。在这种情况下,静态方法 Promise.resolve() 和 Promise.reject() 会做您想要的。Promise.resolve() 采用一个单个实参值,并返回一个 Promise,该 Promise 将立即(但异步地)兑现为该值。同样,Promise.reject() 接受一个实参值,并返回一个 Promise,该值将作为被拒绝的原因。(请注意:这些静态方法返回的 Promise 在返回时尚未兑现或拒绝,但是它们将在当前同步代码块运行完毕后立即兑现或拒绝。通常,这种情况会在几毫秒内发生,除非有许多等待执行的待处理异步任务。)

回顾 §13.2.3,已决议的 Promise 与已兑现的 Promise 不同。当我们调用 Promise.resolve() 时,通常会传递兑现值以创建一个 Promise 对象,该对象将很快兑现该值。但是,该方法未命名为 Promise.fulfill()。如果将 Promise p1 传递给 Promise.resolve(),它将返回一个新的 Promise p2,该 p2 立即被决议,但是直到 p1 被兑现或拒绝,该 Promise p2 才会被兑现或拒绝。

可以(但不常见)编写一个基于 Promise 的函数,同步计算的值通过通过 Promise.resolve() 异步返回。但是,在异步函数中包含同步特殊情况是很常见的,可以使用 Promise.resolve() 和 Promise.reject() 处理这些特殊情况。特别是,如果在开始异步操作之前检测到异常条件(例如异常的实参值),则可以通过返回使用 Promise.reject() 创建的 Promise 来报告该异常。(在这种情况下,也可以同步引发异常,但这被认为是较差的形式,因为函数的调用者需要同时编写同步 catch 子句并使用异步 .catch() 方法来处理异常。)最后,Promise.resolve() 有时可用于在 Promise 链中创建初始 Promise。我们将看到几个使用这种方式的示例。

构造函数创建 PROMISE

对于 getJSON() 和 getHighScore(),我们首先调用现有函数以获取初始 Promise,然后通过调用该初始 Promise 的 .then() 方法创建并返回新的 Promise。但是,当不能使用另一个返回 Promise 函数作为起点时,如何编写返回 Promise 函数呢?在这种情况下,可以使用 Promise() 构造函数创建一个可以完全控制的新 Promise 对象。它是这样工作的:调用 Promise() 构造函数并将一个函数作为唯一实参传递。传递的函数应编写为包含两个参数,按照惯例,应将其命名为 resolve 和 reject。构造函数会同步调用使用 resolve 和 reject 参数的函数。调用函数后,Promise() 构造函数将返回新创建的 Promise。返回的 Promise 受传递给构造函数的函数的控制。该函数应该执行一些异步操作,然后调用 resolve 函数来决议或兑现返回的 Promise,或者调用 reject 函数来拒绝返回的 Promise。函数不必是异步的:它可以同步地调用 resolve 或拒绝,但是如果这样做,Promise 仍将被异步地决议,兑现或拒绝。

仅仅阅读一下很难理解传递给构造函数的函数,但是希望有一些例子可以使这一点变得清楚。这是在本章前面的各种示例中使用的基于 Promise 的 wait() 函数的编写方法:

function wait(duration) {
    // Create and return a new Promise
    return new Promise((resolve, reject) => { // These control the Promise
        // If the argument is invalid, reject the Promise
        if (duration < 0) {
            reject(new Error("Time travel not yet implemented"));
        }
        // Otherwise, wait asynchronously and then resolve the Promise.
        // setTimeout will invoke resolve() with no arguments, which means
        // that the Promise will fulfill with the undefined value.
        setTimeout(resolve, duration);
    });
}

请注意,用于控制由 Promise() 构造函数创建的 Promise 命运的一对函数分别命名为 resolve() 和 reject(),而不是 fulfill() 和 reject()。如果传递一个 Promise 给 resolve(),则返回的 Promise 将决议于该新的 Promise。但是,通常会传递一个非 Promise 值,返回的 Promise 会兑现这个值。

示例 13-1 是使用 Promise() 构造函数的另一个示例。这一节实现了 Node 中在未内置 fetch() API 使用的 getJSON() 函数。请记住,本章开始时讨论了异步回调和事件。此示例同时使用了回调和事件处理程序,因此很好地演示了如何在其他风格的异步编程之上实现基于 Promise 的 API。

示例 13-1 异步 getJSON() 函数

const http = require("http");

function getJSON(url) {
    // Create and return a new Promise
    return new Promise((resolve, reject) => {
        // Start an HTTP GET request for the specified URL
        request = http.get(url, response => { // called when response starts
            // Reject the Promise if the HTTP status is wrong
            if (response.statusCode !== 200) {
                reject(new Error(`HTTP status ${response.statusCode}`));
                response.resume();  // so we don't leak memory
            }
            // And reject if the response headers are wrong
            else if (response.headers["content-type"] !== "application/json") {
                reject(new Error("Invalid content-type"));
                response.resume();  // don't leak memory
            }
            else {
                // Otherwise, register events to read the body of the response
                let body = "";
                response.setEncoding("utf-8");
                response.on("data", chunk => { body += chunk; });
                response.on("end", () => {
                    // When the response body is complete, try to parse it
                    try {
                        let parsed = JSON.parse(body);
                        // If it parsed successfully, fulfill the Promise
                        resolve(parsed);
                    } catch(e) {
                        // If parsing failed, reject the Promise
                        reject(e);
                    }
                });
            }
        });
        // We also reject the Promise if the request fails before we
        // even get a response (such as when the network is down)
        request.on("error", error => {
            reject(error);
        });
    });
}

13.2.7 连续的 Promise

Promise.all() 使得并行运行任意数量的 Promise 变得容易。Promise 链使表达固定数量的 Promise 序列变得容易。但是,依次执行任意数量的 Promise 会比较棘手。例如,假设您要获取一组 URL,但是为了避免网络过载,希望一次获取一个 URL。如果数组的长度是任意的且内容未知,则无法提前写出 Promise 链,因此需要使用以下代码动态构建一个:

function fetchSequentially(urls) {
    // We'll store the URL bodies here as we fetch them
    const bodies = [];

    // Here's a Promise-returning function that fetches one body
    function fetchOne(url) {
        return fetch(url)
            .then(response => response.text())
            .then(body => {
                // We save the body to the array, and we're purposely
                // omitting a return value here (returning undefined)
                bodies.push(body);
            });
    }

    // Start with a Promise that will fulfill right away (with value undefined)
    let p = Promise.resolve(undefined);

    // Now loop through the desired URLs, building a Promise chain
    // of arbitrary length, fetching one URL at each stage of the chain
    for(url of urls) {
        p = p.then(() => fetchOne(url));
    }

    // When the last Promise in that chain is fulfilled, then the
    // bodies array is ready. So let's return a Promise for that
    // bodies array. Note that we don't include any error handlers:
    // we want to allow errors to propagate to the caller.
    return p.then(() => bodies);
}

定义了这个 fetchSequentially() 函数之后,我们可以用一个代码一次来获取多个 URL,就像我们之前用来演示 Promise.all() 的并行获取代码一样:

fetchSequentially(urls)
    .then(bodies => { /* do something with the array of strings */ })
    .catch(e => console.error(e));

fetchSequentially() 函数首先创建一个 Promise,该 Promise 将在返回后立即兑现。然后,它从该初始 Promise 构建一个长的线性 Promise 链,并返回链中的最后一个 Promise。这就像设置一排多米诺骨牌,然后将第一个多米诺骨牌撞倒一样。

我们可以采用另一种方法(可能更优雅)。除了提前创建 Promise 外,我们还可以为每个 Promise 创建回调,并返回下一个 Promise。也就是说,我们没有创建和链接一堆 Promise,而是创建了决议于其他承诺的承诺。与其创建类似多米诺骨牌的 Promise 链,不如创建一系列彼此嵌套在一起的 Promise 序列,就像俄罗斯套娃一样。使用这种方法,我们的代码可以知道第一个(最外面的)Promise 最终将兑现(或拒绝!),使其返回序列中最后一个(最里面的)Promise 相同的值。后面是通用的 promiseSequence() 函数,并不特定于 URL 提取。因为它很复杂,所以将它放在我们对 Promise 的讨论的结尾。但是,如果仔细阅读了本章,希望能理解它的工作原理。特别要注意的是,promiseSequence() 中的嵌套函数类似递归地调用自身,但是由于“递归”调用是通过 then() 方法进行的,因此实际上没有发生任何传统的递归:

// This function takes an array of input values and a "promiseMaker" function.
// For any input value x in the array, promiseMaker(x) should return a Promise
// that will fulfill to an output value. This function returns a Promise
// that fulfills to an array of the computed output values.
//
// Rather than creating the Promises all at once and letting them run in
// parallel, however, promiseSequence() only runs one Promise at a time
// and does not call promiseMaker() for a value until the previous Promise
// has fulfilled.
function promiseSequence(inputs, promiseMaker) {
    // Make a private copy of the array that we can modify
    inputs = [...inputs];

    // Here's the function that we'll use as a Promise callback
    // This is the pseudorecursive magic that makes this all work.
    function handleNextInput(outputs) {
        if (inputs.length === 0) {
            // If there are no more inputs left, then return the array
            // of outputs, finally fulfilling this Promise and all the
            // previous resolved-but-not-fulfilled Promises.
            return outputs;
        } else {
            // If there are still input values to process, then we'll
            // return a Promise object, resolving the current Promise
            // with the future value from a new Promise.
            let nextInput = inputs.shift(); // Get the next input value,
            return promiseMaker(nextInput)  // compute the next output value,
                // Then create a new outputs array with the new output value
                .then(output => outputs.concat(output))
                // Then "recurse", passing the new, longer, outputs array
                .then(handleNextInput);
        }
    }

    // Start with a Promise that fulfills to an empty array and use
    // the function above as its callback.
    return Promise.resolve([]).then(handleNextInput);
}

promiseSequence() 函数是通用的。我们可以使用它通过以下代码来获取 URL:

// Given a URL, return a Promise that fulfills to the URL body text
function fetchBody(url) { return fetch(url).then(r => r.text()); }
// Use it to sequentially fetch a bunch of URL bodies
promiseSequence(urls, fetchBody)
    .then(bodies => { /* do something with the array of strings */ })
    .catch(console.error);

13.3 async 和 await

ES2017 引入了两个新的关键字(async 和 await)描述异步 JavaScript 编程中的模式转变。这些新关键字极大地简化了 Promises 的使用,使我们能够编写基于 Promise 的异步代码看起来像是等待网络响应或其他异步事件而阻塞的同步代码。尽管了解 Promises 的工作原理仍然很重要,但是当将它们与 async 和 await 一起使用时,它们的大部分复杂性(有时甚至是它们的存在!)就消失了。

如本章前面所述,异步代码无法像常规同步代码那样返回值或引发异常。这就是为什么 Promise 如此设计的原因。已兑现的 Promise 的值类似于同步函数的返回值。而且已拒绝的 Promise 的值就像同步函数抛出的值。后者通过类似的 .catch() 方法命名,使得表述更清晰。async 和 await 使用高效的、基于 Promise 的代码并隐藏 Promise,以便异步代码可以像低效、阻塞、同步代码一样容易阅读和推理。

13.3.1 await 表达式

关键字 await 接受一个 Promise,并将其转换为返回值或引发的异常。给定一个 Promise 对象 p,表达式 await p 等待直到 p 敲定。如果 p 兑现,则等待 p 的值就是 p 的兑现值。另一方面,如果 p 被拒绝,则 await p 表达式将抛出 p 的拒绝值。我们通常不将 await 与保存 Promise 的变量一起使用;相反,我们在调用返回 Promise 的函数之前使用它:

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

立即了解至关重要的一点是,在指定的 Promise 敲定之前,await 关键字不会导致程序阻塞,并且实际上什么也不做。代码保持异步,并且 await 只是掩盖了这一事实。这意味着使用 await 的任何代码本身都是异步的。

13.3.2 async 函数

因为任何使用 await 的代码都是异步的,所以有一个关键规则:只能在使用 async 关键字声明的函数中使用 await 关键字。下面是本章前面的 getHighScore() 函数的一个版本,使用 async 和 await 重写:

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

异步声明函数意味着函数的返回值将是一个 Promise,即使函数体中没有出现与 Promise 相关的代码。如果异步函数看起来正常返回,那么作为函数实际返回值的 Promise 对象将决议为该返回值。如果一个异步函数出现抛出异常,那么它返回的 Promise 对象将被那个异常拒绝。

getHighScore() 函数被声明为异步,因此它返回一个承诺。因为它返回一个承诺,所以我们可以使用 await 关键字:

displayHighScore(await getHighScore());

但是请记住,只有在另一个异步函数中,该行代码才有效!可以根据需要在异步函数中嵌套任何层 await 表达式。但是,如果处于最高级别 2 或由于某种原因而处于不异步的函数内,那么您无法使用 await 并且必须以常规方式处理返回的 Promise:

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

可以将 async 关键字与任何函数一起使用。它用作于 function 关键字作为语句或表达式。它可与箭头函数以及类和对象字面量中的速记方法方式一起使用。(有关如何编写函数的各种方法,请参见第 8 章。)

13.3.3 等待多个 Promise

假设我们已经使用 async 编写了 getJSON() 函数:

async function getJSON(url) {
    let response = await fetch(url);
    let body = await response.json();
    return body;
}

并且现在假设我们要用这个方法获取两个 JSON 值:

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

此代码的问题在于,它不必要地是连续的:第二个 URL 的获取要等到第一次获取完成后才能开始。如果第二个 URL 不依赖于从第一个 URL 获得的值,那么我们可能应该尝试同时获取两个值。这是基于 Promise 的异步函数本质的一种情况。为了等待一组并发执行的异步函数,我们使用 Promise.all() 就像直接使用 Promise 一样:

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

13.3.4 实现详细

最后,为了了解异步功能是如何工作的,考虑一下幕后发生了什么可能会有所帮助。

假设写这样的一个异步函数:

async function f(x) { /* body */ }

可以将其视为包装原始函数主体的 Promise 返回函数:

function f(x) {
    return new Promise(function(resolve, reject) {
        try {
            resolve((function(x) { /* body */ })(x));
        }
        catch(e) {
            reject(e);
        }
    });
}

用像这样的语法转换来表达 await 关键字比较困难。但是,将 await 关键字视为将函数主体分解为单独的同步块的标记。ES2017 解释器可以将函数主体分解为一系列单独的子函数,每个子函数都传递给位于其前面 await 标记的 Promise 的 then() 方法。

13.4 异步迭代

在本章的开头我们讨论了基于回调和基于事件的异步,当我们介绍 Promise 时,我们注意到它们对于单次异步计算很有用,但不适用于重复性异步事件的代码,例如 setInterval(),网络浏览器中的“click”事件或 Node 流上的“data”事件。因为单个 Promise 不适用于异步事件序列,所以我们也不能对这些事物使用常规的异步函数和 await 语句。

但是 ES2018 提供了一个解决方案。异步迭代器类似于第 12 章中描述的迭代器,但是它们基于 Promise,并且打算与 for/of 循环一起使用的新形式:for/await。

13.4.1 for/await 循环

Node 12 使其可读流可以异步迭代。这意味着可以使用如下所示的 for/await 循环从流中读取连续的数据块:

const fs = require("fs");

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

像普通的 await 表达式一样,for/await 循环是基于 promise 的。粗略地说,异步迭代器产生一个 Promise,for/await 循环等待该 Promise 兑现,将兑现值分配给循环变量,然后运行循环的主体。然后重新开始,从迭代器中获得另一个 Promise,然后等待该新 Promise 兑现。

假设有一个 URL 数组:

const urls = [url1, url2, url3];

可以在每个 URL 上调用 fetch() 以获取一个 promise 数组:

const promises = urls.map(url => fetch(url));

我们在本章的前面已经看到,我们现在可以使用 Promise.all() 等待数组中的所有 promise 都已兌現。但是,假设我们希望第一个提取的结果尽快可用,并且不想等待所有 URL 都被获取。(当然,第一次获取可能比其他任何获取都要花费更长的时间,因此不一定比使用 Promise.all() 更快。)数组是可迭代的,因此我们可以使用常规的 for/of 对数组进行遍历:

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

此示例代码使用 for/of 循环遍历常规迭代器。但是因为此迭代器返回 Promise,所以我们还可以将新的 for/await 稍微简化下代码:

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

在这种情况下,for/await 循环仅将 await 调用构建到循环中,并使我们的代码稍微紧凑一些,但是两个示例的作用完全相同。重要的是,这两个示例只有在声明为异步的函数中时才起作用。for/await 循环与常规 await 表达式没有什么不同。

但是,重要的是要意识到,在此示例中,我们正在使用 for/wait 作用于常规迭代器的。作用于完全异步的迭代器使事情变得更加有趣。

13.4.2 异步迭代器

让我们回顾一下第 12 章中的一些术语。可迭代对象是可以与 for/of 循环一起使用的对象。它定义了一个名称为 Symbol.iterator 的方法。此方法返回一个迭代器对象。迭代器对象具有 next() 方法,可以重复调用该方法以获得可迭代对象的值。迭代器对象的 next() 方法返回迭代结果对象。迭代结果对象具有 value 属性和或或 done 属性。

异步迭代器与常规迭代器非常相似,但是有两个重要的区别。 首先,一个异步可迭代对象以符号名称 Symbol.asyncIterator 而不是 Symbol.iterator 实现一个方法。(如前所述,for/await 与常规可迭代对象兼容,但是它更喜欢异步可迭代对象,并在尝试 Symbol.iterator 方法之前先尝试使用 Symbol.asyncIterator 方法。)其次,异步迭代器的 next() 方法返回解析为迭代器结果对象的 Promise,而不是直接返回迭代器结果对象。

注意

在上一节中,当我们在常规的、同步可迭代的 Promise 数组上使用 for/await 时,我们正在使用同步迭代器结果对象,其中 value 属性是 Promise 对象,但 done 属性是同步的。真正的异步迭代器为迭代结果对象返回 Promise,并且 value 和 done 属性都是异步的。这是一个微妙的区别:使用异步迭代器,可以异步选择何时结束迭代。

13.4.3 异步生成器

正如我们在第 12 章中看到的那样,实现迭代器的最简单方法通常是使用生成器。异步迭代器也是如此,我们可以使用声明为异步的生成器函数来实现。异步生成器具有异步特性和生成器特性:可以像在常规异步函数中一样使用 await,并且可以像在常规生成器中一样使用 yield。但是,产生的值会自动包装在 Promise 中。甚至异步生成器的语法也是一个组合:异步函数和 function* 组合为 async function*。这是一个示例,描述如何使用异步生成器和 for/await 循环代替 setInterval() 回调函数以固定的间隔重复运行代码:

// A Promise-based wrapper around setTimeout() that we can use await with.
// Returns a Promise that fulfills in the specified number of milliseconds
function elapsedTime(ms) {
    return new Promise(resolve => setTimeout(resolve, ms));
}

// An async generator function that increments a counter and yields it
// a specified (or infinite) number of times at a specified interval.
async function* clock(interval, max=Infinity) {
    for(let count = 1; count <= max; count++) { // regular for loop
        await elapsedTime(interval);            // wait for time to pass
        yield count;                            // yield the counter
    }
}

// A test function that uses the async generator with for/await
async function test() {                       // Async so we can use for/await
    for await (let tick of clock(300, 100)) { // Loop 100 times every 300ms
        console.log(tick);
    }
}

13.4.4 实现异步迭代器

除了使用异步生成器来实现异步迭代器外,还可以通过使用 Symbol.asyncIterator() 方法定义一个对象来直接实现它们,而 Symbol.asyncIterator() 方法将返回一个对象,而 next() 方法将返回一个决议为迭代器结果对象的 Promise。在下面的代码中,我们重新实现了上一个示例中的 clock() 函数,因此它不是生成器,而仅是返回一个异步可迭代的对象。请注意,此示例中的 next() 方法未明确返回 Promise;相反,我们只声明 next() 是异步的:

function clock(interval, max=Infinity) {
    // A Promise-ified version of setTimeout that we can use await with.
    // Note that this takes an absolute time instead of an interval.
    function until(time) {
        return new Promise(resolve => setTimeout(resolve, time - Date.now()));
    }

    // Return an asynchronously iterable object
    return {
        startTime: Date.now(),  // Remember when we started
        count: 1,               // Remember which iteration we're on
        async next() {          // The next() method makes this an iterator
            if (this.count > max) {     // Are we done?
                return { done: true };  // Iteration result indicating done
            }
            // Figure out when the next iteration should begin,
            let targetTime = this.startTime + this.count * interval;
            // wait until that time,
            await until(targetTime);
            // and return the count value in an iteration result object.
            return { value: this.count++ };
        },
        // This method means that this iterator object is also an iterable.
        [Symbol.asyncIterator]() { return this; }
    };
}

这个基于迭代器版本的 clock() 函数修复了基于生成器版本中的一个缺陷。请注意,在此代码中,我们在每次迭代开始时设置了绝对目标时间,并将其与当前时间的差值作为间隔传递给 setTimeout()。如果我们将 clock() 与 for/await 循环一起使用,则此版本将在指定的时间间隔内更精确地运行循环迭代,因为它考虑了实际运行循环主体所需的时间。但是,此修补程序不仅涉及定时精度。for/await 循环始终在开始下一次迭代之前等待一次迭代返回的 Promise 被兑现。但是,如果使用不带 for/await 循环的异步迭代器,则没有什么可以阻止在需要时调用 next() 方法。使用基于生成器的 clock() 版本,如果依次调用 next() 方法三遍,将获得三个 Promise,这些 Promise 几乎都在同一时间完成,这可能不是想要的结果。我们在这里实现的基于迭代器的版本没有这个问题。

异步迭代器的好处在于,它们允许我们表示异步事件或数据流。前面讨论的 clock() 函数编写起来非常简单,因为异步源是我们自己进行的 setTimeout() 调用。但是,当我们尝试与其他异步源一起使用时,例如事件处理程序的触发,实现异步迭代器的难度将大大提高————我们通常只有一个事件处理程序函数来响应事件,但是每次调用迭代器的 next() 方法一定返回一个不同的 Promise 对象,并且在第一个 Promise 决议之前,可能会多次调用 next()。这意味着,任何异步迭代器方法都必须能够维护一个内部的 Promise 队列,该队列将在响应异步事件时按顺序进行决议。如果我们将此 Promise 有序列的行为封装到 AsyncQueue 类中,那么基于 AsyncQueue 编写异步迭代器将变得更加容易。 3

正如期望的那样,后面的 AsyncQueue 类具有 enqueue() 和 dequeue() 方法。但是,dequeue() 方法返回 Promise 而不是实际值,这意味着可以在调用 enqueue() 之前调用 dequeue()。AsyncQueue 类也是一个异步迭代器,旨在与 for/await 循环一起使用,该循环的主体在每次将新值异步排队时都运行一次。(AsyncQueue 有一个 close() 方法。一旦被调用,就不能将更多的值加入队列。当关闭的队列为空时,for/await 循环将停止遍历。)

请注意,AsyncQueue 的实现不使用异步或等待,而是直接与 Promise 一起使用。该代码有些复杂,可以使用它来测试对本长篇文章所涉及内容的理解。即使您不完全了解 AsyncQueue 的实现,也请看一下它后面的简短示例:它在 AsyncQueue 之上实现了一个简单但非常有趣的异步迭代器。

/**
 * An asynchronously iterable queue class. Add values with enqueue()
 * and remove them with dequeue(). dequeue() returns a Promise, which
 * means that values can be dequeued before they are enqueued. The
 * class implements [Symbol.asyncIterator] and next() so that it can
 * be used with the for/await loop (which will not terminate until
 * the close() method is called.)
 */
class AsyncQueue {
    constructor() {
        // Values that have been queued but not dequeued yet are stored here
        this.values = [];
        // When Promises are dequeued before their corresponding values are
        // queued, the resolve methods for those Promises are stored here.
        this.resolvers = [];
        // Once closed, no more values can be enqueued, and no more unfulfilled
        // Promises returned.
        this.closed = false;
    }

    enqueue(value) {
        if (this.closed) {
            throw new Error("AsyncQueue closed");
        }
        if (this.resolvers.length > 0) {
            // If this value has already been promised, resolve that Promise
            const resolve = this.resolvers.shift();
            resolve(value);
        }
        else {
            // Otherwise, queue it up
            this.values.push(value);
        }
    }

    dequeue() {
        if (this.values.length > 0) {
            // If there is a queued value, return a resolved Promise for it
            const value = this.values.shift();
            return Promise.resolve(value);
        }
        else if (this.closed) {
            // If no queued values and we're closed, return a resolved
            // Promise for the "end-of-stream" marker
            return Promise.resolve(AsyncQueue.EOS);
        }
        else {
            // Otherwise, return an unresolved Promise,
            // queuing the resolver function for later use
            return new Promise((resolve) => { this.resolvers.push(resolve); });
        }
    }

    close() {
        // Once the queue is closed, no more values will be enqueued.
        // So resolve any pending Promises with the end-of-stream marker
        while(this.resolvers.length > 0) {
            this.resolvers.shift()(AsyncQueue.EOS);
        }
        this.closed = true;
    }

    // Define the method that makes this class asynchronously iterable
    [Symbol.asyncIterator]() { return this; }

    // Define the method that makes this an asynchronous iterator. The
    // dequeue() Promise resolves to a value or the EOS sentinel if we're
    // closed. Here, we need to return a Promise that resolves to an
    // iterator result object.
    next() {
        return this.dequeue().then(value => (value === AsyncQueue.EOS)
                                   ? { value: undefined, done: true }
                                   : { value: value, done: false });
    }
}

// A sentinel value returned by dequeue() to mark "end of stream" when closed
AsyncQueue.EOS = Symbol("end-of-stream");

因为 AsyncQueue 类定义了异步迭代基础,所以我们可以简单地通过异步排队值来创建更有趣的自定义异步迭代器。这是一个使用 AsyncQueue 生成可通过 for/await 循环处理的 web 浏览器事件流的示例:

// Push events of the specified type on the specified document element
// onto an AsyncQueue object, and return the queue for use as an event stream
function eventStream(elt, type) {
    const q = new AsyncQueue();                  // Create a queue
    elt.addEventListener(type, e=>q.enqueue(e)); // Enqueue events
    return q;
}

async function handleKeys() {
    // Get a stream of keypress events and loop once for each one
    for await (const event of eventStream(document, "keypress")) {
        console.log(event.key);
    }
}

13.5 Summary

在本章中,您学习了:

大多数真实的 JavaScript 程序都是异步的。

传统上,异步是通过事件和回调函数来处理的。但是,这可能会变得复杂,因为最终可能会嵌套在其他回调中嵌套的多个级别的回调,并且因为很难进行可靠的异常处理。

Promise 提供了构造回调函数的新方法。如果正确使用(不幸的是,Promise 易于错误使用),它们可以将异步代码转换为嵌套在调用 then() 的线性链中的代码,一个计算的异步步骤跟随在其他之后。而且,Promise 允许将异常处理代码集中到一个 then() 调用链末尾的单个 catch() 调用中。

async 和 await 关键字允许我们编写异步代码,该代码基于 Promise,但看起来像同步代码。这使代码更易于理解和推理。如果一个函数被声明为 async,它将隐式返回一个 Promise。在异步函数内部,可以 await Promise(或返回 Promise 的函数),就像 Promise 值是同步计算的一样。

异步可迭代的对象可以与 for/await 循环一起使用。可以通过实现 [Symbol.asyncIterator]() 方法或调用 async function* 生成器函数来创建异步可迭代对象。异步迭代器为 Node 中流的“data”事件提供了一种替代方法,可用于表示客户端 JavaScript 中的用户输入事件的流。


  1. XMLHttpRequest 类与 XML 无关。在现代的客户端 JavaScript 中,它已被 fetch() API 取代,该 API 已在 §15.11.1 中进行了介绍。此处显示的代码示例是本书中最后一个基于 XMLHttpRequest 的示例。  

  2. 通常,可以在浏览器的开发人员控制台的顶层使用 await。还有一个悬而未决的建议,允许在将来的 JavaScript 版本中进行顶级 await。  

  3. 我从 Dr. Axel Rauschmayer 的博客https://2ality.com中了解了这种异步迭代方法。