2022 年 8 月 14 日

Promise

想象一下,你是一位顶级歌手,粉丝们日夜催问你的新歌。

为了得到一些喘息,你承诺在歌曲发布时将它发送给他们。你给粉丝们一个列表。他们可以填写他们的电子邮件地址,以便在歌曲可用时,所有订阅者都能立即收到它。即使出了什么大问题,比如工作室失火,导致你无法发布歌曲,他们仍会收到通知。

每个人都很开心:你,因为人们不再围着你,粉丝们,因为他们不会错过这首歌。

这是我们经常在编程中遇到的事物的真实类比

  1. 一个“生成代码”做一些事情并花费时间。例如,一些通过网络加载数据的代码。那是一个“歌手”。
  2. 一个“消费代码”想要在“生成代码”准备好后得到它的结果。许多函数可能需要该结果。这些是“粉丝”。
  3. Promise 是一个特殊的 JavaScript 对象,它将“生成代码”和“消费代码”链接在一起。就我们的类比而言:这是“订阅列表”。“生成代码”需要任何时间来生成承诺的结果,“promise”在准备好后向所有订阅的代码提供该结果。

这个类比并不是非常准确,因为 JavaScript promise 比简单的订阅列表更复杂:它们具有附加的功能和限制。但一开始这样就可以了。

promise 对象的构造函数语法是

let promise = new Promise(function(resolve, reject) {
  // executor (the producing code, "singer")
});

传递给 new Promise 的函数称为executor。当创建 new Promise 时,executor 会自动运行。它包含最终应该生成结果的生成代码。就上面的类比而言:executor 是“歌手”。

它的参数 resolvereject 是 JavaScript 本身提供的回调。我们的代码只在 executor 中。

无论 executor 何时获得结果,无论是早还是晚,它都应该调用其中一个回调

  • resolve(value) — 如果作业成功完成,结果为 value
  • reject(error) — 如果发生错误,error 是错误对象。

因此,总结一下:executor 自动运行并尝试执行作业。当它完成尝试时,如果成功,它会调用 resolve,如果出错,它会调用 reject

new Promise 构造函数返回的 promise 对象具有以下内部属性

  • state — 最初为 "pending",然后在调用 resolve 时更改为 "fulfilled",或在调用 reject 时更改为 "rejected"
  • result — 最初为 undefined,然后在调用 resolve(value) 时更改为 value,或在调用 reject(error) 时更改为 error

因此,executor 最终将 promise 移至其中一个状态

稍后我们将了解“粉丝”如何订阅这些更改。

以下是一个 Promise 构造函数和一个简单的执行器函数的示例,其中包含需要时间(通过 setTimeout)的“生成代码”

let promise = new Promise(function(resolve, reject) {
  // the function is executed automatically when the promise is constructed

  // after 1 second signal that the job is done with the result "done"
  setTimeout(() => resolve("done"), 1000);
});

通过运行以上代码,我们可以了解两件事

  1. 执行器会自动立即被调用(通过 new Promise)。

  2. 执行器接收两个参数:resolvereject。这些函数由 JavaScript 引擎预先定义,因此我们无需创建它们。我们只应在准备就绪时调用其中一个函数。

    经过一秒钟的“处理”后,执行器调用 resolve("done") 以生成结果。这会更改 promise 对象的状态

这是一个作业成功完成的示例,即“已完成的 Promise”。

现在来看一个执行器用错误拒绝 Promise 的示例

let promise = new Promise(function(resolve, reject) {
  // after 1 second signal that the job is finished with an error
  setTimeout(() => reject(new Error("Whoops!")), 1000);
});

调用 reject(...) 会将 Promise 对象移至“已拒绝”状态

总而言之,执行器应执行一项作业(通常是需要花费时间的事情),然后调用 resolvereject 来更改相应 Promise 对象的状态。

已解决或已拒绝的 Promise 被称为“已解决”,与最初的“待处理”Promise 相对。

只能有一个结果或一个错误

执行器应仅调用一个 resolve 或一个 reject。任何状态更改都是最终的。

将忽略所有进一步的 resolvereject 调用

let promise = new Promise(function(resolve, reject) {
  resolve("done");

  reject(new Error("…")); // ignored
  setTimeout(() => resolve("…")); // ignored
});

这个想法是,执行器完成的作业可能只有一个结果或一个错误。

此外,resolve/reject 仅期望一个参数(或没有参数),并将忽略其他参数。

使用 Error 对象拒绝

如果出现问题,执行器应调用 reject。可以使用任何类型的参数来完成此操作(就像 resolve 一样)。但建议使用 Error 对象(或从 Error 继承的对象)。其原因很快就会变得明显。

立即调用 resolve/reject

在实践中,执行器通常会异步执行某些操作,并在一段时间后调用 resolve/reject,但并非必须如此。我们还可以像这样立即调用 resolvereject

let promise = new Promise(function(resolve, reject) {
  // not taking our time to do the job
  resolve(123); // immediately give the result: 123
});

例如,当我们开始执行一项作业,但随后发现所有内容都已完成并已缓存时,可能会发生这种情况。

这很好。我们立即获得一个已解决的 Promise。

stateresult 是内部的

Promise 对象的 stateresult 属性是内部的。我们无法直接访问它们。我们可以使用 .then/.catch/.finally 方法来实现此目的。它们在下面进行了描述。

使用者:then、catch

Promise 对象充当执行器(“生成代码”或“歌手”)和使用者函数(“粉丝”)之间的链接,后者将收到结果或错误。可以使用 .then.catch 方法注册(订阅)使用者函数。

then

最重要的基本方法是 .then

语法为

promise.then(
  function(result) { /* handle a successful result */ },
  function(error) { /* handle an error */ }
);

.then 的第一个参数是一个函数,该函数在 Promise 已解决并收到结果时运行。

.then 的第二个参数是一个函数,当 Promise 被拒绝并收到错误时运行。

例如,以下是对成功解决的 Promise 的反应

let promise = new Promise(function(resolve, reject) {
  setTimeout(() => resolve("done!"), 1000);
});

// resolve runs the first function in .then
promise.then(
  result => alert(result), // shows "done!" after 1 second
  error => alert(error) // doesn't run
);

第一个函数已执行。

在拒绝的情况下,第二个函数

let promise = new Promise(function(resolve, reject) {
  setTimeout(() => reject(new Error("Whoops!")), 1000);
});

// reject runs the second function in .then
promise.then(
  result => alert(result), // doesn't run
  error => alert(error) // shows "Error: Whoops!" after 1 second
);

如果我们只对成功完成感兴趣,那么我们可以只向 .then 提供一个函数参数

let promise = new Promise(resolve => {
  setTimeout(() => resolve("done!"), 1000);
});

promise.then(alert); // shows "done!" after 1 second

catch

如果我们只对错误感兴趣,那么我们可以使用 null 作为第一个参数:.then(null, errorHandlingFunction)。或者我们可以使用 .catch(errorHandlingFunction),它完全相同

let promise = new Promise((resolve, reject) => {
  setTimeout(() => reject(new Error("Whoops!")), 1000);
});

// .catch(f) is the same as promise.then(null, f)
promise.catch(alert); // shows "Error: Whoops!" after 1 second

调用 .catch(f).then(null, f) 的完全类似,它只是一个简写。

清理:finally

就像在常规 try {...} catch {...} 中有 finally 子句一样,在 Promise 中也有 finally

调用 .finally(f) 类似于 .then(f, f),因为当 Promise 解决时,f 总会运行:无论它解决还是拒绝。

finally 的目的是在之前的操作完成后设置一个处理程序来执行清理/完成。

例如,停止加载指示器,关闭不再需要的连接等。

把它想象成一个派对结束者。无论派对好坏,有多少朋友参加,我们仍然需要(或至少应该)在派对结束后进行清理。

代码可能如下所示

new Promise((resolve, reject) => {
  /* do something that takes time, and then call resolve or maybe reject */
})
  // runs when the promise is settled, doesn't matter successfully or not
  .finally(() => stop loading indicator)
  // so the loading indicator is always stopped before we go on
  .then(result => show result, err => show error)

请注意,finally(f) 并不完全是 then(f,f) 的别名。

有重要的区别

  1. finally 处理程序没有参数。在 finally 中,我们不知道 Promise 是否成功。这没关系,因为我们的任务通常是执行“常规”完成程序。

    请看上面的示例:如您所见,finally 处理程序没有参数,并且 Promise 结果由下一个处理程序处理。

  2. finally 处理程序将结果或错误“传递”给下一个合适的处理程序。

    例如,这里结果通过 finally 传递到 then

    new Promise((resolve, reject) => {
      setTimeout(() => resolve("value"), 2000);
    })
      .finally(() => alert("Promise ready")) // triggers first
      .then(result => alert(result)); // <-- .then shows "value"

    如您所见,第一个 Promise 返回的 value 通过 finally 传递到下一个 then

    这非常方便,因为 finally 不是用来处理 Promise 结果的。如前所述,无论结果如何,它都是执行通用清理的地方。

    以下是一个错误的示例,以便我们了解它是如何通过 finally 传递到 catch

    new Promise((resolve, reject) => {
      throw new Error("error");
    })
      .finally(() => alert("Promise ready")) // triggers first
      .catch(err => alert(err));  // <-- .catch shows the error
  3. finally 处理程序也不应该返回任何内容。如果返回,则会忽略返回的值。

    此规则的唯一例外是当 finally 处理程序抛出错误时。然后,此错误将转到下一个处理程序,而不是任何先前的结果。

总结

  • finally 处理程序不会获取前一个处理程序的结果(它没有参数)。此结果将传递给下一个合适的处理程序。
  • 如果 finally 处理程序返回内容,则会被忽略。
  • finally 抛出错误时,执行将转到最近的错误处理程序。

如果我们按照预期的方式使用 finally(用于通用清理过程),这些功能非常有用,可以正确地完成工作。

我们可以将处理程序附加到已解决的 Promise

如果 Promise 处于待处理状态,.then/catch/finally 处理程序将等待其结果。

有时,当我们向 Promise 添加处理程序时,它可能已经解决。

在这种情况下,这些处理程序会立即运行

// the promise becomes resolved immediately upon creation
let promise = new Promise(resolve => resolve("done!"));

promise.then(alert); // done! (shows up right now)

请注意,这使得 Promise 比实际的“订阅列表”场景更强大。如果歌手已经发布了他们的歌曲,然后有人在订阅列表上注册,他们可能收不到这首歌。现实生活中的订阅必须在事件发生之前完成。

Promise 更灵活。我们可以随时添加处理程序:如果结果已经存在,它们就会执行。

示例:loadScript

接下来,让我们看一些更实际的示例,了解 Promise 如何帮助我们编写异步代码。

我们有 loadScript 函数,用于从上一章加载脚本。

这是基于回调的变体,只是为了提醒我们

function loadScript(src, callback) {
  let script = document.createElement('script');
  script.src = src;

  script.onload = () => callback(null, script);
  script.onerror = () => callback(new Error(`Script load error for ${src}`));

  document.head.append(script);
}

让我们使用 Promise 重写它。

新的函数 loadScript 不需要回调。相反,它将创建并返回一个 Promise 对象,该对象在加载完成后解析。外部代码可以使用 .then 向其添加处理程序(订阅函数)

function loadScript(src) {
  return new Promise(function(resolve, reject) {
    let script = document.createElement('script');
    script.src = src;

    script.onload = () => resolve(script);
    script.onerror = () => reject(new Error(`Script load error for ${src}`));

    document.head.append(script);
  });
}

用法

let promise = loadScript("https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.11/lodash.js");

promise.then(
  script => alert(`${script.src} is loaded!`),
  error => alert(`Error: ${error.message}`)
);

promise.then(script => alert('Another handler...'));

我们可以立即看到基于回调的模式的一些优势

Promise 回调
Promise 允许我们按自然顺序做事。首先,我们运行 loadScript(script),然后 .then 我们编写如何处理结果。 在调用 loadScript(script, callback) 时,我们必须准备好一个 callback 函数。换句话说,我们必须在调用 loadScript 之前 知道如何处理结果。
我们可以多次对 Promise 调用 .then。每次调用,我们都向“订阅列表”中添加一个新的“粉丝”,即一个新的订阅函数。下一章将对此进行更详细的介绍:Promise 链式调用 只能有一个回调函数。

因此,Promise 为我们提供了更好的代码流程和灵活性。但还有更多内容。我们将在下一章中看到。

任务

以下代码的输出是什么?

let promise = new Promise(function(resolve, reject) {
  resolve(1);

  setTimeout(() => resolve(2), 1000);
});

promise.then(alert);

输出为:1

resolve 的第二次调用被忽略,因为只考虑 reject/resolve 的第一次调用。后续调用将被忽略。

内置函数 setTimeout 使用回调函数。创建一个基于 Promise 的替代方案。

函数 delay(ms) 应返回一个 Promise。该 Promise 应在 ms 毫秒后解析,以便我们可以向其添加 .then,如下所示

function delay(ms) {
  // your code
}

delay(3000).then(() => alert('runs after 3 seconds'));
function delay(ms) {
  return new Promise(resolve => setTimeout(resolve, ms));
}

delay(3000).then(() => alert('runs after 3 seconds'));

请注意,在此任务中,resolve 被调用时没有参数。我们不会从 delay 返回任何值,只需确保延迟即可。

重写任务 带有回调函数的动画圆圈 中的 showCircle 函数,使其返回一个 Promise,而不是接受一个回调函数。

新用法

showCircle(150, 150, 100).then(div => {
  div.classList.add('message-ball');
  div.append("Hello, world!");
});

以任务 带有回调函数的动画圆圈 的解决方案为基础。

教程地图

评论

在评论之前先阅读此内容…
  • 如果您有改进建议,请 提交 GitHub 问题 或提交拉取请求,而不是评论。
  • 如果您无法理解文章中的某些内容,请详细说明。
  • 要插入几行代码,请使用 <code> 标签;要插入多行代码,请将其包装在 <pre> 标签中;要插入 10 行以上的代码,请使用沙盒(plnkrjsbincodepen…)