Promises 常见的3个错误

时至今日,即使有 async / await 的引入,JavaScript 中 Promises 的编写规则对于所有的 JS 开发者来说仍然是必不可少的知识。

JavaScript 在处理异步问题上和其它编程语言不同。因此,即使具有丰富经验的开发人员有时也会陷入误区。我亲身看到过优秀的 Python 或 Java 程序员在为 Node.js 或浏览器编码时犯了非常愚蠢的错误。

为了避免这些错误,JavaScript 中的 Promises 有许多编写细节需要考虑。其中有一些纯粹是语言风格问题,但也有许多是实际引入、难以跟踪的错误。因此,我决定编写一个清单,列出开发人员在使用 Promises 编程时遇到的三个最常见的错误。

将所有内容包装在 Promise 构造函数中

第一个错误也是最为明显的错误之一,但是我发现开发者犯这个错误的频率出奇的高。

当第一次学习 Promises 时,你会了解到 Promise 的构造函数,这个构造函数可以用来创建一个新的 Promises 对象。

也许因为人们通常是通过将一些浏览器 API(例如 setTimeout)包装在 Promise 构造函数中这种方式来开始学习的,所以在他们心中根深蒂固地认为创建 Promise 对象的唯一方法是使用构造函数。

因此,通常会这样写:

1
2
3
4
5
6
const createdPromise = new Promise(resolve => {
somePreviousPromise.then(result => {
// 对 result 进行一些操作
resolve(result);
});
});

可以看到,为了对 somePreviousPromise 的结果 result 进行一些操作,有些人使用了 then,但是后来决定将其再次包装在一个 Promise 的构造函数中,为的是将该操作的结果存储在 createdPromise 的变量中,大概是为了稍后对该 Promise 进行更多操作。

这显然是没有必要的。then 方法的全部要点在于它本身会返回一个 Promise,它表示的是执行 somePreviousPromise 后再执行 then 中的的回调函数,then 的参数是 somePreviousPromise 成功执行返回的结果。

所以,上一段代码大致等价于:

1
2
3
4
const createPromise = somePreviousPromise.then(result => {
// 对 result 进行一些操作
return result
})

如此编写,会简洁很多。

但是,为什么我说它只是大致等价呢?区别在哪里?

经验不足且不细心观察的话可能很难发现,实际上两者在错误处理上存在巨大的差异,这种差异比第一段代码的冗余问题更为重要。

假设 somePreviousPromise 出于某些原因失败了且抛出错误。例如,这个 Promise 里发送了一个 HTTP 请求,而 API 响应 500 错误。

事实证明,在上一段代码中,我们将一个 Promise 包装到另一个 Promise 中,我们根本无法捕获该错误。为了解决此问题,我们必须进行以下更改:

1
2
3
4
5
6
const createdPromise = new Promise((resolve, reject) => {
somePreviousPromise.then(result => {
// 对 result 进行一些操作
resolve(result);
}, reject);
});

我们简单的在回调函数中添加了一个 reject 参数,然后通过将其作为第二个参数传递给 then 的方式来使用它。请务必记住,then 方法接受第二个可选参数来进行错误处理,这一点非常重要。

现在如果 somePreviousPromise 出于某些原因失败了,reject 函数将会被调用,并且我们将能够一如往常地处理 createdPromise 上的错误。

这样是否解决了所有问题?抱歉,并没有。

我们处理了 somePreviousPromise 自身可能发生的错误,但是我们仍然无法控制作为 then 方法第一个参数的回调函数中发生的情况。在注释区域 // 对 result 进行一些操作 执行的代码可能会有一些错误,如果这块地方的代码抛出任何错误,那 then 方法的第二个参数 reject 依旧捕获不到这些错误。

这是因为作为 then 方法的第二个参数的错误处理函数只对 Promise 链上当前 then 之前发生的错误作出响应。

因此,最合适的(也是最终的)解决方案应该如下:

1
2
3
4
5
6
const createdPromise = new Promise((resolve, reject) => {
somePreviousPromise.then(result => {
// 对 result 进行一些操作
resolve(result);
}).catch(reject);
});

注意,这次我们使用了 catch 方法 —— 因为它将在第一个 then 之后被调用,它将捕获到 Promise 链上抛出的所有错误。无论是 somePreviousPromise 还是 then 中的回调失败了,Promise 都将按预期处理这些情况。

从上述示例可以发现,在 Promise 的构造函数中包装代码时,有很多细节问题需要处理。这就是为什么最好使用 then 方法创建新的 Promises 的原因,如第二段代码所示。它不仅看起来优雅,并且还可以帮助我们避免那些极端情况。

串行调用 then 与并行调用 then 的比较

由于许多程序员都有着面向对象的编程背景,因此对他们来说,调用一个方法会更改一个对象,而非创建一个新的对象,这很稀松平常。

这或许也是我看到有人对于「在 Promise 上调用 then 方法时」到底发生了什么会感到困惑的原因。

比较下面两段代码:

1
2
3
4
5
const somePromise = createSomePromise();

somePromise
.then(doFirstThingWithResult)
.then(doSecondThingWithResult);
1
2
3
4
5
6
7
const somePromise = createSomePromise();

somePromise
.then(doFirstThingWithResult);

somePromise
.then(doSecondThingWithResult);

它们所做之事是否相同?看起来似乎相同,毕竟,两段代码都在 somePromise 上调用了两次 then,对吗?

不,这又是一个非常普遍的误区。实际上,这两段代码做的事情完全不同。如果不完全理解两段代码中正在做的事情,可能会导致出现非常棘手的错误。

正如我们在之前的章节中所说,then 方法会创建一个完全新的、独立的 Promise。这意味着在第一段代码中,第二个 then 方法不是在 somePromise 上调用,而是在一个新的 Promise 对象上调用,这段代码表示等待 somePromise 的状态变为成功后立刻调用 doFirstThingWithResult。然后给新返回的 Promise 实例添加一个回调操作 doSecondThingWithResult

实际上,这两个回调将会一个接着一个地执行 —— 可以确保只有在第一个回调执行完成且没有任何问题之后,才会调用第二个回调。此外,第一个回调将会接收 somePromise 返回的值作为参数,但是第二个回调函数将接收 doFirstThingWithResult 函数返回的值作为参数。

另一方面,在第二段代码中,我们在 somePromise 上调用两次 then 方法,基本上忽略了从该方法返回的两个新的 Promises 对象。因为 then 在完全相同的 Promise 实例上被调用了两次,因此我们无法确定首先执行哪个回调,这里的执行顺序是不确定的。

从某种意义上说,这两个回调应该是独立的,并且不依赖于任何先前调用的回调,我有时将其视为 “并行” 的执行。但是,当然,实际上,JS 引擎同一时刻只能执行一个功能 —— 你根本无法知道它们将以什么顺序调用。

两段代码的第二个不同之处是,在第二段代码中 doFirstThingWithResult 和 doSecondThingWithResult 都会接收到同样的参数 —— somePromise 成功执行返回的结果,两个回调函数的返回值在这个示例中被完全忽略掉了。

创建后立即执行 Promise

这个误区出现的原因也是因为大部分程序员有着丰富的面向对象编程经验。

在面向对象编程的思想中,确保对象的构造函数自身不执行任何操作通常被认为是一种很好的实践。举个例子,一个代表数据库的对象在使用 new 关键字调用其构造函数时不应该启动与数据库的链接。

相反,应该提供一个特定的方法,如调用一个名为 init 的方法 —— 它将显式地创建连接。这样,一个对象不会因为已被创建而执行任何期望之外的操作。它会按照程序员的明确要求来执行。

但这「不是 Promises 的工作方式」。

考虑如下示例:

1
2
3
4
const somePromise = new Promise(resolve => {
// 创建 HTTP 请求
resolve(result);
});

你可能会认为发出 HTTP 请求的函数未在此处调用,因为它包装在 Promise 构造函数中。实际上,许多程序员希望 somePromise 上执行 then 方法之后它才被调用。

但事实并非如此。创建该 Promise 后,回调将立即执行。这意味着当您在创建 somePromise 变量后进入下一行时,你的 HTTP 请求可能已被执行,或者说已存在执行队列里。

我们说 Promise 是 “eager” 的,因为它尽可能快地执行与其关联的动作。相反,许多人期望 Promises 是 “lazy” 的,即仅在必要时调用(例如,当 then 方法在 Promise 上首次被调用)。这是一个误区,Promise 永远是 eager 的,而非 lazy 的。

但是,如果您想要延迟执行 Promise,应该怎么做?如果您希望延迟发出该 HTTP 请求怎么办?Promises 中是否内置了某种奇特的机制,可以让您执行类似的操作?

答案有时会超出开发者们的期望。函数是一种 lazy 机制。仅当程序员使用 () 语法显式调用它们时,才执行它们。仅仅定义一个函数实际上并不能做任何事情。因此,要使 Promise 成为 “lazy”, 最佳方法是将其简单地包装在函数中!

具体代码如下:

1
2
3
4
const createSomePromise = () => new Promise(resolve => {
// 创建 HTTP 请求
resolve(result);
});

现在,我们将 Promise 构造函数的调用操作包装在一个函数中。事实上它还没有真正被调用。我们还将变量名从 somePromise 更改为 createSomePromise,因为它不再是一个 Promise 对象 —— 而是一个创建并返回 Promise 对象的函数。

Promise 构造函数(以及带有 HTTP 请求的回调函数)仅在执行该函数时被调用。因此,现在我们有了一个 lazy 的 Promise,只有在我们真正想要它执行时才去执行它。

此外,请注意,它还附带提供了另一种功能。我们可以轻松地创建另一个可以执行相同操作的 Promise 对象。

如果出于某些奇怪的原因,我们希望进行两次相同的 HTTP 请求并同时执行这些请求,则只需要两次调用 createSomePromise 函数。又或者,如果请求由于任何原因失败了,我们可以使用相同的函数重新请求。

这表明将 Promises 包装在函数(或方法)中非常方便,因此对于 JavaScript 开发人员来说,使用这种模式开发应该要变得很自然而然。

结语

本文展示了我经常看到开发者使用 Promise 时所犯的三种类型的错误,因为他们对 JavaScript 中的 Promises 的理解仅停留在表面。

参考资料

  • [印记中文]
  • 版权声明: 本博客所有文章,未经许可,任何单位及个人不得做营利性使用!转载请标明出处!如有侵权请联系作者。
  • Copyrights © 2015-2024 翟天野

请我喝杯咖啡吧~