阅读 42

在JavaScript中使用Promises时最常见的3个错误

Javascript 中的 Promises 规则,即使在现在,随着 async / await 的引入,对于所有的 JS 开发者来说,它们仍然是必不可少的知识。

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

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


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

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

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

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

因此,他们通常会这样写代码:

const createdPromise = new Promise(resolve => {
    somePreviousPromise.then(result => {
        // 对 result 进行一些操作
        resolve(result);
    });
});
复制代码

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

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

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

const createPromise = somePreviousPromise.then(result => {
    // 对 result 进行一些操作
    return result
})
复制代码

看起来是不是好多了?

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

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

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

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

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 之前发生的错误作出响应。

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

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 方法时到底发生了什么会感到困惑这种现象的原因。

比较下面两段代码:

const somePromise = createSomePromise();

somePromise
  .then(doFirstThingWithResult)
  .then(doSecondThingWithResult);
复制代码
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引擎同一时刻只能执行一个功能——你根本不知道将以什么顺序调用它们。

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


创建后立即执行Promise

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

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

相反,提供一个特定的方法是个更好的办法——例如名为 init 的方法——它将显式地创建连接。这样,一个对象不会仅因为已被启动而执行任何期望之外的操作。它耐心的等待程序员的明确要求来执行动作。

但这不是 Promises 的工作方式。

考虑如下示例:

const somePromise = new Promise(resolve => {
  // 创建 HTTP 请求
  resolve(result);
});
复制代码

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

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

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

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

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

请看以下代码:

const createSomePromise = () => new Promise(resolve => {
  // 创建 HTTP 请求
  resolve(result);
});
复制代码

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

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

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

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

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

具有讽刺意味的是,如果您阅读了我写的文章 Promises vs Observables ,您就会知道被介绍给 Rx.js 的程序员经常会犯一个相反的错误。他们对 Observable 进行编码,就好像它们是急切的(如Promises 一样),而实际上它们是懒惰的。因此,举个例子,将 Observables 封装在函数或方法中通常没有任何意义,实际上甚至是有害的。


结语

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

你是否在你的代码或者其他人的代码中遇到过一些有趣的类型的错误,如果有,请在评论区留言。

感谢您的阅读!