Promises

Promise 是 ECMAScript 2015 标准(或 ES6,这就是为什么它们也被称为 ES6 Promise)的一部分,并且从版本 4 开始就在 Node.js 中原生可用。但是 Promise 的历史可以追溯到几年前,当时周围有数十种实现,最初具有不同的功能和行为。 最终,大多数实现都采用了名为 Promises/A+ 的标准。

Promise 代表着在为传播异步结果提供连续传递样式回调的稳健替代方案方面向前迈出了一大步。 正如我们将看到的,与基于回调的替代方案相比,promise 的使用将使所有主要的异步控制流结构更易于阅读、更简洁、更健壮。

什么是 promise?

Promise 是一个体现异步操作的最终结果(或错误)的对象。 用 Promise 术语来说,当异步操作尚未完成时,我们说 Promise 处于待处理状态;当操作成功完成时,Promise 被履行;当操作因错误而终止时,我们说 Promise 被拒绝。 一旦 Promise 被履行或被拒绝,它就被视为已解决。

要接收与拒绝相关的履行值或错误(原因),我们可以使用 Promise 实例的 then() 方法。 以下是其签名:

promise.then(onFulfilled, onRejected)

在前面的签名中,onFulfilled 是一个回调,最终将接收 Promise 的履行值,而 onRejected 是另一个回调,它将接收拒绝原因(如果有)。 两者都是可选的。

为了了解 Promise 如何转换我们的代码,让我们考虑以下基于回调的代码:

asyncOperation(arg, (err, result) => {
    if(err) {
        // handle the error
    }
    // do stuff with the result
})

Promise 允许我们将这种典型的延续传递风格的代码转换为结构更好、更优雅的代码,如下所示:

asyncOperationPromise(arg)
    .then(result => {
        // do stuff with result
    }, err => {
        // handle the error
    }

在上面的代码中,asyncOperationPromise() 返回一个 Promise,然后我们可以使用它来接收函数最终结果的履行值或拒绝原因。 到目前为止,似乎没有什么大的事情发生,但是 then() 方法的一个关键属性是它同步返回另一个 Promise。

此外,如果任何 onFulfilled 或 onRejected 函数返回值 x,则 then() 方法返回的 Promise 将:

  • 如果 x 是一个值,则 fulfill 为 x

  • 如果 x 是 Promise,则 fulfill 为 x 的 fulfill 值

  • 如果 x 是 Reject Promise,则 Rejected 为 x 的 Rejected 值

通过这种行为,我们可以构建许诺链,从而轻松地将异步操作聚合和排列成多个配置。此外,如果我们没有指定 onFulfilled 或 onRejected 处理程序,则履行值或拒绝原因会自动转发给链中的下一个承诺。例如,这样我们就可以在整个链中自动传播错误,直到被 onRejected 处理程序捕获。有了 promise 链,任务的顺序执行突然变得轻而易举:

asyncOperationPromise(arg)
    .then(result1 => {
        // returns another promise
        return asyncOperationPromise(arg2)
    })
    .then(result2 => {
        // returns a value
        return 'done'
    })
    .then(undefined, err => {
        // any error in the chain is caught here
    }

下图提供了 Promise 链如何工作的另一个视角:

image 2024 05 06 18 16 20 376
Figure 1. 图 5.1:Promise 链执行流程

图 5.1 显示了我们使用 promise 链时的程序流程。当我们在 Promise A 上调用 then() 时,会同步接收到 Promise B 的结果;当我们在 Promise B 上调用 then() 时,会同步接收到 Promise C 的结果。最终,当 Promise A 确定后,它将履行或拒绝,这将分别导致调用 onFulfilled() 或 onRejected() 回调。执行回调的结果将使 Promise B 履行或拒绝履行,而这一结果又会传递给 Promise B 的 then() 调用所传递的 onFulfilled() 或 onRejected() 回调。

Promise 的一个重要特性是,即使我们同步解析 Promise 的值,也会保证异步调用 onFulfilled() 和 onRejected() 回调,且最多调用一次。不仅如此,即使在调用 then() 时 Promise 对象已经完成,也会异步调用 onFulfilled() 和 onRejected() 回调。这种行为使我们的代码避免了所有可能无意中释放 Zalgo 的情况(参见第 3 章,回调和事件),从而使我们的异步代码更加一致和健壮,而无需付出额外的努力。

现在是最精彩的部分。如果在 onFulfilled() 或 onRejected() 处理程序中抛出异常(使用 throw 语句),那么 then() 方法返回的 Promise 将自动拒绝,并将抛出的异常作为拒绝原因。与 CPS 相比,这是一个巨大的优势,因为这意味着使用 Promise 时,异常会自动在链中传播,throw 语句也变得最终可用。

Promise/A+ 和 thenables

从历史上看,Promises 有很多不同的实现,其中大部分都不兼容,这意味着无法在来自使用不同 Promise 实现的库的 Promise 对象之间创建链。

JavaScript 社区为解决这一限制付出了巨大努力,并最终制定了 Promises/A+ 规范。该规范详细说明了 then() 方法的行为,提供了一个可互操作的基础,使得来自不同库的 Promise 对象可以开箱即用。如今,大多数 Promise 实现都使用了这一标准,包括 JavaScript 和 Node.js 的原生 Promise 对象。

关于 Promises/A+ 规范的详细概述,可以参考官方网站:nodejsdp.link/promises-aplus。

由于采用了 Promises/A+ 标准,许多 Promise 实现(包括原生 JavaScript Promise API)都会将任何具有 then() 方法的对象视为类似 Promise 的对象,也称为 thenable。 这种行为允许不同的 Promise 实现彼此无缝交互。

根据对象的外部行为而不是其实际类型来识别(或键入)对象的技术称为鸭子类型,广泛用于 JavaScript 中。

promise API

现在让我们快速浏览一下原生 JavaScript Promise 的 API。 这只是一个概述,让您了解我们可以用 Promise 做什么,所以如果现在事情还不太清楚,请不要担心; 我们将有机会在整本书中使用大部分 API。

Promise 构造函数 (new Promiseresolve,reject) ⇒ {} 创建一个新的 Promise 实例,该实例根据作为参数提供的函数的行为来满足或拒绝。 提供给构造函数的函数将接收两个参数:

  • resolve(obj):这是一个函数,在调用时,将使用提供的履行值来履行 Promise,如果 obj 是一个值,则该值将为 obj。 如果 obj 是 Promise 或 thenable,那么它将是 obj 的 fulfillment。

  • reject(err):这会用 err 原因拒绝该 Promise。约定俗成地,err 通常是 Error 的一个实例。

现在,我们来看看 Promise 对象最重要的静态方法:

  • Promise.resolve(obj):此方法从另一个 Promise、thenable 或值创建一个新的 Promise。 如果 Promise 被传递,那么该 Promise 将按原样返回。 如果提供了 thenable,那么它将转换为正在使用的 Promise 实现。 如果提供了一个值,那么 Promise 将用该值来实现。

  • Promise.reject(err):此方法创建一个 Promise,该 Promise 会以 err 为原因拒绝。

  • Promise.all(iterable):此方法创建一个 Promise,当输入可迭代对象(例如数组)中的每个项目都满足时,该 Promise 会满足一个满足值数组。 如果可迭代对象中的任何 Promise 拒绝,则 Promise.all() 返回的 Promise 将拒绝并显示第一个拒绝原因。 可迭代对象中的每一项都可以是 Promise、通用 thenable 或值。

  • Promise.allSettled(iterable):此方法会等待所有输入的 Promise 履行或拒绝,然后返回一个对象数组,其中包含每个输入 Promise 的履行值或拒绝原因。每个输出对象都有一个 status 属性(可以等于 “已履行” 或 “已拒绝”),以及一个包含履行值的 value 属性或一个包含拒绝原因的 reason 属性。与 Promise.all() 不同的是,Promise.allSettled() 会一直等待每个 Promise 实现或拒绝,而不是在其中一个 Promise 拒绝时立即拒绝。

  • Promise.race(iterable):此方法返回一个 Promise,该 Promise 相当于 iterable 中第一个已解决的 Promise。

最后,以下是 Promise 实例上可用的主要方法:

  • Promise.then(onFulfilled, onRejected):这是 Promise 的基本方法。 它的行为与我们之前提到的 Promises/A+ 标准兼容。

  • Promise.catch(onRejected):这个方法只是 promise.then(undefined, onRejected) 的语法糖(nodejsdp.link/syntropic-sugar)。

  • Promise.finally(onFinally):此方法允许我们设置一个 onFinally 回调,当 Promise 被解决(履行或拒绝)时调用该回调。 与 onFulfilled 和 onRejected 不同,onFinally 回调不会接收任何参数作为输入,并且从它返回的任何值都将被忽略。 finally 返回的 Promise 将以与当前 Promise 实例相同的履行值或拒绝原因进行结算(settle)。 所有这一切只有一次例外,就是我们在 onFinally 回调中抛出异常或返回被拒绝的 Promise 的情况。 在这种情况下,返回的 Promise 将被拒绝,并返回抛出的错误或被拒绝的 Promise 的拒绝原因。

现在让我们看一个示例,了解如何使用 Promise 的构造函数从头开始创建 Promise。

创造一个 promise

现在让我们看看如何使用 Promise 的构造函数创建 Promise。 从头开始创建 Promise 是一个低级操作,当我们需要转换使用另一种异步风格(例如基于回调的风格)的 API 时通常需要它。 大多数时候,我们(作为开发人员)是其他库生成的 Promise 的消费者,并且我们创建的大多数 Promise 将来自 then() 方法。 尽管如此,在一些高级场景中,我们需要使用其构造函数手动创建 Promise。

为了演示如何使用 Promise 构造函数,让我们创建一个函数,该函数返回一个 Promise,该 Promise 在指定的毫秒数后满足当前日期。 我们来看一下:

function delay (milliseconds) {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            resolve(new Date())
        }, milliseconds)
    })
}

正如您可能已经猜到的,我们使用 setTimeout() 来调用 Promise 构造函数的resolve() 函数。 我们可以注意到 Promise 构造函数如何包装整个函数体; 这是从头开始创建 Promise 时会看到的常见代码模式。

我们刚刚创建的 delay() 函数可以与如下代码一起使用:

console.log(`Delaying...${new Date().getSeconds()}s`)
delay(1000)
    .then(newDate => {
        console.log(`Done ${newDate.getSeconds()}s`)
    })

then() 处理程序中的 console.log() 将在调用 delay() 后大约 1 秒后执行。

Promises/A+ 规范规定 then() 方法的 onFulfilled 和 onRejected 回调必须仅被调用一次并且是独占的(仅调用其中之一)。 合规的 Promise 实现可确保即使我们多次调用 resolve 或 reject,Promise 也只会被实现或被拒绝一次。

承诺化

当提前知道基于回调的函数的某些特征时,就可以创建一个函数,将这种基于回调的函数转换为返回 Promise 的等效函数。 这种转变称为承诺化。

例如,让我们考虑 Node.js 风格的基于回调的函数中使用的约定:

  • 回调是函数的最后一个参数

  • 错误(如果有)始终是传递给回调的第一个参数

  • 任何返回值都会在错误参数之后传递给回调函数

基于这些规则,我们可以轻松创建一个通用函数,该函数 promise Node.js 风格的基于回调的函数。 让我们看看这个函数是什么样的:

function promisify (callbackBasedApi) {
    return function promisified (...args) {
        return new Promise((resolve, reject) => { // (1)
            const newArgs = [
                ...args,
                function (err, result) { // (2)
                    if (err) {
                        return reject(err)
                    }
                    resolve(result)
                }
            ]
            callbackBasedApi(...newArgs) // (3)
        })
    }
}

前面的函数返回另一个函数 promisified(),它表示输入的 callbackBasedApi 的 promisified 版本。工作原理如下:

  1. promisified() 函数使用 Promise 构造函数创建一个新的 Promise,并立即将其返回给调用者。

  2. 在传递给 Promise 构造函数的函数中,我们确保传递给 callbackBasedApi 一个特殊的回调。 因为我们知道回调总是最后出现,所以我们只需将其附加到提供给 promisified() 函数的参数列表 (args) 中。 在特殊回调中,如果我们收到错误,我们立即拒绝 Promise; 否则,我们用给定的结果来 resolve 它。

  3. 最后,我们只需使用我们构建的参数列表调用 callbackBasedApi。

现在,让我们使用新创建的 promisify() 函数来承诺化 Node.js 函数。 我们可以使用核心加密(crypto)模块的 randomBytes() 函数,它生成一个包含指定数量的随机字节的缓冲区。 randomBytes() 函数接受回调作为最后一个参数,它遵循我们已经非常了解的约定。 让我们看看它是什么样子的:

import { randomBytes } from 'crypto'
const randomBytesP = promisify(randomBytes)
randomBytesP(32)
    .then(buffer => {
        console.log(`Random bytes: ${buffer.toString()}`)
    })

前面的代码应该在控制台上打印一些乱码;这是因为并非所有生成的字节都有相应的可打印字符。

我们在这里创建的承诺函数仅用于教育目的,它缺少一些功能,例如处理返回多个结果的回调的能力。 在现实生活中,我们将使用 util core 模块的 promisify() 函数来承诺化 Node.js 风格的基于回调的函数。 您可以在 nodejsdp.link/promisify 查看其文档。

顺序执行和迭代

现在我们已经了解了足够的知识,可以将我们在上一章中创建的网络蜘蛛应用程序转换为使用 Promise。 我们直接从版本 2 开始,即按顺序下载网页链接的版本。

我们可以通过 fs 模块的 Promise 对象访问已经 Promisified 版本的核心 fs API。 例如:import { promises } from 'fs'。

在 Spider.js 模块中,所需的第一步是导入我们的依赖项并承诺我们将要使用的任何基于回调的函数:

import { promises as fsPromises } from 'fs' // (1)
import { dirname } from 'path'
import superagent from 'superagent'
import mkdirp from 'mkdirp'
import { urlToFilename, getPageLinks } from './utils.js'
import { promisify } from 'util'

const mkdirpPromises = promisify(mkdirp) // (2)

与上一章的 Spider.js 模块相比,这里有两个主要区别:

  1. 我们导入 fs 模块的 Promise 对象来访问所有已 Promised 的 fs 函数。

  2. 我们手动承诺化 mkdirp() 函数。

现在,我们可以开始转换 download() 函数:

function download (url, filename) {
    console.log(`Downloading ${url}`)
    let content
    return superagent.get(url) // (1)
        .then((res) => {
            content = res.text // (2)
            return mkdirpPromises(dirname(filename))
        })
        .then(() => fsPromises.writeFile(filename, content))
        .then(() => {
            console.log(`Downloaded and saved: ${url}`)
            return content // (3)
        })
}

我们可以立即欣赏到使用 Promise 实现顺序异步操作的优雅。 我们只有一个干净且非常直观的 then() 调用链。

与之前版本的功能相比,这次我们利用了对 superagent 包的 Promise 的开箱即用支持。 我们不需要对 superagent.get() 返回的请求对象调用 end(),而是只需调用 then() 来发送请求 (1) 并接收一个满足/拒绝请求结果的 Promise。

download() 函数的最终返回值是由链中最后一个 then() 调用返回的 Promise,其中包含网页内容 (3),我们在第一个 then() 调用 (2) 的 onFulfilled 处理程序中对其进行了初始化。这样可以确保调用者只有在所有操作(get、mkdirp 和 writeFile)完成后,才会收到一个包含内容的 Promise。

在我们刚刚看到的 download() 函数中,我们按顺序执行了一组已知的异步操作。 然而,在 SpiderLinks() 函数中,我们必须处理一组动态异步任务的顺序迭代。 让我们看看如何实现这一目标:

function spiderLinks (currentUrl, content, nesting) {
    let promise = Promise.resolve() // (1)
    if (nesting === 0) {
        return promise
    }
    const links = getPageLinks(currentUrl, content)
    for (const link of links) {
        promise = promise.then(() => spider(link, nesting - 1)) // (2)
    }
    return promise
}

为了异步迭代网页的所有链接,我们必须动态构建一个 Promise 链,如下所示:

  1. 首先,我们定义一个“空” Promise,它解析为 undefined。 这个 Promise 被用作我们链的起点。

  2. 然后,在循环中,我们使用通过对链中前一个 Promise 调用 then() 获得的新 Promise 来更新 Promise 变量。 这实际上是我们使用 Promise 的异步迭代模式。

在 for 循环结束时,promise 变量将包含最后一次 then() 调用的 Promise,因此只有当链中的所有 Promise 都已解析时它才会解析。

模式(带有承诺的顺序迭代)

使用循环动态构建承诺链。

现在,我们终于可以转换 spider() 函数了:

export function spider (url, nesting) {
    const filename = urlToFilename(url)
    return fsPromises.readFile(filename, 'utf8')
        .catch((err) => {
            if (err.code !== 'ENOENT') {
                throw err
            }
            // The file doesn't exist, so let's download it
            return download(url, filename)
        })
        .then(content => spiderLinks(url, content, nesting))
}

在这个新的 Spider() 函数中,我们使用 catch() 来处理 readFile() 产生的任何错误。 特别是,如果错误代码为 “ENOENT”,则意味着该文件尚不存在,因此我们需要下载相应的 URL。 download() 返回的 Promise 如果满足,将返回 URL 处的内容。 另一方面,如果 readFile() 生成的 Promise 满足,它将跳过 catch() 处理程序并直接进入下一个 then()。 在这两种情况下,最后一次 then() 调用的 onFulfilled 处理程序将始终接收网页的内容,无论是来自本地文件还是来自新下载的文件。

现在我们也已经转换了 Spider() 函数,我们终于可以修改 Spider-cli.js 模块了:

spider(url, nesting)
    .then(() => console.log('Download complete'))
    .catch(err => console.error(err))

这里的 catch() 处理程序将拦截源自整个 Spider() 进程的任何错误。

如果我们再次查看迄今为止编写的所有代码,我们会惊喜地发现我们没有包含任何错误传播逻辑(就像使用回调时我们被迫这样做的那样)。 这显然是一个巨大的优势,因为它大大减少了代码中的样板以及遗漏任何异步错误的可能性。

这样就完成了带有 Promise 的网络蜘蛛应用程序版本 2 的实现。

带有 Promise 的顺序迭代模式的替代方案是使用 reduce() 函数,以实现更紧凑的实现:

const promise = tasks.reduce((prev, task) => {
    return prev.then(() => {
        return task()
    })
}, Promise.resolve())

并行执行

另一个对于 Promise 来说变得微不足道的执行流程是并行执行流程。 事实上,我们需要做的就是使用内置的 Promise.all() 方法。 此辅助函数创建另一个 Promise,仅当作为输入接收到的所有 Promise 都满足时,该 Promise 才会满足。 如果这些 Promise 之间不存在因果关系(例如,它们不属于同一个 Promise 链),那么它们将并行执行。

为了演示这一点,让我们考虑一下网络蜘蛛应用程序的版本 3,它并行下载页面的所有链接。 让我们再次更新 SpiderLinks() 函数,以使用 Promise 实现并行执行流程:

function spiderLinks (currentUrl, content, nesting) {
    if (nesting === 0) {
        return Promise.resolve()
    }

    const links = getPageLinks(currentUrl, content)
    const promises = links.map(link => spider(link, nesting - 1))

    return Promise.all(promises)
}

限制并行执行

到目前为止,承诺并没有辜负我们的期望。 我们能够极大地改进串行和并行执行的代码。 现在,由于并行执行有限,考虑到该流程只是串行和并行执行的组合,情况应该不会有太大不同。

在本节中,我们将直接实现一个解决方案,该解决方案允许我们全局限制网络蜘蛛任务的并发性。 换句话说,我们将在一个类中实现我们的解决方案,我们可以使用该类来实例化对象,然后将这些对象传递给同一应用程序的不同函数。 如果您只是对在本地限制一组任务的并行执行的简单解决方案感兴趣,您仍然可以应用我们将在本节中看到的相同原则来实现 Array.map() 的特殊异步版本。 我们将此留给您作为练习; 您可以在本章末尾找到更多详细信息和提示。

对于支持承诺和有限并发性的 map() 函数的即用型、生产就绪型实现,您可以依赖 p-map 包。 如需了解更多信息,请访问 nodejsdp.link/p-map。

使用 Promise 实现 TaskQueue 类

为了全局限制蜘蛛下载任务的并发性,我们将重用上一章中实现的 TaskQueue 类。 让我们从 next() 方法开始,在该方法中我们触发一组任务的执行,直到达到并发限制:

next () {
    while (this.running < this.concurrency && this.queue.length) {
        const task = this.queue.shift()
        task().finally(() => {
            this.running--
            this.next()
        })
        this.running++
    }
}

next() 方法的核心变化是我们调用 task() 的地方。 事实上,现在我们期望 task() 返回一个 Promise,所以我们所要做的就是在该 Promise 上调用 finally() ,这样我们就可以在任务完成或拒绝时重置正在运行的任务的计数。

现在,我们实现一个名为 runTask() 的新方法。 该方法负责对一个特殊的包装函数进行排队,并返回一个新构建的 Promise。 这样的 Promise 本质上会转发 task() 最终返回的 Promise 的结果(履行或拒绝)。 让我们看看这个方法是什么样的:

runTask (task) {
    return new Promise((resolve, reject) => { // (1)
        this.queue.push(() => { // (2)
            return task().then(resolve, reject) // (4)
        })
        process.nextTick(this.next.bind(this)) // (3)
    })
}

在我们刚刚看到的方法中:

  1. 我们使用其构造函数创建一个新的 Promise。

  2. 我们向任务队列添加一个特殊的包装函数。 当剩下足够的并发槽时,该函数将在稍后的 next() 运行时执行。

  3. 我们调用 next() 来触发一组新的任务运行。 我们将其推迟到事件循环的后续运行,以保证任务始终相对于调用 runTask() 时异步调用。 这可以防止我们在第 3 章回调和事件(例如 Zalgo)中描述的问题。 事实上,我们可以注意到,在 next() 方法中,在 finally() 处理程序中还有另一个对 next() 本身的调用,该调用始终是异步的。

  4. 当我们排队的包装函数最终运行时,我们执行作为输入接收到的任务,并将其结果(完成值或拒绝原因)转发到外部 Promise,即我们从 runTask() 方法返回的 Promise 。

至此,我们已经使用 Promise 完成了新 TaskQueue 类的实现。 接下来,我们将使用这个新版本的 TaskQueue 类来实现网络蜘蛛的版本 4。

更新 web 蜘蛛

现在是时候使用我们刚刚创建的 TaskQueue 类来调整我们的网络蜘蛛来实现有限的并行执行流了。

首先,我们需要将 spider() 函数拆分为两个函数,一个函数简单地初始化一个新的 TaskQueue 对象,另一个函数实际执行蜘蛛任务,我们将其称为 spiderTask()。 然后,我们需要更新 spiderLinks() 函数以调用新创建的 spiderTask() 函数并转发作为输入接收的任务队列实例。 让我们看看这一切是什么样子的:

function spiderLinks (currentUrl, content, nesting, queue) {
    if (nesting === 0) {
        return Promise.resolve()
    }

    const links = getPageLinks(currentUrl, content)
    const promises = links
        .map(link => spiderTask(link, nesting - 1, queue))

    return Promise.all(promises) // (2)
}

const spidering = new Set()
function spiderTask (url, nesting, queue) {
    if (spidering.has(url)) {
        return Promise.resolve()
    }

    spidering.add(url)

    const filename = urlToFilename(url)

    return queue
        .runTask(() => { // (1)
            return fsPromises.readFile(filename, 'utf8')
                .catch((err) => {
                    if (err.code !== 'ENOENT') {
                        throw err
                    }

                    // The file doesn't exists, so let's download it
                    return download(url, filename)
                })
        })
        .then(content => spiderLinks(url, content, nesting, queue))
}

export function spider (url, nesting, concurrency) {
    const queue = new TaskQueue(concurrency)
    return spiderTask(url, nesting, queue)
}

我们刚刚看到的代码中的关键指令是调用队列的地方。 runTask()(1)。 在这里,我们排队(因此受到限制)的任务仅包括从本地文件系统或远程 URL 位置检索 URL 的内容。 只有队列运行完这个任务后,我们才能继续抓取网页的链接。 请注意,我们有意将 SpiderLinks() 保留在我们想要限制的任务之外。 这是因为 spiderLinks() 可以触发更多的 spiderTasks(),如果 spidering 进程的深度高于队列的并发限制,就会产生死锁。

我们还可以注意到在 SpiderLinks() 中我们如何简单地继续使用 Promise.all() (2) 并行下载网页的所有链接。 这是因为我们的队列有责任限制任务的并发性。

在生产代码中,您可以使用包 p-limit(可在 nodejsdp.link/p-limit 获取)来限制一组任务的并发性。 该包本质上实现了我们刚刚展示的模式,但封装在略有不同的 API 中。

我们对 JavaScript Promise 的探索到此结束。 接下来,我们将学习 async/await 对,它将彻底改变我们处理异步代码的方式。