![31aaef894e66802b035a98c84c7fc028.png](https://i-blog.csdnimg.cn/blog_migrate/6e8d30a056d4646c3f60c59d53563f37.jpeg)
来源:公众号《前端全栈开发者》
Promises统治着JavaScript。即使是在引入 async/await的今天,它们仍然是任何JS开发者的必备知识。
但是JavaScript与其他编程语言的不同之处在于如何处理异步性。因此,即使具有丰富经验的开发人员有时也会陷入陷阱。我亲自看到伟大的Python或Java程序员在为Node.js或浏览器编码时犯了非常愚蠢的错误。
为了避免那些错误,JavaScript中的Promises有许多微妙之处。其中一些将是纯粹的风格,但许多可以引入实际的,难以跟踪的错误。正因为如此,我决定编制一个简短的清单,列出我所看到的开发人员在使用Promises编程时最常见的三个错误。
将所有内容包装在Promise构造函数中
这第一个错误是最明显的错误之一,但我却看到开发者竟然经常这样做。
当你第一次学习Promise时,你读到了Promise构造函数,它可以用来创建新的Promise。
也许是因为人们开始学习时通常是通过在Promise构造函数中包装一些浏览器api(比如 setTimeout)来学习的,所以在他们的脑海中,创建Promise的唯一方法就是使用构造函数。
因此,他们通常会得到这样的代码:
const createdPromise = new Promise(resolve => { somePreviousPromise.then(result => { // 对result做些什么 resolve(result); });});
你可以看到,为了对 somePreviousPromise 的 result 做一些事情,有人当时就用了,但后来又决定把它包在一个Promise构造函数中,以便把这个计算结果存储在 createPromise 变量中,大概是为了以后对这个Promise再做一些操作。
这当然是不必要的。then 方法的全部意义在于,它本身返回一个Promise,代表执行 somePreviousPromise,然后在 somePreviousPromise 得到解析值后,执行传递给 then 的回调作为参数。
因此,上一片段大致相当于:
const createdPromise = somePreviousPromise.then(result => { // 对result做些什么 return result;});
好多了,不是吗?
但为什么我写的只是大致相当?差别在哪里呢?
对于非训练有素的人来说,可能很难发现,但其实在错误处理上有巨大的差异,比第一个片段的丑陋啰嗦重要得多。
假设 somePreviousPromise 出于任何原因失败并抛出错误。另一个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 方法捕获。
这是因为作为第二个参数传递的错误处理方法只对方法链中较早发生的错误作出反应。
因此,正确的(也是最终的)修复将如下所示:
const createdPromise = new Promise((resolve, reject) => { somePreviousPromise.then(result => { // 对result做些什么 resolve(result); }).catch(reject);});
注意,这次我们使用了 catch 方法,因为它是在第一个方法之后调用的,所以它将捕获在它上面的链中抛出的任何错误。因此,不管 somePreviousPromise 还是 then 的回调都将失败——我们的Promise将在两种情况下均按预期处理它。
如你所见,在Promise构造函数中包装代码时,有很多微妙之处。这就是为什么最好使用 then 方法创建新的Promises的原因,如第二段所示。不仅看起来更好,而且我们也将避免那些极端情况。
![65ccf3541e65e3e590699ca590ae8a73.png](https://i-blog.csdnimg.cn/blog_migrate/151a46aef8cfe84488ceed268faa76a6.jpeg)
连续then vs 并行then
因为许多程序员都有面向对象编程的背景,所以对他们来说,方法改变一个对象而不是创建一个新对象是很自然的。
这可能是为什么我看到人们对在Promise上调用 then 方法时到底发生了什么感到困惑的原因。
比较这两个代码段:
const somePromise = createSomePromise();somePromise .then(doFirstThingWithResult) .then(doSecondThingWithResult);
const somePromise = createSomePromise();somePromise .then(doFirstThingWithResult);somePromise .then(doSecondThingWithResult);
他们做的是同样的事情吗?可能看起来是这样。毕竟,这两段代码都涉及到对 somePromise 调用两次,对吧?
不,这是一个非常普遍的误解。实际上,这两个代码段的行为完全不同。如果不完全了解两者中正在发生的事情,可能会导致棘手的错误。
正如我们在上一节中所写,then 方法将创建一个全新的独立Promise。这意味着在第一个片段中,second then 方法不是在 somePromise 上调用的,而是在一个新的Promise对象上调用的,它封装了(或代表了)等待 somePromise 得到解析,然后紧接着调用 doFirstThingWithResult。然后,向此新的Promise实例添加 doSecondThingWithResult 回调。
实际上,这两个回调将一个接一个地执行——我们保证只有在第一个回调完成执行之后,第二个回调才会被调用,而不会出现任何问题。更重要的是,第一个回调会得到一个由 somePromise 返回的值作为参数,但第二个回调会得到从 doFirstThingWithResult 函数返回的任何值作为参数。
另一方面,在第二个代码片段中,我们两次调用了 somePromise 上的 then 方法,基本上忽略了从该方法返回的两个新的Promises。因为在Promise的同一个实例上调用了两次,所以我们不能保证哪个回调会先被执行。这里的执行顺序是未定义的。
我有时认为它是“并行”执行,在某种意义上,这两个回调应该是独立的,不依赖于前面调用的任何一个回调。但当然,在现实中,JS引擎一次只执行一个函数——你根本不知道它们将被调用的顺序。
第二个不同之处在于,第二个代码片段中的 doFirstThingWithResult 和 doSecondThingWithResult 都将接收相同的参数—— somePromise 被解析为的值。在该示例中,两个回调函数返回的值完全被忽略。
![fc69ad8181692d863979633eb0b303d9.png](https://i-blog.csdnimg.cn/blog_migrate/85e451cc943766b8b980e413ede37a11.jpeg)
创建后立即执行Promise
这种误解还源于大多数编码人员通常在面向对象编程中经验丰富的事实。
在这种范式中,通常认为确保对象构造函数本身不执行任何操作是一种好的做法。例如一个代表数据库的对象,当它的构造函数用 new 关键字调用时,不应该启动与数据库的连接。
相反,最好提供一种特殊的方法,例如称为 init 的方法,它将显式创建一个连接。这样,对象不会仅由于已被启动而执行任何意外动作,它耐心地等待程序员明确要求执行动作。
但这不是Promises的工作方式。
考虑示例:
const somePromise = new Promise(resolve => { // make HTTP request resolve(result);});
你可能会认为发出HTTP请求的函数未在此处调用,因为它包装在Promise构造函数中。实际上,许多程序员希望仅在 somePromise 上执行 then 方法之后才调用它。
但这里不是,创建该Promise后,回调将立即执行。这意味着,当你在创建 somePromis 变量后的下一行中,你的HTTP请求可能已经被执行了,或者至少是被调度了。
我们说Promise是“渴望的”,因为它尽可能快地执行与其关联的动作。相比之下,很多人都期望Promise是“懒惰的”——即只有在绝对必要的情况下才会执行某个动作(例如当一个 then 被第一次调用Promise时)。这是一个误解,Promise总是急切的,从不偷懒。
但是,如果你想稍后再执行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的程序员经常会犯一个相反的错误。他们对Observable进行编码,就好像它们渴望一样(例如Promises),而实际上它们是懒惰的。因此,例如,将Observable包裹在函数或方法中通常没有任何意义,实际上甚至是有害的。
结束
我向你展示了三种类型的错误,这些错误是我经常看到的,他们仅仅从表面上了解JavaScript中的Promises。
你在你的代码或其他人的代码中是否遇到过任何有趣的错误类型?如果有,请在评论中分享。