在 JavaScript 中使用 Promises 时最常见的 3 个错误
Promises 统治着 JavaScript。即使在 async/await 出现之后的今天,它们仍然是每个 JS 开发人员的必备知识。
但 JavaScript 在处理异步性方面与其他编程语言有所不同。正因如此,即使是经验丰富的开发人员有时也会掉入它的陷阱。我亲眼见过优秀的 Python 或 Java 程序员在为 Node.js 或浏览器编写代码时犯下非常愚蠢的错误。
JavaScript 中的 Promises 有很多微妙之处,必须注意才能避免这些错误。有些只是纯粹的风格问题,但很多问题可能会引发实际的、难以追踪的错误。因此,我决定整理一份简短的清单,列出我见过的开发者在使用 Promises 编程时最常犯的三个错误。
将所有内容包装在 Promise 构造函数中
第一个错误是最明显的错误之一,但我却发现开发人员经常犯这个错误。
当您第一次了解 Promises 时,您会读到有关 Promise 构造函数的信息,它可用于创建新的 Promises。
也许是因为人们通常通过setTimeout
在 Promise 构造函数中包装一些浏览器 API(如)来开始学习,所以他们根深蒂固地认为创建 Promise 的唯一方法是使用构造函数。
因此,他们通常会得到如下代码:
const createdPromise = new Promise(resolve => {
somePreviousPromise.then(result => {
// do something with the result
resolve(result);
});
});
您可以看到,为了对某人使用的result
进行某些操作,但后来决定将其再次包装在 Promise 构造函数中,以便将该计算存储在变量中,大概是为了稍后对该 Promise 进行更多操作。somePreviousPromise
then
createdPromise
这当然是不必要的。该方法的重点then
在于它本身返回一个 Promise,表示执行somePreviousPromise
,然后在得到一个值then
后执行作为参数传递给它的回调。somePreviousPromise
因此前面的代码片段大致相当于:
const createdPromise = somePreviousPromise.then(result => {
// do something with result
return result;
});
好多了,不是吗?
但为什么我说只是大致相当呢?区别在哪里呢?
对于未经训练的人来说可能很难发现,但事实上在错误处理方面存在巨大差异,这比第一个代码片段的丑陋冗长更重要。
假设somePreviousPromise
由于某种原因失败并抛出错误。也许这个 Promise 底层正在发出 HTTP 请求,而 API 响应了 500 错误。
事实证明,在前面的代码片段中,我们将一个 Promise 包装到另一个 Promise 中,我们根本无法捕获该错误。为了解决这个问题,我们必须进行以下更改:
const createdPromise = new Promise((resolve, reject) => {
somePreviousPromise.then(result => {
// do something with the result
resolve(result);
}, reject);
});
我们只需reject
向回调函数添加一个参数,然后将其作为第二个参数传递给then
方法即可。务必记住,该then
方法接受第二个可选参数,用于错误处理。
现在,如果somePreviousPromise
由于任何原因失败,reject
函数将被调用,我们将能够createdPromise
像平常一样处理错误。
那么这能解决所有问题吗?很遗憾,不能。
我们处理了方法本身可能出现的错误somePreviousPromise
,但仍然无法控制then
作为第一个参数传递给该方法的函数内部发生的情况。在我们添加注释的位置执行的代码可能会出现一些错误。如果此处的代码抛出任何类型的错误,它都不会被作为方法第二个参数传递的函数// do something with the result
捕获。reject
then
这是因为作为第二个参数传递的错误处理函数then
仅对方法链中较早发生的错误做出反应。
因此,正确的(最终的)修复方法将如下所示:
const createdPromise = new Promise((resolve, reject) => {
somePreviousPromise.then(result => {
// do something with the result
resolve(result);
}).catch(reject);
});
注意,这次我们使用了catch
方法,因为它在第一个方法之后then
被调用,所以它会捕获上一个方法链中抛出的任何错误。因此,无论somePreviousPromise
或 中的回调是否then
失败,我们的 Promise 都会按预期进行处理。
如你所见,在 Promise 构造函数中包装代码有很多微妙之处。这就是为什么最好直接使用then
方法来创建新的 Promise,正如我们在第二个代码片段中展示的那样。这样不仅看起来更美观,还能避免一些特殊情况。
连续 then 与并行 then
由于许多程序员具有面向对象编程背景,因此对于他们来说,方法改变对象而不是创建新对象是很自然的。
then
这可能就是为什么我看到人们对在 Promise 上调用方法时究竟会发生什么感到困惑。
比较这两个代码片段:
const somePromise = createSomePromise();
somePromise
.then(doFirstThingWithResult)
.then(doSecondThingWithResult);
const somePromise = createSomePromise();
somePromise
.then(doFirstThingWithResult);
somePromise
.then(doSecondThingWithResult);
它们做的事情一样吗?看起来是的。毕竟,这两个代码片段都涉及then
两次调用somePromise
,对吧?
不,这是一个很常见的误解。事实上,这两个代码片段的行为完全不同。如果不完全理解它们之间到底发生了什么,可能会导致一些棘手的错误。
正如我们在上一节中所写,then
方法会创建一个全新的、独立的 Promise。这意味着在第一个代码片段中,第二个then
方法不是在 上调用somePromise
,而是在一个新的 Promise 对象上调用,该对象封装(或表示)等待somePromise
得到解决,然后doFirstThingWithResult
立即调用。然后我们doSecondThingWithResult
为这个新的 Promise 实例添加一个回调。
实际上,这两个回调会相继执行——我们保证只有在第一个回调顺利完成执行后才会调用第二个回调。此外,第一个回调会将 的返回值作为参数somePromise
,而第二个回调则会将 函数的返回值作为参数doFirstThingWithResult
。
另一方面,在第二段代码中,我们两次调用了then
on 方法somePromise
,并且忽略了该方法返回的两个新的 Promise。由于then
两次调用的都是同一个 Promise 实例,因此我们无法保证哪个回调会先执行。这里的执行顺序是未定义的。
我有时会把它想象成“并行”执行,从某种意义上说,这两个回调应该是独立的,并且不依赖于任何一个回调函数被更早调用。但实际上,JS 引擎一次只执行一个函数——你根本不知道它们会以什么顺序被调用。
第二个区别是,第二个代码片段中的doFirstThingWithResult
和都doSecondThingWithResult
将接收相同的参数——解析后的值somePromise
。在该示例中,两个回调的返回值都被完全忽略了。
创建后立即执行 Promise
这种误解也源于这样一个事实:大多数程序员通常都有面向对象编程的经验。
在该范式中,确保对象构造函数本身不执行任何操作通常被认为是一种良好做法。例如,表示数据库的对象在使用new
关键字调用其构造函数时,不应启动与数据库的连接。
相反,最好提供特殊的方法(例如,调用init
)来显式地创建连接。这样,对象就不会仅仅因为被初始化就执行任何意外的操作。它会耐心地等待程序员明确请求执行操作。
但 Promises 的工作方式并非如此。
考虑以下示例:
const somePromise = new Promise(resolve => {
// make HTTP request
resolve(result);
});
你可能会认为,发出 HTTP 请求的函数在这里不会被调用,因为它被包装在 Promise 构造函数中。事实上,许多程序员认为只有在then
执行了方法之后才会调用它somePromise
。
但事实并非如此。回调函数会在 Promise 创建后立即执行。这意味着,当你创建somePromise
变量并进入下一行时,你的 HTTP 请求可能已经在执行中,或者至少已经被调度了。
我们说 Promise 是“eager”类型的,因为它会尽快执行与其关联的操作。相反,许多人认为 Promise 是“lazy”类型的——即仅在绝对必要时才执行操作(例如,then
在 Promise 中第一次调用 a 时)。这是一种误解。Promise 始终是“eager”类型的,绝不会是“lazy”类型的。
但是,如果您想稍后执行 Promise,该怎么办?如果您想暂停发出 HTTP 请求,该怎么办?Promise 中是否存在某种神奇的机制,可以让您实现这样的功能?
答案比开发人员有时预想的要明显得多。函数是一种惰性机制。只有当程序员使用()
括号语法显式调用它们时,它们才会执行。简单地定义一个函数实际上还不会做任何事情。因此,让 Promise 变得惰性的最佳方法是……简单地将其包装在一个函数中!
看一下:
const createSomePromise = () => new Promise(resolve => {
// make HTTP request
resolve(result);
});
现在,我们将相同的 Promise 构造函数调用包装在一个函数中。因此,实际上还没有调用任何函数。我们还将变量名从 改为somePromise
,createSomePromise
因为它不再是一个真正的 Promise 了——它是一个创建并返回 Promise 的函数。
Promise 的构造函数(以及带有 HTTP 请求的回调函数)只有在我们执行该函数时才会被调用。所以现在我们有了一个惰性 Promise,它只有在我们真正需要的时候才会被执行。
更重要的是,请注意,我们免费获得了另一项功能。我们可以轻松创建另一个执行相同操作的 Promise。
如果出于某些原因,我们想同时执行两次相同的 HTTP 调用,那么只需立即调用该createSomePromise
函数两次即可。或者,如果请求因任何原因失败,我们可以使用同一个函数重试。
这表明将 Promises 包装在函数(或方法)中非常方便,因此对于 JavaScript 开发人员来说,这是一种自然而然的模式。
讽刺的是,如果你读过我的文章《Promises vs Observables》,你就会知道,刚接触 Rx.js 的程序员经常会犯一个相反的错误。他们把 Observables 写得像 Eager(像 Promises 一样)一样,但实际上它们是 Lazy 的。所以,比如说,把 Observables 包装在函数或方法中通常没有任何意义,实际上甚至可能有害。
结论
我向您展示了三种我经常看到的对 JavaScript 中的 Promises 仅有肤浅了解的开发人员所犯的错误。
你在自己的代码或他人的代码中遇到过哪些有趣的错误?如果有,请在评论中分享。
如果您喜欢这篇文章,请在Twitter上关注我,我将在那里发布更多关于 JavaScript 编程的文章。
感谢阅读!
(图片由Sebastian Herrmann在Unsplash上拍摄)
文章来源:https://dev.to/mpodlasin/3-most-common-mistakes-when-using-promises-in-javascript-oab