使用 Promise

一个 Promise 是一个对象,表示一个异步操作的最终完成或失败。由于大多数人都是已创建 Promise 的使用者,本指南将先解释如何消费返回的 Promise,然后再解释如何创建它们。

本质上,Promise 是一个返回的对象,你可以将回调函数附加到它上面,而不是将回调函数作为参数传递给函数。想象一个函数 createAudioFileAsync(),它根据配置记录和两个回调函数异步生成一个声音文件:一个在音频文件成功创建时调用,另一个在发生错误时调用。

以下是一些使用 createAudioFileAsync() 的代码:

js
function successCallback(result) {
  console.log(`Audio file ready at URL: ${result}`);
}

function failureCallback(error) {
  console.error(`Error generating audio file: ${error}`);
}

createAudioFileAsync(audioSettings, successCallback, failureCallback);

如果 createAudioFileAsync() 被重写为返回一个 Promise,你将把回调函数附加到它上面,而不是

js
createAudioFileAsync(audioSettings).then(successCallback, failureCallback);

这种约定有几个优点。我们将逐一探讨。

链式调用

一个常见的需求是执行两个或更多异步操作,其中每个后续操作在前一个操作成功后开始,并接收前一步骤的结果。在过去,连续执行多个异步操作会导致经典的回调地狱

js
doSomething(function (result) {
  doSomethingElse(result, function (newResult) {
    doThirdThing(newResult, function (finalResult) {
      console.log(`Got the final result: ${finalResult}`);
    }, failureCallback);
  }, failureCallback);
}, failureCallback);

使用 Promise,我们通过创建 Promise 链来实现这一点。Promise 的 API 设计使其非常出色,因为回调函数是附加到返回的 Promise 对象上的,而不是作为参数传递给函数。

神奇之处在于:then() 函数返回一个**新的 Promise**,与原始 Promise 不同

js
const promise = doSomething();
const promise2 = promise.then(successCallback, failureCallback);

这个第二个 Promise (promise2) 不仅代表 doSomething() 的完成,也代表你传入的 successCallbackfailureCallback 的完成——它们可以是返回 Promise 的其他异步函数。在这种情况下,添加到 promise2 的任何回调函数都会排队在由 successCallbackfailureCallback 返回的 Promise 之后。

注意:如果你想要一个可用的示例来尝试,你可以使用以下模板来创建任何返回 Promise 的函数

js
function doSomething() {
  return new Promise((resolve) => {
    setTimeout(() => {
      // Other things to do before completion of the promise
      console.log("Did something");
      // The fulfillment value of the promise
      resolve("https://example.com/");
    }, 200);
  });
}

具体实现将在下面的围绕旧回调 API 创建 Promise 部分讨论。

通过这种模式,你可以创建更长的处理链,其中每个 Promise 代表链中一个异步步骤的完成。此外,then 的参数是可选的,catch(failureCallback)then(null, failureCallback) 的简写形式——所以如果你的错误处理代码对所有步骤都相同,你可以将其附加到链的末尾

js
doSomething()
  .then(function (result) {
    return doSomethingElse(result);
  })
  .then(function (newResult) {
    return doThirdThing(newResult);
  })
  .then(function (finalResult) {
    console.log(`Got the final result: ${finalResult}`);
  })
  .catch(failureCallback);

你可能会看到使用箭头函数来表达

js
doSomething()
  .then((result) => doSomethingElse(result))
  .then((newResult) => doThirdThing(newResult))
  .then((finalResult) => {
    console.log(`Got the final result: ${finalResult}`);
  })
  .catch(failureCallback);

注意:箭头函数表达式可以有隐式返回;因此,() => x() => { return x; } 的简写。

doSomethingElsedoThirdThing 可以返回任何值——如果它们返回 Promise,那么这个 Promise 会首先等待直到它解决,下一个回调函数会接收到履行值,而不是 Promise 本身。重要的是始终从 then 回调中返回 Promise,即使该 Promise 总是解析为 undefined。如果前一个处理程序启动了一个 Promise 但没有返回它,就无法再跟踪它的解决状态,该 Promise 被称为“浮动”的。

js
doSomething()
  .then((url) => {
    // Missing `return` keyword in front of fetch(url).
    fetch(url);
  })
  .then((result) => {
    // result is undefined, because nothing is returned from the previous
    // handler. There's no way to know the return value of the fetch()
    // call anymore, or whether it succeeded at all.
  });

通过返回 fetch 调用的结果(它是一个 Promise),我们既可以跟踪它的完成,又可以在它完成时接收到它的值。

js
doSomething()
  .then((url) => {
    // `return` keyword added
    return fetch(url);
  })
  .then((result) => {
    // result is a Response object
  });

如果你有竞态条件,浮动 Promise 可能会更糟——如果上一个处理程序中的 Promise 没有返回,下一个 then 处理程序将提前调用,它读取的任何值可能都不完整。

js
const listOfIngredients = [];

doSomething()
  .then((url) => {
    // Missing `return` keyword in front of fetch(url).
    fetch(url)
      .then((res) => res.json())
      .then((data) => {
        listOfIngredients.push(data);
      });
  })
  .then(() => {
    console.log(listOfIngredients);
    // listOfIngredients will always be [], because the fetch request hasn't completed yet.
  });

因此,作为经验法则,每当你的操作遇到 Promise 时,就返回它并将其处理推迟到下一个 then 处理程序。

js
const listOfIngredients = [];

doSomething()
  .then((url) => {
    // `return` keyword now included in front of fetch call.
    return fetch(url)
      .then((res) => res.json())
      .then((data) => {
        listOfIngredients.push(data);
      });
  })
  .then(() => {
    console.log(listOfIngredients);
    // listOfIngredients will now contain data from fetch call.
  });

更好的是,你可以将嵌套链扁平化为单个链,这更简单,并且使错误处理更容易。详细内容将在下面的嵌套部分讨论。

js
doSomething()
  .then((url) => fetch(url))
  .then((res) => res.json())
  .then((data) => {
    listOfIngredients.push(data);
  })
  .then(() => {
    console.log(listOfIngredients);
  });

使用async/await 可以帮助你编写更直观、更像同步代码的代码。下面是使用 async/await 的相同示例

js
async function logIngredients() {
  const url = await doSomething();
  const res = await fetch(url);
  const data = await res.json();
  listOfIngredients.push(data);
  console.log(listOfIngredients);
}

请注意,除了 Promise 前面的 await 关键字外,代码看起来与同步代码完全一样。唯一的权衡之一是,很容易忘记 await 关键字,这只有在类型不匹配时(例如,尝试将 Promise 用作值)才能修复。

async/await 建立在 Promise 的基础上——例如,doSomething() 和以前是相同的函数,因此从 Promise 更改为 async/await 所需的重构很少。你可以在异步函数await参考中阅读有关 async/await 语法的更多信息。

注意:async/await 具有与普通 Promise 链相同的并发语义。一个异步函数中的 await 不会停止整个程序,只会停止依赖其值的部分,因此其他异步作业在 await 挂起时仍然可以运行。

错误处理

你可能会记得,在前面的回调地狱中,failureCallback 出现了三次,而 Promise 链的末尾只出现了一次

js
doSomething()
  .then((result) => doSomethingElse(result))
  .then((newResult) => doThirdThing(newResult))
  .then((finalResult) => console.log(`Got the final result: ${finalResult}`))
  .catch(failureCallback);

如果出现异常,浏览器将沿着链查找 .catch() 处理程序或 onRejected。这与同步代码的工作方式非常相似

js
try {
  const result = syncDoSomething();
  const newResult = syncDoSomethingElse(result);
  const finalResult = syncDoThirdThing(newResult);
  console.log(`Got the final result: ${finalResult}`);
} catch (error) {
  failureCallback(error);
}

这种与异步代码的对称性最终体现在 async/await 语法中

js
async function foo() {
  try {
    const result = await doSomething();
    const newResult = await doSomethingElse(result);
    const finalResult = await doThirdThing(newResult);
    console.log(`Got the final result: ${finalResult}`);
  } catch (error) {
    failureCallback(error);
  }
}

Promise 解决了回调地狱的基本缺陷,它捕获所有错误,甚至是抛出的异常和编程错误。这对于异步操作的功能组合至关重要。所有错误现在都由链末尾的 catch() 方法处理,在不使用 async/await 的情况下,你几乎永远不需要使用 try/catch

嵌套

在上面涉及 listOfIngredients 的示例中,第一个示例将一个 Promise 链嵌套在另一个 then() 处理程序的返回值中,而第二个示例使用完全扁平的链。简单的 Promise 链最好保持扁平,不要嵌套,因为嵌套可能是粗心组合的结果。

嵌套是一种控制结构,用于限制 catch 语句的作用域。具体来说,嵌套的 catch 只捕获其作用域及以下范围内的失败,而不捕获链中嵌套作用域之外的更高层错误。如果使用得当,这可以在错误恢复中提供更高的精度

js
doSomethingCritical()
  .then((result) =>
    doSomethingOptional(result)
      .then((optionalResult) => doSomethingExtraNice(optionalResult))
      .catch((e) => {}),
  ) // Ignore if optional stuff fails; proceed.
  .then(() => moreCriticalStuff())
  .catch((e) => console.error(`Critical failure: ${e.message}`));

请注意,这里的可选步骤是嵌套的——嵌套不是由缩进引起的,而是由围绕这些步骤的外部 () 括号的位置引起的。

内部的错误静默 catch 处理程序只捕获 doSomethingOptional()doSomethingExtraNice() 的失败,之后代码会继续执行 moreCriticalStuff()。重要的是,如果 doSomethingCritical() 失败,其错误只会被最终(外部)的 catch 捕获,而不会被内部 catch 处理程序吞噬。

async/await 中,这段代码看起来像

js
async function main() {
  try {
    const result = await doSomethingCritical();
    try {
      const optionalResult = await doSomethingOptional(result);
      await doSomethingExtraNice(optionalResult);
    } catch (e) {
      // Ignore failures in optional steps and proceed.
    }
    await moreCriticalStuff();
  } catch (e) {
    console.error(`Critical failure: ${e.message}`);
  }
}

注意:如果你没有复杂的错误处理,你很可能不需要嵌套的 then 处理程序。相反,使用扁平链并将错误处理逻辑放在末尾。

catch 后的链式调用

在失败(即 catch)*之后*进行链式调用是可能的,这对于即使操作在链中失败后也能执行新操作很有用。请阅读以下示例

js
doSomething()
  .then(() => {
    throw new Error("Something failed");

    console.log("Do this");
  })
  .catch(() => {
    console.error("Do that");
  })
  .then(() => {
    console.log("Do this, no matter what happened before");
  });

这将输出以下文本

Do that
Do this, no matter what happened before

注意:文本“Do this”没有显示,因为“Something failed”错误导致了拒绝。

async/await 中,这段代码看起来像

js
async function main() {
  try {
    await doSomething();
    throw new Error("Something failed");
    console.log("Do this");
  } catch (e) {
    console.error("Do that");
  }
  console.log("Do this, no matter what happened before");
}

Promise 拒绝事件

如果 Promise 拒绝事件未被任何处理程序处理,它会冒泡到调用堆栈的顶部,并且宿主需要将其浮出水面。在 Web 上,每当 Promise 被拒绝时,两个事件中的一个会被发送到全局作用域(通常,这是 window,或者,如果在 Web Worker 中使用,则是 Worker 或其他基于 Worker 的接口)。这两个事件是

unhandledrejection

当 Promise 被拒绝但没有可用的拒绝处理程序时发送。

rejectionhandled

当处理程序附加到已导致 unhandledrejection 事件的被拒绝 Promise 时发送。

在这两种情况下,事件(类型为 PromiseRejectionEvent)都具有 promise 属性,指示被拒绝的 Promise,以及 reason 属性,提供 Promise 被拒绝的原因。

这些使得为 Promise 提供回退错误处理以及帮助调试 Promise 管理问题成为可能。这些处理程序是每个上下文全局的,因此所有错误都将发送到相同的事件处理程序,无论来源如何。

Node.js 中,处理 Promise 拒绝略有不同。你可以通过为 Node.js unhandledRejection 事件(注意名称大小写的差异)添加处理程序来捕获未处理的拒绝,如下所示

js
process.on("unhandledRejection", (reason, promise) => {
  // Add code here to examine the "promise" and "reason" values
});

对于 Node.js,为了防止错误被记录到控制台(否则会发生的默认操作),只需添加那个 process.on() 监听器即可;不需要相当于浏览器运行时的 preventDefault() 方法。

然而,如果你添加了 process.on 监听器,但其中没有处理被拒绝 Promise 的代码,它们将只是被忽略,默默无闻。因此,理想情况下,你应该在该监听器中添加代码来检查每个被拒绝的 Promise,并确保它不是由实际的代码错误引起的。

组合

有四个用于并发运行异步操作的组合工具Promise.all()Promise.allSettled()Promise.any()Promise.race()

我们可以同时启动操作并等待它们全部完成,如下所示

js
Promise.all([func1(), func2(), func3()]).then(([result1, result2, result3]) => {
  // use result1, result2 and result3
});

如果数组中的一个 Promise 被拒绝,Promise.all() 会立即拒绝返回的 Promise。其他操作会继续运行,但它们的结果无法通过 Promise.all() 的返回值获得。这可能会导致意外的状态或行为。Promise.allSettled() 是另一个组合工具,它确保所有操作在解决之前都已完成。

这些方法都并发运行 Promise——一系列 Promise 同时启动,并且彼此不等待。使用一些巧妙的 JavaScript 可以实现顺序组合

js
[func1, func2, func3]
  .reduce((p, f) => p.then(f), Promise.resolve())
  .then((result3) => {
    /* use result3 */
  });

在这个例子中,我们将异步函数数组 reduce 成一个 Promise 链。上面的代码等效于

js
Promise.resolve()
  .then(func1)
  .then(func2)
  .then(func3)
  .then((result3) => {
    /* use result3 */
  });

这可以制作成一个可重用的组合函数,这在函数式编程中很常见

js
const applyAsync = (acc, val) => acc.then(val);
const composeAsync =
  (...funcs) =>
  (x) =>
    funcs.reduce(applyAsync, Promise.resolve(x));

composeAsync() 函数接受任意数量的函数作为参数,并返回一个新函数,该新函数接受一个初始值,该值将通过组合管道传递

js
const transformData = composeAsync(func1, func2, func3);
const result3 = transformData(data);

顺序组合也可以用 async/await 更简洁地完成

js
let result;
for (const f of [func1, func2, func3]) {
  result = await f(result);
}
/* use last result (i.e. result3) */

然而,在顺序组合 Promise 之前,请考虑它是否真的必要——最好总是并发运行 Promise,这样它们就不会不必要地相互阻塞,除非一个 Promise 的执行依赖于另一个 Promise 的结果。

取消

Promise 本身没有一流的取消协议,但你可能能够直接取消底层异步操作,通常使用 AbortController

围绕旧回调 API 创建 Promise

一个 Promise 可以使用其构造函数从头开始创建。这应该只在包装旧 API 时才需要。

在理想世界中,所有异步函数都将返回 Promise。不幸的是,一些 API 仍然期望以旧方式传递成功和/或失败回调函数。最明显的例子是 setTimeout() 函数

js
setTimeout(() => saySomething("10 seconds passed"), 10 * 1000);

混合旧式回调和 Promise 会有问题。如果 saySomething() 失败或包含编程错误,则没有任何东西捕获它。这是 setTimeout() 设计固有的。

幸运的是,我们可以将 setTimeout() 包装在一个 Promise 中。最佳实践是在最低层包装接受回调的函数,然后永远不要再次直接调用它们

js
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

wait(10 * 1000)
  .then(() => saySomething("10 seconds"))
  .catch(failureCallback);

Promise 构造函数接受一个执行器函数,它允许我们手动解决或拒绝 Promise。由于 setTimeout() 实际上不会失败,我们在这里省略了拒绝。有关执行器函数如何工作的更多信息,请参阅 Promise() 参考。

时序

最后,我们将深入探讨更技术性的细节,关于何时调用已注册的回调函数。

保证

在基于回调的 API 中,回调函数何时以及如何被调用取决于 API 实现者。例如,回调函数可以同步或异步调用

js
function doSomething(callback) {
  if (Math.random() > 0.5) {
    callback();
  } else {
    setTimeout(() => callback(), 1000);
  }
}

上述设计强烈不推荐,因为它会导致所谓的“Zalgo 状态”。在设计异步 API 的上下文中,这意味着回调在某些情况下同步调用,而在其他情况下异步调用,从而给调用者造成歧义。有关更多背景信息,请参阅文章Designing APIs for Asynchrony,其中首次正式提出了该术语。这种 API 设计使得副作用难以分析

js
let value = 1;
doSomething(() => {
  value = 2;
});
console.log(value); // 1 or 2?

另一方面,Promise 是一种控制反转形式——API 实现者不控制何时调用回调函数。相反,维护回调队列和决定何时调用回调函数的任务被委托给 Promise 实现,API 用户和 API 开发人员都自动获得强大的语义保证,包括

  • 使用 then() 添加的回调函数永远不会在 JavaScript 事件循环的当前运行完成之前被调用。
  • 即使在表示 Promise 的异步操作成功或失败*之后*添加了这些回调函数,它们也会被调用。
  • 可以通过多次调用 then() 来添加多个回调函数。它们将按照插入的顺序依次调用。

为了避免意外,传递给 then() 的函数永远不会同步调用,即使对于已经解决的 Promise 也是如此

js
Promise.resolve().then(() => console.log(2));
console.log(1);
// Logs: 1, 2

传递的函数不会立即运行,而是被放入微任务队列中,这意味着它稍后运行(只有在创建它的函数退出并且 JavaScript 执行堆栈为空之后),就在控制权返回到事件循环之前;也就是说,很快

js
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

wait(0).then(() => console.log(4));
Promise.resolve()
  .then(() => console.log(2))
  .then(() => console.log(3));
console.log(1); // 1, 2, 3, 4

任务队列 vs. 微任务

Promise 回调作为微任务处理,而 setTimeout() 回调作为任务队列处理。

js
const promise = new Promise((resolve, reject) => {
  console.log("Promise callback");
  resolve();
}).then((result) => {
  console.log("Promise callback (.then)");
});

setTimeout(() => {
  console.log("event-loop cycle: Promise (fulfilled)", promise);
}, 0);

console.log("Promise (pending)", promise);

上面的代码将输出

Promise callback
Promise (pending) Promise {<pending>}
Promise callback (.then)
event-loop cycle: Promise (fulfilled) Promise {<fulfilled>}

有关更多详细信息,请参阅任务 vs. 微任务

当 Promise 与任务冲突时

如果你遇到 Promise 和任务(例如事件或回调)以不可预测的顺序触发的情况,你可能会受益于使用微任务来检查状态或在条件创建 Promise 时平衡你的 Promise。

如果你认为微任务可能有助于解决此问题,请参阅微任务指南,了解如何使用 queueMicrotask() 将函数作为微任务入队。

另见