原文:https://pouchdb.com/2015/05/1...javascript
JavaScripts的朋友们,是时候认可了: we have a problem with promises。不,不是promises自己。正如A+ spec所定义的,promises是很是棒的。html
在过去的一年里,当我看到许多程序员在PouchDB API和其余promise-heavy APIs上挣扎时,我发现了一个大问题:咱们中的许多人使用promises 时没有真正理解它们。java
若是你以为很难相信,想一想我最近在Twitter上发布的这个谜题:node
Q: What is the difference between these four promises?git
doSomething().then(function () { return doSomethingElse(); }); doSomething().then(function () { doSomethingElse(); }); doSomething().then(doSomethingElse()); doSomething().then(doSomethingElse); 若是你知道答案,那么恭喜你:你是一个承诺忍者。我容许您中止阅读此日志。对于其余99.99%的人来讲,你是一个很好的同伴。没有人回应个人推特,也没有人能解决这个问题,我本身对#3的答案感到惊讶。是的,即便我写了测验! 答案在这篇文章的最后,但首先,我想探讨一下为何promises一开始就那么棘手,为何咱们中的许多人——新手和专家——会被promises绊倒。我还将提供我认为是独特看法的东西,一个奇异的把戏,它使promises很容易理解。是的,我真的相信在那以后他们不会那么难! 但首先,让咱们挑战一些关于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,才能利用它们。
有些人试图把承诺解释成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)的库,它是目前最小的符合规范的库。
看看人们是如何使用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的输出来调用它。更多的内容之后再谈。
这就是大多数人对承诺的理解开始崩溃的地方。一旦他们到了熟悉的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()承诺也会被拒绝,这更有用。
这是另外一个常见的错误。幸运的是,他们的promises永远不会抛出错误,许多开发人员忘记在代码中的全部地方添加.catch()。不幸的是,这意味着任何抛出的错误都将被吞没,您甚至不会在控制台中看到它们。这多是调试真正的苦恼。 为了不这种糟糕的状况,我养成了在个人promise chains中添加如下代码的习惯: somePromise().then(function () { return anotherPromise(); }).then(function () { return yetAnotherPromise(); }).catch(console.log.bind(console)); // <-- this is badass 即便您不指望出现错误,也要谨慎地添加catch()。若是你的假设被证实是错误的,这会让你的生活更轻松。
这是一个错误 我看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)。
这个代码怎么了? 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()函数内部返回或抛出。我建议你也这么作。
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() 函数中简单地处理它。angularjs
好吧,既然你已经学会了一个让promises变得简单的诀窍,咱们来谈谈边缘案例。由于固然,老是有边缘状况。 我将这些错误归类为“高级错误”,由于我只在那些已经至关擅长promises的程序员所犯的错误中见过。可是若是咱们想解决我在本文开头提出的难题的话.咱们须要讨论一下。
正如我上面所展现的,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'));
我在上面说,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项目有一些示例测试可让您开始。
假设你想一个接一个地按顺序执行一系列的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启发。
一般状况下,一个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的意义所在。
最后,这是我在介绍上述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()传递函数!
既然咱们已经了解了关于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)。
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。不该该有两个平行的系统,这个系统是你必须一直保持头脑中的直线。
这就是我在 "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是另外一个很好的选择!