翻译We-have-a-problem-with-promises

63次阅读

共计 14899 个字符,预计需要花费 38 分钟才能阅读完成。

原文:https://pouchdb.com/2015/05/1…

JavaScripts 的朋友们,是时候承认了:we have a problem with promises。不,不是 promises 本身。正如 A + spec 所定义的,promises 是非常棒的。

在过去的一年里,当我看到许多程序员在 PouchDB API 和其他 promise-heavy APIs 上挣扎时,我发现了一个大问题:我们中的许多人使用 promises 时没有真正理解它们

如果你觉得很难相信,想想我最近在 Twitter 上发布的这个谜题:

Q: What is the difference between these four promises?

doSomething().then(function () {return doSomethingElse();
});

doSomething().then(function () {doSomethingElse();
});

doSomething().then(doSomethingElse());

doSomething().then(doSomethingElse);


如果你知道答案,那么恭喜你:你是一个承诺忍者。我允许您停止阅读此日志。对于其他 99.99% 的人来说,你是一个很好的同伴。没有人回应我的推特,也没有人能解决这个问题,我自己对 #3 的答案感到惊讶。是的,即使我写了测验!答案在这篇文章的最后,但首先,我想探讨一下为什么 promises 一开始就那么棘手,为什么我们中的许多人——新手和专家——会被 promises 绊倒。我还将提供我认为是独特见解的东西,一个奇异的把戏,它使 promises 很容易理解。是的,我真的相信在那之后他们不会那么难!但首先,让我们挑战一些关于 promises 的常见假设。

Wherefore promises?

如果你读过有关 promises 的文献,你会经常发现对 the pyramid of doom(https://medium.com/@wavded/managing-node-js-callback-hell-1fe03ba8baf)的引用,其中有一些可怕的 callback- y 代码稳步地向屏幕的右侧延伸。promises 确实解决了这个问题,但它不仅仅是缩进。正如 "Redemption from Callback Hell"(http://youtu.be/hf1T_AONQJU)中所解释的,callbacks 的真正问题是它们剥夺了我们 return 和 throw 这样的关键字。相反,我们的程序的整个流程基于 side effects:一个函数偶然调用另一个函数。事实上,callbacks 做了一些更险恶的事情:它们剥夺了我们的 stack, stack 在编程语言中我们通常认为是理所当然的。写没有 stack 的代码很像驾驶一辆没有刹车踏板的汽车:你不会意识到你有多么需要它,直到你伸手去拿它而它不在那里。promises 的全部要点是就是把异步时丢失的语言基础还给我们:return, throw, 和 stack。但是你必须知道如何正确地使用 promises,才能利用它们。

Rookie mistakes

有些人试图把承诺解释成 cartoon(https://www.andyshora.com/promises-angularjs-explained-as-cartoon.html), 或者以一种非常面向名词的方式:“哦,正是你可以传递的东西代表了一个异步值。”我觉得这样的解释没什么帮助。对我来说,promises 都是关于代码结构和流程的。所以我认为最好是回顾一些常见的错误,并展示如何修复它们。我把这些叫做 "rookie mistakes",意思是,“你现在是新手了,孩子,但你很快就会成为职业选手。”Quick digression::“promises”对不同的人来说意味着很多不同的事情,但是在本文中,我将只讨论官方规范 (https://promisesaplus.com/),就像 window.Promise 在现代浏览器中一样。并不是所有的浏览器都有 window.Promise,因此,要想得到一个好的 polyfill,请看一个名为 Lie(https://github.com/calvinmetcalf/lie) 的库,它是目前最小的符合规范的库。

Rookie mistake #1: the promisey pyramid of doom

看看人们是如何使用 PouchDB 的,PouchDB 有一个很大程度上基于 promise 的 API,我发现很多糟糕的 promise 模式。最常见的糟糕的做法是:remotedb.allDocs({
  include_docs: true,
  attachments: true
}).then(function (result) {
  var docs = result.rows;
  docs.forEach(function(element) {localdb.put(element.doc).then(function(response) {alert("Pulled doc with id" + element.doc._id + "and added to local db.");
    }).catch(function (err) {if (err.name == 'conflict') {localdb.get(element.doc._id).then(function (resp) {localdb.remove(resp._id, resp._rev).then(function (resp) {
// et cetera...

是的,事实证明你可以像回调一样使用 promises,是的,这很像用电动砂光机锉指甲,但你可以做到。如果你认为这类错误仅仅局限于绝对初学者,你会惊讶地发现我确实从官方的黑莓开发者博客中获取了上述代码!旧的回调习惯很难改变。(对开发人员说:很抱歉挑你的毛病,但你的例子很有启发性。)A better style is this one:

remotedb.allDocs(...).then(function (resultOfAllDocs) {return localdb.put(...);
}).then(function (resultOfPut) {return localdb.get(...);
}).then(function (resultOfGet) {return localdb.put(...);
}).catch(function (err) {console.log(err);
});
这被称为 composing promises,它是 promises 的 great superpowers 之一。每个函数只有在上一个 Promise resolved 后才会被调用,并且将使用该 Promise 的输出来调用它。更多的内容以后再谈。

Rookie mistake #2: WTF, how do I use forEach() with promises?

这就是大多数人对承诺的理解开始崩溃的地方。一旦他们到了熟悉的 foreach()循环(或者 for 循环,或者 while 循环),他们就不知道如何让它与 promises 一起工作。所以他们写了这样的东西:// I want to remove() all docs
    db.allDocs({include_docs: true}).then(function (result) {result.rows.forEach(function (row) {db.remove(row.doc);  
      });
    }).then(function () {// I naively believe all docs have been removed() now!
    });
这个代码有什么问题?问题是第一个函数实际上返回 undefined,这意味着第二个函数不等待对所有文档调用 db.remove()。实际上,它不需要等待任何东西,并且可以在删除任意数量的文档后执行!这是一个特别阴险的 bug,因为您可能不会注意到任何错误,假设 PouchDB 删除这些文档的速度足以更新您的 UI。这个 bug 可能只在 odd race 条件下出现,或者在某些浏览器中出现,此时几乎不可能进行调试。所有这些的 TLDR 都是 forEach()/for/while 不是您要查找的构造。你需要 Promise.all():db.allDocs({include_docs: true}).then(function (result) {return Promise.all(result.rows.map(function (row) {return db.remove(row.doc);
      }));
    }).then(function (arrayOfResults) {// All docs have really been removed() now!
    });

这是怎么回事?基本上 Promise.all() 接受一个 array of promises 作为输入,然后它给您另一个 promise,该 promise 只在其他所有的 promise 都 resolved 时才会解决。它是 for 循环的异步等价物。Promise.all() 还将一个结果数组传递给下一个函数,这非常有用,例如,如果您试图从 pouchdb 去 get()多个结果。如果它的任何一个 sub-promises are rejected,那么 all()承诺也会被拒绝,这更有用。

Rookie mistake #3: forgetting to add .catch()

这是另一个常见的错误。幸运的是,他们的 promises 永远不会抛出错误,许多开发人员忘记在代码中的所有地方添加.catch()。不幸的是,这意味着任何抛出的错误都将被吞没,您甚至不会在控制台中看到它们。这可能是调试真正的苦恼。为了避免这种糟糕的情况,我养成了在我的 promise chains 中添加以下代码的习惯:somePromise().then(function () {return anotherPromise();
    }).then(function () {return yetAnotherPromise();
    }).catch(console.log.bind(console)); // <-- this is badass
即使您不期望出现错误,也要谨慎地添加 catch()。如果你的假设被证明是错误的,这会让你的生活更轻松。

Rookie mistake #4: using “deferred”

这是一个错误 我看 all the time,我甚至不愿意在这里重复它,因为我担心,像甲虫汁一样,仅仅调用它的名字就会引发更多的例子。简言之,promises 有着悠久的历史,而 JavaScript 社区花了很长时间才使其正确。早期,jQuery 和 Angular 在各地都使用这种“deferred”模式,现在已经被 ES6 Promise 规范所取代,由“good”库(如 Q, When, RSVP, Bluebird, Lie, and others 库)实现。所以如果你在代码中写这个词(我不会第三次重复!)你做错了一些事。下面是如何避免它。首先,大多数承诺库都为您提供了从第三方库“import”promises 的方法。例如,Angular 的 $q 模块允许您使用 $q.when()包装 non-$q 承诺。所以 Angular 用户可以这样包装 PouchDB 承诺:$q.when(db.put(doc)).then(/* ... */); // <-- this is all the code you need
另一种策略是使用 revealing constructor pattern(https://blog.domenic.me/the-revealing-constructor-pattern/),这对于包装 non-promise 的 API 很有用。例如,要包装基于回调的 API,如 Node 的 fs.readfile(),只需执行以下操作:new Promise(function (resolve, reject) {fs.readFile('myfile.txt', function (err, file) {if (err) {return reject(err);
        }
        resolve(file);
      });
    }).then(/* ... */)
Done! We have defeated the dreaded def... Aha, caught myself. :)
有关为什么这是 anti-pattern 的更多信息,请访问 Bluebird wiki 上的 Promise anti-patterns 页面(https://github.com/petkaantonov/bluebird/wiki/Promise-anti-patterns#the-deferred-anti-pattern)。

Rookie mistake #5: using side effects instead of returning

这个代码怎么了?somePromise().then(function () {someOtherPromise();
    }).then(function () {// Gee, I hope someOtherPromise() has resolved!
      // Spoiler alert: it hasn't.
    });

好吧,这是一个很好的观点,可以谈论关于 promises 的所有你需要知道的事情。说真的,这是一个 one weird trick,一旦你理解了它,就会阻止我所说的所有错误。准备好了吗?正如我之前所说,promises 的魔力在于,它们把我们宝贵的 return 和 throw 还给我们。但在实践中这到底是什么样子的呢?
每一个承诺都会给你一个 then()方法(或 catch(),它只是 then(null, ...)的语法糖)。这里是 then()函数的内部:somePromise().then(function () {// I'm inside a then() function!
    });
我们在这里能做什么?有三件事:1. return another promise
    2. return a synchronous value (or undefined)
    3. throw a synchronous error
就这样。一旦你理解了这个诀窍,你就明白了 promises。所以,So let's go through each point one at a time.。1. Return another promise
    这是您在 promise 文献中看到的常见模式,如上面的“composing promises”示例所示:getUserByName('nolan').then(function (user) {return getUserAccountById(user.id);
        }).then(function (userAccount) {// I got a user account!});
    请注意,我正在返回第二个 promise—return 是至关重要的。如果我没有说 return,那么 getUserAccountByID()实际上是一个 side effect,下一个函数将接收 undefined 而不是 userAccount。2. Return a synchronous value (or undefined)

返回 undefined 通常是一个错误,但返回同步值实际上是将同步代码转换为 Promisey 代码的一种很棒的方法。例如,假设我们有一个用户的内存缓存。我们可以做到:getUserByName('nolan').then(function (user) {if (inMemoryCache[user.id]) {return inMemoryCache[user.id];    // returning a synchronous value!
      }
      return getUserAccountById(user.id); // returning a promise!
    }).then(function (userAccount) {// I got a user account!});

那不是太棒了吗?第二个函数不关心是同步还是异步获取用户帐户,第一个函数可以自由返回同步或异步值。不幸的是,在 JavaScript 中,non-returning 函数在技术上返回 undefined 结果是不方便的,这意味着当您打算返回某些内容时,很容易意外地引入 side effects。出于这个原因,我习惯于总是从 then()函数内部返回或抛出。我建议你也这么做。
  1. Throw a synchronous error
    说到 throw,这就是 promises 可以变得令人惊叹的地方。假设我们想要抛出一个同步错误,以防用户注销。这很容易:

    getUserByName('nolan').then(function (user) {if (user.isLoggedOut()) {throw new Error('user logged out!'); // throwing a synchronous error!
      }
      if (inMemoryCache[user.id]) {return inMemoryCache[user.id];       // returning a synchronous value!
      }
      return getUserAccountById(user.id);    // returning a promise!
    }).then(function (userAccount) {// I got a user account!}).catch(function (err) {// Boo, I got an error!});

    如果用户注销,我们的 catch()将收到一个同步错误;如果任何 promises 被拒绝,它将收到一个异步错误。同样,函数不关心它得到的错误是同步的还是异步的。这尤其有用,因为它可以帮助识别开发过程中的编码错误。例如,如果在 then()函数内的任何一点执行 json.parse(),那么如果 json 无效,它可能会抛出一个同步错误。通过 callbacks,这个错误会被忽略,但是通过 promise,我们可以在 catch() 函数中简单地处理它。

Advanced mistakes

好吧,既然你已经学会了一个让 promises 变得简单的诀窍,我们来谈谈边缘案例。因为当然,总是有边缘情况。我将这些错误归类为“高级错误”,因为我只在那些已经相当擅长 promises 的程序员所犯的错误中见过。但是如果我们想解决我在本文开头提出的难题的话. 我们需要讨论一下。

Advanced mistake #1: not knowing about Promise.resolve()

正如我上面所展示的,promises 对于将同步代码包装为异步代码非常有用。但是,如果你发现自己经常输入:new Promise(function (resolve, reject) {resolve(someSynchronousValue);
    }).then(/* ... */);
您可以使用 promise.resolve()更简洁地表达这一点:Promise.resolve(someSynchronousValue).then(/* ... */);
这对于捕获任何同步错误也非常有用。它是如此有用,以至于我养成了一个习惯,几乎我所有的 promise-returning API 方法都是这样的:function somePromiseAPI() {return Promise.resolve().then(function () {doSomethingThatMayThrow();
        return 'foo';
      }).then(/* ... */);
    }

只需记住:任何可能同步抛出的代码都是一个很好的 candidate,因为它几乎不可能在一行中的某个地方调试吞没的错误。但是,如果您将所有内容都包装在 promise.resolve()中,那么您以后总是可以确保 catch()。同样,您可以使用 promise.reject()返回一个立即被拒绝的承诺:Promise.reject(new Error('some awful error'));

Advanced mistake #2: then(resolveHandler).catch(rejectHandler) isn’t exactly the same as then(resolveHandler, rejectHandler)

我在上面说,catch()只是语法糖。所以这两个片段是等效的:somePromise().catch(function (err) {// handle error});
    
    somePromise().then(null, function (err) {// handle error});
但是,这并不意味着以下两个片段是等效的:somePromise().then(function () {return someOtherPromise();
    }).catch(function (err) {// handle error});
    
    somePromise().then(function () {return someOtherPromise();
    }, function (err) {// handle error});
如果您想知道为什么它们不是等价的,那么考虑一下如果第一个函数抛出一个错误会发生什么:somePromise().then(function () {throw new Error('oh noes');
    }).catch(function (err) {// I caught your error! :)
    });
    
    somePromise().then(function () {throw new Error('oh noes');
    }, function (err) {// I didn't catch your error! :(});
事实证明,当使用 then(resolveHandler, rejectHandler)格式时,如果 resolveHandler 本身抛出了错误,那么 rejecthandler 实际上不会捕获错误。出于这个原因,我已经习惯了永远不要使用 then()的第二个参数,并且总是更喜欢 catch()。例外情况是,当我在编写异步 mocha 测试时,我可能会编写一个测试来确保抛出一个错误:it('should throw an error', function () {return doSomethingThatThrows().then(function () {throw new Error('I expected an error!');
      }, function (err) {should.exist(err);
      });
    });
说到这一点,Mocha 和 Chai 是测试 Promise API 的可爱组合。pouchdb-plugin-seed 项目有一些示例测试可以让您开始。

Advanced mistake #3: promises vs promise factories

假设你想一个接一个地按顺序执行一系列的 promises。也就是说,您需要像 promise.all()这样的东西,但它不能并行地执行 promises。你可能天真地写了这样的东西:function executeSequentially(promises) {var result = Promise.resolve();
      promises.forEach(function (promise) {result = result.then(promise);
      });
      return result;
    }

不幸的是,这不会像你想的那样奏效。您传递给 executeSequentially()的 promises 仍将并行执行。发生这种情况的原因是,你根本不想对一系列承诺进行操作。根据 Promise 规范,一旦创建了 promise,它就开始执行。所以你真正想要的是一系列的 promise factories:function executeSequentially(promiseFactories) {var result = Promise.resolve();
      promiseFactories.forEach(function (promiseFactory) {result = result.then(promiseFactory);
      });
      return result;
    }
我知道你在想:“这个 Java 程序员到底是谁,为什么他要谈论 factories?”然而,Promise factories 非常简单——它只是一个返回 Promise 的函数:function myPromiseFactory() {return somethingThatCreatesAPromise();
    }
为什么会这样?它起作用是因为 promise factory 在被要求之前不会创造 promise。它与 then 函数的工作方式相同——事实上,它是相同的!如果你看上面的 executeSequentially() 函数,然后想象 myPromiseFactory 在 result.then(...)中被替换了,那么希望一个灯泡会在你的大脑中发出咔嗒声。在那一刻,你将获得 promise 启发。

Advanced mistake #4: okay, what if I want the result of two promises?

通常情况下,一个 promise 依赖于另一个 promise,但我们需要两个 promises 的输出。例如:getUserByName('nolan').then(function (user) {return getUserAccountById(user.id);
    }).then(function (userAccount) {// dangit, I need the "user" object too!});
为了成为优秀的 javascript 开发人员并避免 pyramid of doom,我们可能只将用户对象存储在一个更高范围的变量中:var user;
    getUserByName('nolan').then(function (result) {
      user = result;
      return getUserAccountById(user.id);
    }).then(function (userAccount) {// okay, I have both the "user" and the "userAccount"});
这是可行的,但我个人觉得有点笨拙。我建议的策略是:抛开你的先入之见,拥抱 pyramid:getUserByName('nolan').then(function (user) {return getUserAccountById(user.id).then(function (userAccount) {// okay, I have both the "user" and the "userAccount"});
    });
…至少是暂时的。如果缩进变成了一个问题,那么您可以按照 Javascript 开发人员自古以来的做法,将函数提取到一个命名函数中:function onGetUserAndUserAccount(user, userAccount) {return doSomething(user, userAccount);
    }
    
    function onGetUser(user) {return getUserAccountById(user.id).then(function (userAccount) {return onGetUserAndUserAccount(user, userAccount);
      });
    }
    
    getUserByName('nolan')
      .then(onGetUser)
      .then(function () {// at this point, doSomething() is done, and we are back to indentation 0
    });
随着您的 promise 代码变得越来越复杂,您可能会发现自己正在将越来越多的函数提取到命名函数中。我发现这会产生非常美观的代码,看起来像这样:putYourRightFootIn()
  .then(putYourRightFootOut)
  .then(putYourRightFootIn)  
  .then(shakeItAllAbout);
这就是 promises 的意义所在。

Advanced mistake #5: promises fall through

最后,这是我在介绍上述 promise puzzle 时提到的错误。这是一个非常深奥的用例,它可能永远不会出现在您的代码中,但它确实让我吃惊。你觉得这个代码能打印出来吗?Promise.resolve('foo').then(Promise.resolve('bar')).then(function (result) {console.log(result);
    });
如果你认为它打印出了 bar,你就错了。它实际上打印了 foo!发生这种情况的原因是,当您传递 then()一个 non-function(如 promise)时,它实际上将其解释为 then(null),这会导致前一个 promise 的结果失败。您可以自己测试:Promise.resolve('foo').then(null).then(function (result) {console.log(result);
    });

添加任意 then(null)s;它仍将打印 foo。这实际上回到了我之前关于 promises 和 promise factories 的观点。简而言之,您可以将一个 promise 直接传递到 then()方法中,但它不会执行您认为它正在执行的操作。then()应该接受一个函数,所以最有可能的情况是:Promise.resolve('foo').then(function () {return Promise.resolve('bar');
    }).then(function (result) {console.log(result);
    });
如我们所料,这将打印 bar。所以请提醒自己:总是向 then()传递函数!

Solving the puzzle

既然我们已经了解了关于 promises 的一切(或接近 promises 的一切!)我们应该能够解决我最初在这篇文章开头提出的难题。以下是每个问题的答案,采用图形格式,以便更好地可视化:Puzzle #1
    doSomething().then(function () {return doSomethingElse();
    }).then(finalHandler);
Answer:
    doSomething
    |-----------------|
                      doSomethingElse(undefined)
                      |------------------|
                                         finalHandler(resultOfDoSomethingElse)
                                         |------------------|
Puzzle #2
    doSomething().then(function () {doSomethingElse();
    }).then(finalHandler);
Answer:
    doSomething
    |-----------------|
                      doSomethingElse(undefined)
                      |------------------|
                      finalHandler(undefined)
                      |------------------|
Puzzle #3
    doSomething().then(doSomethingElse())
      .then(finalHandler);
Answer:
    doSomething
    |-----------------|
    doSomethingElse(undefined)
    |---------------------------------|
                      finalHandler(resultOfDoSomething)
                      |------------------|
Puzzle #4
    doSomething().then(doSomethingElse)
      .then(finalHandler);
Answer:
    doSomething
    |-----------------|
                      doSomethingElse(resultOfDoSomething)
                      |------------------|
                                         finalHandler(resultOfDoSomethingElse)
                                         |------------------|

    如果这些答案仍然没有意义,那么我建议您重新阅读文章,或者定义 dosomething()和 dosomethingelse()方法,并在浏览器中自己尝试。Clarification:对于这些示例,我假设 doSomething()和 doSomethingElse()都返回 promises,并且这些 promises 表示在 javascript 事件循环之外所做的事情(例如 IndexedDB, network, setTimeout),这就是为什么它们在适当的时候显示为并发的原因。这里有一个 JSbin 要演示。为了更高级地使用 promises,请查看我的承 promise protips cheat sheet(https://gist.github.com/nolanlawson/6ce81186421d2fa109a4)。

Final word about promises

Promises 是伟大的。如果你仍在使用 callbacks,我强烈建议你转用 promises。您的代码将变得更小、更优雅、更容易理解。如果你不相信我,这里有一个证据:a refactor of PouchDB's map/reduce module (https://t.co/hRyc6ENYGC),用 promises 替换 callbacks。结果:290 次插入,555 次删除。顺便说一下,写那个讨厌的回调代码的人是……我!因此,这是我在 promises 的原始力量方面的第一堂课,我感谢其他 PouchDB 贡献者在这一过程中对我的指导。尽管如此,promises 不完美。的确,他们比回调更好,但这很像是说,一拳打在肚子上总比一拳打在牙齿上好。当然,一个比另一个更好,但是如果你有选择的话,你可能会避开它们。虽然优于 callbacks,promises 仍然很难理解和容易出错,这一点可以证明,我觉得有必要写这篇博文。新手和专家都会经常把事情搞得一团糟,事实上,这不是他们的错。问题是,虽然与我们在同步代码中使用的模式类似,但承诺是一个不错的替代品,但并不完全相同。事实上,您不必学习一堆神秘的规则和新的 API 来做一些事情,在同步的世界中,您可以很好地处理熟悉的模式,如 return, catch, throw, and for-loops。不应该有两个平行的系统,这个系统是你必须一直保持头脑中的直线。

Awaiting async/await

这就是我在 "Taming the asynchronous beast with ES7"(https://pouchdb.com/2015/03/05/taming-the-async-beast-with-es7.html), 中提出的观点,在这里我研究了 ES7 async/await 关键字,以及它们如何将承诺更深入地集成到语言中。ES7 不必编写伪同步代码(使用一个类似 catch 的 fake catch()方法,但实际上不是),它允许我们使用真正的 try/catch/return 关键字,就像我们在 CS 101 中学习到的那样。这对 JavaScript 作为一种语言来说是一个巨大的好处。因为最终,只要我们的工具不告诉我们什么时候出错,这些 promise anti-patterns 仍然会不断出现。以 javascript 的历史为例,我认为可以公平地说,JSlint 和 JShint 为社区提供了比 JavaScript: The Good Parts 更好的服务,即使它们实际上包含相同的信息。两者的区别是:告知你在代码中犯的错误,而不是读一本你试图理解别人错误的书。ES7 Async/Await 的优点是,在大多数情况下,您的错误将显示为语法 / 编译器错误,而不是细微的运行时错误。不过,在那之前,最好掌握 promises 的能力,以及如何在 ES5 和 ES6 中正确地使用它们。所以,虽然我认识到,像 JavaScript: The Good Parts,这个博客文章只能产生有限的影响,但希望你能在看到人们犯同样的错误时指出这些问题。因为我们中仍有太多人需要承认:"I have a problem with promises!"
Update:有人告诉我,Bluebird3.0 会打印出警告,可以防止我在这篇文章中发现的许多错误。所以当我们等待 ES7 时,使用 Bluebird 是另一个很好的选择!

正文完
 0