全面理解 Promise 系列(一):Promise 基础
小举 筑基

虽然 Promise 通常与异步操作相关,但他其实只是值的“临时占位服务”,该值可能是已知的或更常见的是该值是一个异步操作的结果,所有函数都可以返回一个 Promise ,而不用像以前一样订阅(subscribe)一个事件或传递一个回调(callback)给该函数。举例如下:

1
2
// fetch() 承诺会在未来的某个时刻返回
const Promise = fetch('book.json');

fetch()函数是 JavaScript 运行环境中的一个常见的实用函数,用来发出网络请求,fetch()实际上不会立刻完成该请求,而是过一会儿才完成。因此,该函数返回的是一个代表异步操作结果的 Promise (对象在本地中该 Promise 对象存储在名为 Promise 的变量中但其实你可以任意命名这个变量),这样你就可以在将来使用它,具体什么时候能使用这个结果,完全取决于该 Promise 的生命周期。

1.1 Promise 的生命周期

每个 Promise 都会经历一个短暂的生命周期,这个生命周期从待定 (pending)状态开始,待定状态表明该 Promise 还没有完成。一个处于待定状态的 Promise 被认为是未确定的。在前面的例子中 Promise 在 fetch() 函数返回时就处于待定(pending)状态,一旦完成,该 Promise 就被视为已确定,并进入以下两种可能的状态之一:

  • 履行(fulfilled)状态:该 Promise 已经成功地完成了其承诺,并将其值作为结果返回。
  • 拒绝(rejected)状态:该 Promise 由于错误或其他原因没有被成功完成, 也就是被拒绝了。

内部属性[[PromiseState]]可以被设置为 pendingfulfilledrejected反映 Promise 的状态,因为这个属性在 Promise 对象上是非公开的,所以我们无法通过编程来确定 Promise 处于哪个状态,不过我们可以在 Promise 的状态改变时通过then()方法来指定具体的行为。

1.1.1 用 then() 分配处理器

then()方法存在于所有 Promise 中,它有两个参数。第一个参数是当 Promise 被履行时(fulfilled)要调用的函数,被称为履行处理器(fulfillment handler),任何异步操作被履行相关的额外数据都会作为参数被传递给这个函数。第二个参数是当 Promise 被拒绝时(rejected)要调用的函数,被称为拒绝处理器(rejection handler),任何与异步操作被拒绝相关的错误信息都会作为参数被传递给这个函数。

💡 任何以上述方式实现 then() 方法的对象都被称为 thenable 对象。所有 Promise 对象都是 thenable,但并非所有 thenable 都是 Promise 。

因为 then()的两个参数都是可选的,所以你可以选择性的监听履行状态、拒绝状态或者两者的任意组合。来看以下这组 then()的调用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 添加履行处理器和拒绝处理器
const Promise = fetch('book.json');

Promise.then(
(response) => {
// fulfilled
console.log('Request succeeded with response', response.status);
},
(reason) => {
// rejected
console.log('Request failed with error', reason.message);
}
);

// 添加另外一个履行处理器
Promise.then((response) => {
// fulfilled
console.log('Request succeeded with data', response.statusText);
});

// 添加另外一个拒绝处理器
Promise.then(null, (reason) => {
console.log('Request failed with error', reason.message);
});

3 个 then()调用都作用于同一个 Promise 对象,第一个 then()调用分配了一个履行处理器和一个拒绝处理器,第二个 then()调用只分配了一个履行处理器,该异步请求产生的错误不会报告给程序。第三个 then()调用只分配了一个拒绝处理器,该异步请求的成功完成不会被报告给程序。

⚠️ fetch()函数的一个异常行为是:只要它收到一个 HTTP 状态码,哪怕是 404 或者 500,返回的 Promise 都会被视为 fulfilled,并将响应对象作为结果返回。只有当网络请求因为某种原因失败的时候, Promise 才会处于拒绝状态。因此,你需要在 then()调用中检查响应对象的状态码,已确定请求是否成功,如下所示:

1
2
3
4
5
6
7
fetch('book.json').then((response) => {
if (response.ok) {
console.log('Request succeeded.');
} else {
console.log('Request failed.');
}
});

1.1.2 用 catch() 分配拒绝处理器

Promise 还有一个名为 catch() 的方法。当只传递一个拒绝处理器时,它的行为与 then() 类似。比如下面的 catch() 调用和 then() 调用在功能上是等价的:

1
2
3
4
5
6
7
8
9
10
11
12
const Promise = fetch('book.json');

Promise.catch((reason) => {
// rejected
console.log('Request failed with error', reason.message);
});

// 等同于
Promise.then(null, (reason) => {
// rejected
console.log('Request failed with error', reason.message);
});

then()catch() 的根本目的是让你把它们结合起来使用,从而指明如何处理结果。这个组合比事件处理器和回调函数更好,因为它清楚地展示了操作成功与否。(事件处理器往往不会在出现错误时被触发, 而是在回调函数中,你必须随时记得检查可能出现的错误并手动处理。) 如果你不给一个被拒绝的 Promise 添加拒绝处理器, 那么 JavaScript 运行环境 (浏览器或 Node)就会向控制台输出一条错误消息, 或者抛出一个错误对象,又或者两者都有(具体取决于 JavaScript 运行环境)。

1.1.3 用 finally() 分配解决处理器

除了then()catch(), Promise 还有finally(), 无论异步操作是成功还是失败,只要操作完成, 那么传给finally()的回调函数(被称为解决处理器)就会被调用, 与传给then()catch()的回调函数不同, 传给finally()的回调函数不接受任何参数, 因为我们不清楚 Promise 是被履行了还是被拒绝了, 因为解决处理器在 Promise 被履行和被拒绝时都会被调用, 所以它类似于使用then()时将同一个函数传递给 Promise 的履行处理器和拒绝处理器。以下是一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
const Promise = fetch('book.json');

Promise.finally(() => {
// 我们不知道 Promise 是被履行还是被拒绝, 所以我们不接受任何参数
console.log('Request completed.');
});

// 类似于
const callback = () => {
console.log('Request completed.');
};

Promise.then(callback, callback);

只要不访问回调函数的参数,这两个例子所表现出的行为就是一致的。然而,与then()相比,使用finally()可以更清晰地表达你的意图,这一点和catch()一样。

当你希望知道一个操作已经完成,但并不关心结果时,解决处理器很有用。举个例子,假设你想在fetch()请求处于活跃状态时在网页上显示一个加载指示器,然后在fetch()请求完成后隐藏该加载指示器。在这种情况下,该请求本身是否成功并不重要,因为一旦请求完成,加载指示器就应该隐藏。如下代码,可在你的 Web 应用程序中满足上述需求。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const appElement = document.getElementById('app');
const Promise = fetch('book.json');

appElement.classList.add('loading');

Promise.then(() => {
// 处理成功
});

Promise.catch(() => {
// 处理失败
});

Promise.finally(() => {
appElement.classList.remove('loading');
});

在这里,AppElement 代表在网页上包裹整个应用程序的 HTML 元素。使用fetch()发起一个网络请求,添加 CSS 类 loading 到该 HTML 元素中(这样做便可以适当地改变该元素的样式)。当网络请求完成后, Promise 的状态已确定,解决处理器将该 loading 类从 HTML 元素中移除,以重置应用程序的状态。你仍然可以使用then()catch()来响应请求成功和失败的结果, 而finally()只处理状态从不确定到确定的变更。如果没有finally(),你就需要在履行处理器和拒绝处理器中都删除该 loading 类。

⚠️ 注意: 通过finally()添加的解决处理器并不能避免由于请求被拒绝而向控制台输出或抛出错误, 你仍需要添加一个拒绝处理器来避免这种情况。

1.1.4 为已确定的 Promise 分配处理器

即使履行处理器、拒绝处理器或解决处理器是在 Promise 状态已确定的情况下添加的,该处理器也仍然会被执行。这样一来,你便可以在任何时候添加新的处理器和拒绝处理器,并确保它们会被调用。来看以下例子:

1
2
3
4
5
6
7
8
9
10
11
const Promise = fetch('book.json');

// 原来的履行处理器
Promise.then((response) => {
console.log('Request succeeded', response.status);

// 现在再添加另外一个新的履行处理器
Promise.then((response) => {
console.log('Request succeeded', response.statusText);
});
});

在这段代码中,原来的履行处理器在同一个 Promise 上添加了另一个旅履行处理器,此时该 Promise 已经处于履行状态,所以新的履行处理器被添加到微任务(microtask)的队列中,并在就绪时被调用。拒绝处理器和解决处理器的工作方式也是如此。

1.1.5 处理器和微任务

在常规的程序执行流程中,JavaScript 代码是被当作一个任务来执行的。也就是说,JavaScript 会创建一个新的执行环境,彻底的执行代码,并在完成后退出。比如,网页中的一个按钮对应的onclick处理器被当作一个任务来执行。当该按钮被点击时,JavaScript 会创建一个新的任务,并执行onclick处理器。一旦执行完成,JavaScript 就会原地待命,等待下一次用户交互来执行更多的代码。然而, Promise 的处理器则是以一种不同的方式来执行的。

所有的 Promise 处理器,无论是履行处理器,拒绝处理器,还是解决处理器,都被作为JavaScript 引擎内部的微任务执行。微任务被排在对列中,JavaScript 会在执行完当前任务后立即执行下一个任务。当 Promise 的状态确定后,对then()catch()finally()的调用会将指定的处理器排在微任务队列之中。这与通过 setTimeoutsetInterval 创建定时器不同,这两个函数所创建的新任务会在之后某个时刻执行。在微任务队列中的 Promise 处理器则一定会在同一代码脚本任务中排队的定时器之前执行。你可以通过使用全局的 queenMicroTask 函数来测试这一点。该函数可用于在无 Promise 的情况下创建微任务。

1
2
3
4
5
6
7
8
9
10
11
setTimeout(() => {
console.log('Timer task');

queueMicrotask(() => {
console.log('Microtask task in timer');
});
}, 0);

queueMicrotask(() => {
console.log('Microtask');
});

以上代码创建了一个延迟为 0 毫秒的定时器,并在该定时器中创建了一个新的微任务。此外,这段代码还在定时器以外创建了一个微任务。当这段代码执行时,你会在控制台中看到以下输出内容。

1
2
3
'Microtask';
'Timer task';
'Microtask task in timer';

尽管定时器的延迟被设置为 0 毫秒,但微任务还是先于定时器执行,其次是定时器,最后才是定时器内的微任务。关于微任务(包括所有的 Promise 处理器),最重要的一点是,它们会在主任务完成后立即执行。 这最大限度地缩短了解决 Promise 和对解决本身作出反应之间的时间间隔,从而使 Promise 适用于对运行时效有所要求的情况。

1.2 创建未解决的 Promise

新的 Promise 由 Promise 对象的构造函数来创建。这个构造函数接受一个被称为执行器 (executor) 的函数作为参数。该执行器包含初始化 Promise 所需要的代码。执行器接受两个参数,是分别名为 resolvereject 函数。当执行器完成以后,调用 resolve() 函数以表示 Promise 操作成功完成。当执行器操作失败时,则调用 reject() 函数以示 Promise 操作失败。
下面是一个使用旧的 XMLHttpRequest 浏览器应用程序接口的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
function requestURL(url) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();

// 分配事件处理器
xhr.addEventListener('load', () => {
resolve({
status: xhr.status,
text: xhr.responseText,
});
});

xhr.addEventListener('error', (error) => {
reject(error);
});

// 发出请求
xhr.open('GET', url);
xhr.send();
});
}

const Promise = requestURL('book.json');

// 监听履行和拒绝的状态
Promise.then(
(response) => {
// 履行
console.log(response.status);
},
(reason) => {
// 拒绝
console.log(reason.message);
}
);

在这个例子中,XMLHttpRequest 的调用被包裹在一个 Promise 中。load 事件表明该请求已经成功完成,因此 Promise 执行器在事件处理器中调用了 resolve()。与之类似,error 事件则表明该请求无法顺利完成,因此 Promise 执行器在该事件处理器中调用了 reject。你可以通过重复这个过程来将基于事件的功能转换为基于 Promise 的功能。

执行器的重要性在于它在创建 Promise 时会立即运行。在之前的例子中,我们创建 xhr 对象,分配事件处理器,并在 Promise 从 requestURL() 返回之前启动调用。当执行器调用 resolve()reject() 时, Promise 的状态和值被立即设置,但所有 Promise 处理器作为微任务将暂时不会执行,直到当前的脚本工作完成。假如你在执行器内部立即调用 resolve(),试想会发生什么?

1
2
3
4
5
6
7
8
9
10
const Promise = new Promise((resolve, reject) => {
console.log('Executor');
resolve(42);
});

Promise.then((result) => {
console.log(result);
});

console.log('End of script');

在这个例子中,我们创建了一个 Promise ,并立即调用了 resolve() 函数, Promise 被立即解决,没有任何延迟。然后,我们通过 then() 添加了一个履行处理器来输出操作结果。虽然在添加处理器的时候,该 Promise 已经被解决,但是输出结果仍然如下所示:

1
2
3
'Executor';
'End of script';
42;

这说明, Promise 的执行器首先同步运行并将 “Executor” 打印到控制台中。接着,履行处理器被添加,但是不会立即执行。这段代码会创建一个新的微任务,它将在当前脚本工作完成之后被执行。这意味着 console.log('End of script')这句代码会在执行履行处理器之前被执行,在当前脚本的其他部分完成后才输出 42。

💡 一个 Promise 只能被解决一次。如果你在一个执行器中多次调用 resolve(),那么第一次调用之后的每一次调用都会被忽略。

执行器错误:如果执行器抛出错误,那么 Promise 的拒绝处理器就会被调用,如下所示:

1
2
3
4
5
6
7
const Promise = new Promise((resolve, reject) => {
throw new Error('Something went wrong');
});

Promise.catch((error) => {
console.log(error.message);
});

在这段代码中,执行器故意抛出一个错误。每个执行器内部都有一个隐式的 try-catch,如果执行器抛出错误,那么该错误会被捕获并交给拒绝处理器处理。前面的例子和如下的例子效果相同:

1
2
3
4
5
6
7
const Promise = new Promise((resolve, reject) => {
try {
throw new Error('Something went wrong');
} catch (error) {
reject(error);
}
});

执行器可以捕获任何被抛出的错误,从而简化这种常见的情况的处理流程。如果没有分配拒绝处理器,JavaScript 引擎就会抛出错误并停止运行当前程序。

1.3 创建已解决的 Promise

由于 Promise 执行器的行为是动态的,因此 Promise 对象的构造函数是创建未解决的 Promise的最佳工具。不过,如果想让 Promise 表示先前已经计算出的值,那么仅仅创建一个将值传递给 resolve()reject() 的执行器变没有什么实际意义。有两个方法可以根据确定的值来创建已解决的 Promise。

1.3.1 使用 Promise.resolve()

Promise.resolve()方法接受一个参数并返回一个处于履行状态的 Promise 。这意味着如果你已经知道了 Promise 的值,就不必再提供执行器。来看一下这个例子:

1
2
3
4
const promise = Promise.resolve(42);
promise.then((value) => {
console.log(value); // 42
});

自带代码创建了一个已履行的 Promise ,因此履行处理器会收到 42 这个值。和本章中的其他例子一样,履行处理器在当前脚本工作完成后作为一个微任务执行。如果给这个 Promise 添加了拒绝处理器,那么拒绝处理器将永远不会被调用,因为这个 Promise 永远不会处于拒绝状态。

如果你把一个 Promise 传递给Promise.resolve(),那么该方法就会返回你传递的那个 Promise 对象。

1
2
3
4
const promise1 = Promise.resolve(42);
const promise2 = Promise.resolve(promise1);

console.log(promise1 === promise2); // true

1.3.2 使用 Promise.reject()

你也可以通过使用Promise.reject()来创建处于拒绝状态的 Promise 对象。这与Promise.resolve()的工作原理类似,只不过创建的 Promise 是处于拒绝状态的,如下所示:

1
2
3
4
const promise = Promise.reject(42);
promise.catch((error) => {
console.log(error); // 42
});

添加的该 Promise 中的任何额外的拒绝处理器都会被调用,但履行处理器不会被调用,因为该 Promise 永远不会处于履行状态。

1.3.3 非 Promise 的 thenable 对象

Promise.resolve()Promise.reject()也接受非 Promise 的 thenable 对象作为参数。当传递一个非 Promise 的 thenable 对象时,这些方法会基于该 thenable 对象已确定的值和状态,创建一个新的 Promise 对象。

当一个对象有一个 then() 方法,并且可接参数 resolve 函数和 reject 函数作为参数,我们就认为该对象是一个非 Promise 的 thenable 对象,举例如下:

1
2
3
4
5
const thenable = {
then: (resolve, reject) => {
resolve(42);
},
};

除了 then() 方法,这个例子中的 thenable 对象没有其他与 Promise 对象相关联的特征。你可以通过调用Promise.resolve()来将 thenable 对象转换为处于履行状态的 Promise 对象:

1
2
3
4
5
6
7
8
9
10
11
const thenable = {
then: (resolve, reject) => {
resolve(42);
},
};

const promise = Promise.resolve(thenable);

promise.then((value) => {
console.log(value); // 42
});

在这个例子中,Promise.resolve()通过调用 thenable.then() 来确定该 Promise 的状态,因为在 then() 方法中调用了 resolve(42),所以 thenable 的 Promise 对象处于履行状态。因此,以上代码创建了一个处于履行状态的 Promise 对象,名为 promise。该 Promise 对象的履行处理器接收来自 thenable 对象的 42,作为其值。

同理,可以用Promise.resolve()来从 thenable 创建一个处于拒绝状态的 Promise ,如下所示:

1
2
3
4
5
6
7
8
9
10
11
const thenable = {
then: (resolve, reject) => {
reject(42);
},
};

const promise = Promise.resolve(thenable);

promise.catch((value) => {
console.log(value); // 42
});

这个例子与上一个例子相似,只不过 thenable 对象处于拒绝状态。当 thenable.then() 执行时,它会创建一个处于拒绝状态的 Promise 对象,其值为 42,这个值会被传递给 Promise 的拒绝处理器。

Promise.resolve()Promise.reject()的这种功能使得它们成为处理非 Promise 值和 thenable 对象的有用工具。在 2015 年 ECMAScript 引入 Promise 之前,很多库使用 thenable 对象。 因此, 能够将 thenable 对象转换为 Promise 对象对于兼容之前的库来说非常重要。

当你不确定一个对象是否是一个 Promise 时,通过 Promise.resolve()Promise.reject() 传递该对象是找出答案的最佳方法,因为 Promise 只会被原样传回。

1.4 小结

  • Promise 是值的”临时站位符“,这个值将在以后某个时间点作为某个异步操作的结果来提供给当前程序。 你可以使用 Promise 来表示操作的结果,而无需使用事件处理器或回调函数。
  • Promise 有三种状态:待定(pending)、履行(fulfilled)和拒绝(rejected)。一个 Promise 从待定状态(未确定的状态)开始,在成功执行时进入履行状态,在失败时进入拒绝状态。(履行状态和拒绝状态都是已确定的状态)无论是哪种情况都可以通过添加处理器来表明该 Promise 的状态已确定。
  • then()方法可以用于分配履行处理器和拒绝处理器,catch() 方法可用于分配拒绝处理器。finally()方法可用于分配解决处理器。无论操作结果是成功还是失败,解决处理器(finally)都会被调用。
  • 因为所有的 Promise 处理器都是被作为微任务来执行的,所以他们在当前脚本工作完成之前不会执行。
  • 你可以使用构造函数来创建处于未确定状态的 Promise 对象,该构造函数接受一个执行器函数(Executor)作为其唯一的参数。执行器函数被传入resolvereject两个参数,分别用以表明 Promise 是成功还是失败。
  • 执行器在创建 Promise 时立即执行。这与被作为微任务运行的处理器不同,执行器抛出的任何错误都会被自动捕获并传递给 reject。
  • 可以使用 Promise.resolve() 来创建处于履行状态的 Promise,或使用 Promise.reject() 来创建处于拒绝状态的 Promise。 这两个方法都会把传入的参数包裹在 Promise 中(如果该参数不是一个 Promise 对象,也不是一个非 Promise 的 thenable 对象),并创建一个新的 Promise 对象,或将原来的 Promise 对象原样传递。当你不确定某对象是否是 Promise,但又希望它表现得像 Promise 时,这些方法很有帮助。