如何使用 Promise

Promise 是现代 JavaScript 中异步编程的基础。Promise 是一个由异步函数返回的对象,它代表了操作的当前状态。当 Promise 返回给调用者时,操作通常还没有完成,但 Promise 对象提供了处理操作最终成功或失败的方法。

预备知识 对本模块前面课程中介绍的 JavaScript 基础知识和异步概念有扎实的理解。
学习成果
  • 在 JavaScript 中使用 Promise 的概念和基础知识。
  • Promise 的链式调用和组合。
  • 处理 Promise 中的错误。
  • asyncawait:它们与 Promise 的关系,以及它们为何有用。

上一篇文章中,我们讨论了使用回调函数实现异步函数。通过这种设计,你调用异步函数,并传入你的回调函数。该函数会立即返回,并在操作完成时调用你的回调函数。

使用基于 Promise 的 API 时,异步函数会启动操作并返回一个 Promise 对象。然后,你可以将处理程序附加到此 Promise 对象,当操作成功或失败时,这些处理程序将被执行。

使用 fetch() API

注意: 在本文中,我们将通过将页面中的代码示例复制到浏览器的 JavaScript 控制台中来探索 Promise。要进行此设置:

  1. 打开一个浏览器标签页并访问 https://example.org
  2. 在该标签页中,在你的浏览器开发者工具中打开 JavaScript 控制台
  3. 当我们展示一个示例时,将其复制到控制台中。每次输入新示例时,你都必须重新加载页面,否则控制台会抱怨你重新声明了 fetchPromise

在此示例中,我们将从 https://mdn.github.io/learning-area/javascript/apis/fetching-data/can-store/products.json 下载 JSON 文件,并记录一些关于它的信息。

为此,我们将向服务器发出 HTTP 请求。在 HTTP 请求中,我们向远程服务器发送一个请求消息,它会向我们发回一个响应。在这种情况下,我们将发送一个请求以从服务器获取一个 JSON 文件。还记得上一篇文章中,我们使用 XMLHttpRequest API 发出 HTTP 请求吗?嗯,在本文中,我们将使用 fetch() API,它是 XMLHttpRequest 的现代的、基于 Promise 的替代品。

将此复制到浏览器的 JavaScript 控制台中

js
const fetchPromise = fetch(
  "https://mdn.github.io/learning-area/javascript/apis/fetching-data/can-store/products.json",
);

console.log(fetchPromise);

fetchPromise.then((response) => {
  console.log(`Received response: ${response.status}`);
});

console.log("Started request…");

这里我们正在

  1. 调用 fetch() API,并将返回值赋给 fetchPromise 变量
  2. 紧接着,记录 fetchPromise 变量。这应该输出类似 Promise { <state>: "pending" } 的内容,告诉我们我们有一个 Promise 对象,并且它有一个 state,其值为 "pending""pending" 状态表示 fetch 操作仍在进行中。
  3. 将一个处理函数传递给 Promise 的 then() 方法。当(如果)fetch 操作成功时,Promise 将调用我们的处理程序,传入一个 Response 对象,其中包含服务器的响应。
  4. 记录一条消息,表示我们已开始请求。

完整的输出应该类似于

Promise { <state>: "pending" }
Started request…
Received response: 200

请注意,Started request… 在我们收到响应之前被记录。与同步函数不同,fetch() 在请求仍在进行时返回,使我们的程序能够保持响应。响应显示 200 (OK) 状态码,这意味着我们的请求成功了。

这可能看起来很像上一篇文章中的示例,我们在 XMLHttpRequest 对象上添加了事件处理程序。不同的是,我们正在将处理程序传递到返回的 Promise 的 then() 方法中。

链式调用 Promise

使用 fetch() API,一旦你获得了一个 Response 对象,你需要调用另一个函数来获取响应数据。在这种情况下,我们希望将响应数据获取为 JSON,因此我们会调用 Response 对象的 json() 方法。结果 json() 也是异步的。所以这是一个我们必须调用两个连续异步函数的情况。

尝试这个

js
const fetchPromise = fetch(
  "https://mdn.github.io/learning-area/javascript/apis/fetching-data/can-store/products.json",
);

fetchPromise.then((response) => {
  const jsonPromise = response.json();
  jsonPromise.then((data) => {
    console.log(data[0].name);
  });
});

在此示例中,和以前一样,我们将一个 then() 处理程序添加到 fetch() 返回的 Promise 中。但这次,我们的处理程序调用 response.json(),然后将一个新的 then() 处理程序传递给 response.json() 返回的 Promise。

这应该会记录 "baked beans"("products.json" 中列出的第一个产品的名称)。

但是等等!还记得上一篇文章中,我们说过通过在一个回调函数内调用另一个回调函数,我们会得到越来越深的嵌套代码层次吗?我们还说过这种“回调地狱”使我们的代码难以理解吗?这不就是一样的情况吗,只是使用了 then() 调用?

当然是。但是 Promise 的优雅之处在于 then() 本身返回一个新的 Promise,该 Promise 使用回调函数的返回值(如果函数成功运行)来解决。这意味着我们可以(也应该)将上述代码改写成这样

js
const fetchPromise = fetch(
  "https://mdn.github.io/learning-area/javascript/apis/fetching-data/can-store/products.json",
);

fetchPromise
  .then((response) => response.json())
  .then((data) => {
    console.log(data[0].name);
  });

我们可以不是在第一个 then() 的处理程序内部调用第二个 then(),而是返回 json() 返回的 Promise,并在该返回值上调用第二个 then()。这被称为 Promise 链式调用,意味着当我们需要进行连续的异步函数调用时,我们可以避免不断增加的缩进级别。

在进入下一步之前,还有最后一步要补充。我们需要检查服务器是否接受并能够处理请求,然后才能尝试读取它。我们将通过检查响应中的状态码来做到这一点,如果状态码不是“OK”,则抛出错误。

js
const fetchPromise = fetch(
  "https://mdn.github.io/learning-area/javascript/apis/fetching-data/can-store/products.json",
);

fetchPromise
  .then((response) => {
    if (!response.ok) {
      throw new Error(`HTTP error: ${response.status}`);
    }
    return response.json();
  })
  .then((data) => {
    console.log(data[0].name);
  });

捕获错误

这引出了最后一点:我们如何处理错误?fetch() API 可以因为多种原因抛出错误(例如,因为没有网络连接或 URL 格式不正确),如果服务器返回错误,我们也会自己抛出错误。

在上一篇文章中,我们看到错误处理在嵌套回调中会变得非常困难,这使得我们必须在每个嵌套级别处理错误。

为了支持错误处理,Promise 对象提供了一个 catch() 方法。这与 then() 非常相似:你调用它并传入一个处理函数。然而,当异步操作成功时,传递给 then() 的处理程序会被调用;而当异步操作失败时,传递给 catch() 的处理程序会被调用。

如果你将 catch() 添加到 Promise 链的末尾,那么当任何异步函数调用失败时,它都会被调用。因此,你可以将一个操作实现为几个连续的异步函数调用,并拥有一个处理所有错误的单一位置。

尝试我们 fetch() 代码的这个版本。我们已经使用 catch() 添加了一个错误处理程序,并且还修改了 URL,以便请求将失败。

js
const fetchPromise = fetch(
  "bad-scheme://mdn.github.io/learning-area/javascript/apis/fetching-data/can-store/products.json",
);

fetchPromise
  .then((response) => {
    if (!response.ok) {
      throw new Error(`HTTP error: ${response.status}`);
    }
    return response.json();
  })
  .then((data) => {
    console.log(data[0].name);
  })
  .catch((error) => {
    console.error(`Could not get products: ${error}`);
  });

尝试运行此版本:您应该会看到由我们的 catch() 处理程序记录的错误。

Promise 术语

Promise 附带了一些非常具体的术语,值得弄清楚。

首先,一个 Promise 可以处于三种状态之一:

  • pending(待定):初始状态。操作尚未完成(成功或失败)。
  • fulfilled(已成功):操作成功。此时会调用 Promise 的 .then() 处理程序。
  • rejected(已拒绝):操作失败。此时会调用 Promise 的 .catch() 处理程序。

请注意,这里“成功”或“失败”的含义取决于所讨论的 API。例如,如果(除其他原因外)网络错误阻止了请求的发送,fetch() 会拒绝返回的 Promise,但即使响应是 404 Not Found 这样的错误,如果服务器发送了响应,它也会兑现 Promise。

我们还使用其他一些术语来描述 Promise 的状态:

  • completed(已完成):Promise 不再处于待定状态;它已成功或已拒绝。
  • resolved(已解决):Promise 已完成,或者它已“锁定”以遵循另一个 Promise 的状态。这是一个更高级的概念,当一个 Promise 依赖于另一个 Promise 时相关。

我们来谈谈如何谈论 Promise 一文对这些术语的细节做了很好的解释。

组合多个 Promise

当你的操作由多个异步函数组成,并且你需要每个函数在开始下一个函数之前完成时,你就需要 Promise 链。但是,还有其他方法可以组合异步函数调用,Promise API 为它们提供了一些帮助。

有时,你需要所有 Promise 都被兑现,但它们之间没有依赖关系。在这种情况下,将它们全部同时启动,然后在它们全部兑现时得到通知会更有效率。Promise.all() 方法就是你所需要的。它接受一个 Promise 数组并返回一个单一的 Promise。

Promise.all() 返回的 Promise 是

  • 当数组中的所有 Promise 都被兑现时,才会被兑现。在这种情况下,then() 处理程序会被调用,并带有一个包含所有响应的数组,其顺序与 Promise 传递给 all() 的顺序相同。
  • 当数组中的任何 Promise 被拒绝时,它都会被拒绝。在这种情况下,catch() 处理程序会被调用,并带有一个由被拒绝的 Promise 抛出的错误。

例如

js
const fetchPromise1 = fetch(
  "https://mdn.github.io/learning-area/javascript/apis/fetching-data/can-store/products.json",
);
const fetchPromise2 = fetch(
  "https://mdn.github.io/learning-area/javascript/apis/fetching-data/can-store/not-found",
);
const fetchPromise3 = fetch(
  "https://mdn.github.io/learning-area/javascript/oojs/json/superheroes.json",
);

Promise.all([fetchPromise1, fetchPromise2, fetchPromise3])
  .then((responses) => {
    for (const response of responses) {
      console.log(`${response.url}: ${response.status}`);
    }
  })
  .catch((error) => {
    console.error(`Failed to fetch: ${error}`);
  });

在这里,我们向三个不同的 URL 发出了三个 fetch() 请求。如果它们都成功,我们将记录每个请求的响应状态。如果其中任何一个失败,那么我们将记录失败。

使用我们提供的 URL,所有请求都应该得到 fulfilled 状态,尽管对于第二个请求,服务器将返回 404(未找到)而不是 200(OK),因为请求的文件不存在。所以输出应该如下:

https://mdn.github.io/learning-area/javascript/apis/fetching-data/can-store/products.json: 200
https://mdn.github.io/learning-area/javascript/apis/fetching-data/can-store/not-found: 404
https://mdn.github.io/learning-area/javascript/oojs/json/superheroes.json: 200

如果我们用一个格式错误的 URL 尝试相同的代码,例如:

js
const fetchPromise1 = fetch(
  "https://mdn.github.io/learning-area/javascript/apis/fetching-data/can-store/products.json",
);
const fetchPromise2 = fetch(
  "https://mdn.github.io/learning-area/javascript/apis/fetching-data/can-store/not-found",
);
const fetchPromise3 = fetch(
  "bad-scheme://mdn.github.io/learning-area/javascript/oojs/json/superheroes.json",
);

Promise.all([fetchPromise1, fetchPromise2, fetchPromise3])
  .then((responses) => {
    for (const response of responses) {
      console.log(`${response.url}: ${response.status}`);
    }
  })
  .catch((error) => {
    console.error(`Failed to fetch: ${error}`);
  });

那么我们可以预期 catch() 处理程序将运行,我们应该会看到类似以下内容:

Failed to fetch: TypeError: Failed to fetch

有时,你可能需要一组 Promise 中的任何一个被兑现,而不在乎是哪一个。在这种情况下,你需要 Promise.any()。这类似于 Promise.all(),不同之处在于它会在 Promise 数组中的任何一个 Promise 被兑现时立即被兑现,或者在所有 Promise 都被拒绝时被拒绝。

js
const fetchPromise1 = fetch(
  "https://mdn.github.io/learning-area/javascript/apis/fetching-data/can-store/products.json",
);
const fetchPromise2 = fetch(
  "https://mdn.github.io/learning-area/javascript/apis/fetching-data/can-store/not-found",
);
const fetchPromise3 = fetch(
  "https://mdn.github.io/learning-area/javascript/oojs/json/superheroes.json",
);

Promise.any([fetchPromise1, fetchPromise2, fetchPromise3])
  .then((response) => {
    console.log(`${response.url}: ${response.status}`);
  })
  .catch((error) => {
    console.error(`Failed to fetch: ${error}`);
  });

请注意,在这种情况下,我们无法预测哪个 fetch 请求会首先完成。

这些只是用于组合多个 Promise 的额外 Promise 函数中的两个。要了解其余的,请参阅 Promise 参考文档。

async 和 await

async 关键字提供了一种更简单的方式来处理异步的、基于 Promise 的代码。在函数开头添加 async 会使其成为一个异步函数:

js
async function myFunction() {
  // This is an async function
}

在异步函数内部,你可以在调用返回 Promise 的函数之前使用 await 关键字。这会使代码在该点等待,直到 Promise 解决,此时 Promise 的已解决值被视为返回值,或者已拒绝值被抛出。

这使你能够编写使用异步函数但看起来像同步代码的代码。例如,我们可以用它来重写我们的 fetch 示例:

js
async function fetchProducts() {
  try {
    // after this line, our function will wait for the `fetch()` call to be settled
    // the `fetch()` call will either return a Response or throw an error
    const response = await fetch(
      "https://mdn.github.io/learning-area/javascript/apis/fetching-data/can-store/products.json",
    );
    if (!response.ok) {
      throw new Error(`HTTP error: ${response.status}`);
    }
    // after this line, our function will wait for the `response.json()` call to be settled
    // the `response.json()` call will either return the parsed JSON object or throw an error
    const data = await response.json();
    console.log(data[0].name);
  } catch (error) {
    console.error(`Could not get products: ${error}`);
  }
}

fetchProducts();

这里,我们调用了 await fetch(),但我们的调用者得到的不是一个 Promise,而是一个完全完成的 Response 对象,就像 fetch() 是一个同步函数一样!

我们甚至可以使用 try...catch 块进行错误处理,就像代码是同步的一样。

但请注意,async 函数总是返回一个 Promise,所以你不能这样做:

js
async function fetchProducts() {
  try {
    const response = await fetch(
      "https://mdn.github.io/learning-area/javascript/apis/fetching-data/can-store/products.json",
    );
    if (!response.ok) {
      throw new Error(`HTTP error: ${response.status}`);
    }
    const data = await response.json();
    return data;
  } catch (error) {
    console.error(`Could not get products: ${error}`);
  }
}

const promise = fetchProducts();
console.log(promise[0].name); // "promise" is a Promise object, so this will not work

相反,你需要做类似以下的事情:

js
async function fetchProducts() {
  const response = await fetch(
    "https://mdn.github.io/learning-area/javascript/apis/fetching-data/can-store/products.json",
  );
  if (!response.ok) {
    throw new Error(`HTTP error: ${response.status}`);
  }
  const data = await response.json();
  return data;
}

const promise = fetchProducts();
promise
  .then((data) => {
    console.log(data[0].name);
  })
  .catch((error) => {
    console.error(`Could not get products: ${error}`);
  });

这里,我们将 try...catch 移回了返回的 Promise 的 catch 处理程序。这意味着我们的 then 处理程序不必处理 fetchProducts 函数内部捕获到错误导致 dataundefined 的情况。将错误处理作为 Promise 链的最后一步。

另外请注意,你只能在 async 函数内部使用 await,除非你的代码在 JavaScript 模块中。这意味着你不能在普通脚本中这样做:

js
try {
  // using await outside an async function is only allowed in a module
  const response = await fetch(
    "https://mdn.github.io/learning-area/javascript/apis/fetching-data/can-store/products.json",
  );
  if (!response.ok) {
    throw new Error(`HTTP error: ${response.status}`);
  }
  const data = await response.json();
  console.log(data[0].name);
} catch (error) {
  console.error(`Could not get products: ${error}`);
  throw error;
}

你可能会大量使用 async 函数,而这原本可能会使用 Promise 链,并且它们使得使用 Promise 更加直观。

请记住,就像 Promise 链一样,await 强制异步操作按系列完成。如果下一个操作的结果依赖于上一个操作的结果,这是必要的,但如果不是这种情况,那么像 Promise.all() 这样的方法会更高效。

总结

Promise 是现代 JavaScript 中异步编程的基础。它们使表达和推理异步操作序列变得更容易,而无需深度嵌套的回调,并且它们支持一种类似于同步 try...catch 语句的错误处理方式。

asyncawait 关键字使从一系列连续的异步函数调用构建操作变得更容易,避免了创建显式 Promise 链的需要,并允许你编写看起来就像同步代码的代码。

Promise 在所有现代浏览器的最新版本中都有效;唯一可能出现 Promise 支持问题的地方是 Opera Mini 和 IE11 及更早版本。

本文并未涉及 Promise 的所有功能,只介绍了最有趣和最有用的部分。随着你对 Promise 了解的深入,你还会遇到更多功能和技术。

许多现代 Web API 都是基于 Promise 的,包括 WebRTCWeb Audio APIMedia Capture and Streams API 等等。

另见