使用 Promise

一个 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 被称为“浮动”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 的示例中,第一个示例在一个 then() 处理程序的返回值中嵌套了一个 promise 链,而第二个示例使用了一个完全平坦的链。简单的 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");
  });

这将输出以下文本

Initial
Do that
Do this, no matter what happened before

注意:“执行此操作”文本不会显示,因为“某些操作失败”错误导致了拒绝。

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.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 */
  });

这可以转换成一个可重用的 compose 函数,这在函数式编程中很常见

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()实际上不会失败,因此在这种情况下我们省略了reject。有关执行器函数如何工作的更多信息,请参阅Promise()参考。

计时

最后,我们将深入了解更技术性的细节,即注册的回调何时被调用。

保证

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

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

强烈不建议采用上述设计,因为它会导致所谓的“Zalgo 状态”。在设计异步 API 的上下文中,这意味着回调在某些情况下同步调用,而在其他情况下异步调用,从而为调用者造成歧义。有关更多背景信息,请参阅文章设计异步 API,其中首次正式提出了该术语。这种 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

任务队列与微任务

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>}

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

Promise 和任务冲突时

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

如果您认为微任务可以帮助解决此问题,请参阅微任务指南,以了解如何使用queueMicrotask()将函数排队作为微任务。

另请参阅