用Javascript的小伙伴们,是时候认可了,关于 promises 咱们一直存在着问题。并不是说 promises 自己有问题,Promises/A+ 是极好的。html
就我过去数年观察大量 PouchDB API 以及其余 promise-heavy API 的使用者们与这些 API 的搏斗中我发现,最大的问题是:git
大部分使用 promises 的小伙伴们并无真正的理解它web
若是你不认同这个观点,能够看看我最近在 twitter 上出的这道题:编程
Q: 下面的四种 promises 的区别是什么windows
doSomething().then(function () { return doSomethingElse(); }); doSomething().then(function () { doSomethingElse(); }); doSomething().then(doSomethingElse()); doSomething().then(doSomethingElse);
若是你知道正常答案,那么我要恭喜你,你是一位 promises 大拿,你彻底能够再也不继续阅读这篇文件。数组
另外 99.99% 的小伙伴们,大家才是正义。没有一我的在 twitter 上给出正确答案,甚至我本身也被 #3 的答案惊到了。恩,即便这道题是我本身出的。promise
正确答案在这篇文章的结尾,在此以前,我但愿首先探究一下为什么为什么 promises 如此复杂,而且为什么有这么多人,不管是新手仍是专家,都被它坑到了。同时我还会给出一个我自认为很是独特的视角,可让 promises 变的更加容易理解。同时,我很是确信在了解这些以后,promises 并不会再难以理解。浏览器
不过在开始这些以前,让咱们先了解一些 promises 的基础知识。缓存
若是你阅读了 promises 的一些相关文献,你会发现有一个词 金字塔问题 常常出现。它描述的是大量的回调函数慢慢向右侧屏幕延伸的一种状态。异步
Promises 的确解决了这个问题,而且不只仅是缩进问题。就像在 Callback Hell的救赎 中描述的同样,回调函数真正的问题在于他剥夺了咱们使用 return 和 throw 这些关键字的能力。相反,咱们的整个代码流程都是基于反作用的: 一个函数会附带调用其余函数。
原文关于反作用的描述并不能很直观的进行理解,建议参考 WIKI 。简单来讲就是一个函数除了会返回一个值以外,还会修改函数之外的状态如全局变量等等。实际上全部异步调用均可以视为带有反作用的行为。译者注。
而且实际上,回调更加恼人的是,他会将咱们一般在大部分编程语言中能得到的 堆栈 破坏。编写代码时若是没法获取堆栈就像开车时没有刹车同样: 不到用的时候,你不会知道它有多重要。
Promises 给予咱们的就是在咱们使用异步时丢失的最重要的语言基石: return, throw 以及堆栈。可是想要 promises 可以提供这些便利给你的前提是你知道如何正确的使用它们。
一些同窗试图经过用 卡通 来描述 promises,或者试图用语言去描述它: “哦,你能够把它做为一个异步的值进行传递。”
我认为这些解释并不会有很大的帮助。对我来讲,promises 彻底是一种代码结构和流程。所以我认为直接展现一些常见的错误而且演示如何修复它们更可以说明问题。我说这些问题是 “新手问题” ,这意味着 “虽然你如今是一个新手,孩子,可是立刻你会变成一位专家”。
小插曲: “promises” 对于不一样的人有不一样的理解和观点,可是在这篇文章中我特指 正式标准 ,在现代浏览器中暴露为 window.Promise。虽然并不是全部浏览器都有 windows.Promise,可是能够寻找一些 pollyfill ,好比 Lie 是目前体积最小的兼容标准的库。
新手错误 #1: promise版的金字塔问题
观察你们如何使用 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.status == 409) { localdb.get(element.doc._id).then(function (resp) { localdb.remove(resp._id, resp._rev).then(function (resp) { // et cetera...
是的,实际上你能够像使用回调同样使用 promises,恩,就像用打磨机去削脚趾甲同样,你确实能够这么作。
而且若是你觉得这样的错误只限于初学者,那么你会惊讶于我其实是在黑莓官方开发者博客上看到上面的代码。老的回调风格的习惯难以消灭。(至开发者: 抱歉选了你的例子,可是你的例子将会有积极的教育意义)
正确的风格应该是这样:
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 的强大能力之一。每个函数只会在前一个 promise 被调用而且完成回调后调用,而且这个函数会被前一个 promise 的输出调用,稍后咱们在这块作更多的讨论。
新手错误 #2: WTF, 用了 promises 后怎么用 forEach?
这里是大多数人对于 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,这意味着第二个方法不会等待全部 documents 都执行 db.remove()。实际上他不会等待任何事情,而且可能会在任意数量的文档被删除后执行!
这是一个很是隐蔽的 bug,由于若是 PouchDB 删除这些文档足够快,你的 UI 界面上显示的会完成正常,你可能会彻底注意不到有什么东西有错误。这个 bug 可能会在一些古怪的竞态问题或一些特定的浏览器中暴露出来,而且到时可能几乎没有可能去定位问题。
简而言之,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()会以一个 promises 数组为输入,而且返回一个新的 promise。这个新的 promise 会在数组中全部的 promises 都成功返回后才返回。他是异步版的 for 循环。
而且 Promise.all() 会将执行结果组成的数组返回到下一个函数,好比当你但愿从 PouchDB 中获取多个对象时,会很是有用。此外一个更加有用的特效是,一旦数组中的 promise 任意一个返回错误,Promise.all() 也会返回错误。
新手错误 #3: 忘记使用 .catch()
这是另外一个常见的错误。单纯的坚信本身的 promises 会永远不出现异常,不少开发者会忘记在他们的代码中添加一个 .catch()。然而不幸的是这也意味着,任何被抛出的异常都会被吃掉,而且你没法在 console 中观察到他们。这类问题 debug 起来会很是痛苦。
相似 Bluebird 之类的 Promise 库会在这种场景抛出 UnhandledRejectionError 警示有未处理的异常,这类状况一旦发现,就会形成脚本异常,在 Node 中更会形成进程 Crash 的问题,所以正确的添加 .catch() 很是重要。 译者注
为了不这类讨厌的场景,我习惯于像下面的代码同样使用 promise:
somePromise().then(function () { return anotherPromise(); }).then(function () { return yetAnotherPromise(); }).catch(console.log.bind(console)); // <-- this is badass
即便你坚信不会出现异常,添加一个 catch() 总归是更加谨慎的。若是你的假设最终被发现是错误的,它会让你的生活更加美好。
新手错误 #4:使用 “deferred”
这是一个我常常能够看到的错误,以致于我甚至不肯意在这里重复它,就像害怕 Beetlejuice 同样,仅仅是提到它的名字,就会召唤出来更多。
简单的说,promises 拥有一个漫长而且戏剧化的历史,Javascript 社区花费了大量的时间让其走上正轨。在早期,deferred 在 Q,When,RSVP,Bluebird,Lie等等的 “优秀” 类库中被引入, jQuery 与 Angular 在使用 ES6 Promise 规范以前,都是使用这种模式编写代码。
所以若是你在你的代码中使用了这个词 (我不会把这个词重复第三遍!),你就作错了。下面是说明一下如何避免它。
首先,大部分 promises 类库都会提供一个方式去包装一个第三方的 promises 对象。举例来讲,Angular的 $q 模块容许你使用 $q.when 包裹非 $q 的 promises。所以 Angular 用户能够这样使用 PouchDB promises.
$q.when(db.put(doc)).then(/* ... */); // <-- this is all the code you need
另外一种策略是使用构造函数声明模式,它在用来包裹非 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(/* ... */)
完工!咱们战胜了可怕的 def….啊哈,抓到本身了。:)
关于为什么这是一种反模式更多的内容,请查看 Bluebird 的 promise anti-patterns wiki 页
新手错误 #5:使用反作用调用而非返回
下面的代码有什么问题?
somePromise().then(function () { someOtherPromise(); }).then(function () { // Gee, I hope someOtherPromise() has resolved! // Spoiler alert: it hasn't. });
好了,如今是时候讨论一下关于 promises 你所须要知道的一切。
认真的说,这是一个一旦你理解了它,就会避免全部我说起的错误的古怪的技巧。你准备好了么?
就如我前面所说,promises 的奇妙在于给予咱们之前的 return 与 throw。可是在实践中这究竟是怎么一回事呢?
每个 promise 都会提供给你一个 then() 函数 (或是 catch(),实际上只是 then(null, ...) 的语法糖)。当咱们在 then() 函数内部时:
somePromise().then(function () { // I'm inside a then() function! });
咱们能够作什么呢?有三种事情:
就是这样。一旦你理解了这个技巧,你就理解了 promises。所以让咱们逐个了解下。
返回另外一个 promise
这是一个在 promise 文档中常见的使用模式,也就是咱们在上文中提到的 “composing promises”:
getUserByName('nolan').then(function (user) { return getUserAccountById(user.id); }).then(function (userAccount) { // I got a user account! });
注意到我是 return
第二个 promise,这个 return
很是重要。若是我没有写 return
,getUserAccountById()
就会成为一个反作用,而且下一个函数将会接收到 undefined
而非 userAccount
。
返回一个同步值 (或者 undefined)
返回 undefined 一般是错误的,可是返回一个同步值其实是将同步代码包裹为 promise 风格代码的一种很是赞的手段。举例来讲,咱们对 users 信息有一个内存缓存。咱们能够这样作:
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! });
是否是很赞?第二个函数不须要关心 userAccount 是从同步方法仍是异步方法中获取的,而且第一个函数能够很是自由的返回一个同步或者异步值。
不幸的是,有一个不便的现实是在 JavaScript 中无返回值函数在技术上是返回 undefined,这就意味着当你本意是返回某些值时,你很容易会不经意间引入反作用。
出于这个缘由,我我的养成了在 then() 函数内部 永远返回或抛出 的习惯。我建议你也这样作。
抛出同步异常
谈到 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() 会接收到一个同步异常,而且若是 后续的 promise 中出现异步异常,他也会接收到。再强调一次,这个函数并不须要关心这个异常是同步仍是异步返回的。
这种特性很是有用,所以它可以在开发过程当中帮助定位代码问题。举例来讲,若是在 then() 函数内部中的任何地方,咱们执行 JSON.parse(),若是 JSON 格式是错误的,那么它就会抛出一个异常。若是是使用回调风格,这个错误极可能就会被吃掉,可是使用 promises,咱们能够轻易的在 catch() 函数中处理它了。
好了,如今你已经了解了让 promises 变的超级简单的技巧,如今让咱们聊一聊一些特殊场景。
这些错误之因此被我归类为 “进阶” ,是由于我只见过这些错误发生在对 promises 已经有至关深刻了解的开发者身上。可是为了解决文章最开始的谜题,咱们必须讨论一下这些错误。
进阶错误 #1:不知道 Promise.resolve()
如我上面所列举的,promises 在封装同步与异步代码时很是的有用。然而,若是你发现你常常写出下面的代码:
new Promise(function (resolve, reject) { resolve(someSynchronousValue); }).then(/* ... */);
你会发现使用 Promise.resolve 会更加简洁:
Promise.resolve(someSynchronousValue).then(/* ... */);
它在用来捕获同步异常时也极其的好用。因为它实在是好用,所以我已经养成了在我全部 promise 形式的 API 接口中这样使用它:
function somePromiseAPI() { return Promise.resolve().then(function () { doSomethingThatMayThrow(); return 'foo'; }).then(/* ... */); }
切记:任何有可能 throw 同步异常的代码都是一个后续会致使几乎没法调试异常的潜在因素。可是若是你将全部代码都使用 Promise.resolve() 封装,那么你老是能够在以后使用 catch() 来捕获它。
相似的,还有 Promise.reject() 你能够用来返回一个马上返回失败的 promise。
Promise.reject(new Error('some awful error'));
进阶错误 #2:catch() 与 then(null, ...) 并不是彻底等价
以前我说过 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) 这种形式时,rejectHandler 并不会捕获由 resolveHandler 引起的异常。
鉴于此,我我的的习惯是不适用 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 接口时,是一对很是好的组合。 pouchdb-plugin-seed 项目中有一些 示例 能够帮助你入门。
进阶错误 #3:promises vs promises factories
当咱们但愿执行一个个的执行一个 promises 序列,即相似 Promise.all() 可是并不是并行的执行全部 promises。
你可能天真的写下这样的代码:
function executeSequentially(promises) { var result = Promise.resolve(); promises.forEach(function (promise) { result = result.then(promise); }); return result; }
不幸的是,这份代码不会按照你的指望去执行,你传入 executeSequentially() 的 promises 依然会并行执行。
其根源在于你所但愿的,实际上根本不是去执行一个 promises 序列。依照 promises 规范,一旦一个 promise 被建立,它就被执行了。所以你实际上须要的是一个 promise factories 数组。
function executeSequentially(promiseFactories) { var result = Promise.resolve(); promiseFactories.forEach(function (promiseFactory) { result = result.then(promiseFactory); }); return result; }
我知道你在想什么:“这是哪一个见鬼的 Java 程序猿,他为啥在说 factories?” 。实际上,一个 promises factory 是十分简单的,它仅仅是一个能够返回 promise 的函数:
function myPromiseFactory() { return somethingThatCreatesAPromise(); }
为什么这样就能够了?这是由于一个 promise factory 在被执行以前并不会建立 promise。它就像一个 then 函数同样,而实际上,它们就是彻底同样的东西。
若是你查看上面的 executeSequentially() 函数,而后想象 myPromiseFactory 被包裹在 result.then(...) 之中,也许你脑中的小灯泡就会亮起。在此时此刻,对于 promise 你就算是悟道了。
进阶错误 #4:好了,若是我但愿得到两个 promises 的结果怎么办
有时候,一个 promise 会依赖于另外一个,可是若是咱们但愿同时得到这两个 promises 的输出。举例来讲:
getUserByName('nolan').then(function (user) { return getUserAccountById(user.id); }).then(function (userAccount) { // dangit, I need the "user" object too! });
为了成为一个优秀的 Javascript 开发者,而且避免金字塔问题,咱们可能会将 user 对象存在一个更高的做用域中的变量里:
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" });
这样是没问题的,可是我我的认为这样作有些杂牌。我推荐的策略是抛弃成见,拥抱金字塔:
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 的重点。
进阶错误 #5:promises 穿透
最后,这个错误就是我开头说的 promises 谜题所影射的错误。这是一个很是稀有的用例,而且可能彻底不会出如今你的代码中,可是的的确确震惊了我。
你认为下面的代码会打印出什么?
Promise.resolve('foo').then(Promise.resolve('bar')).then(function (result) { console.log(result); });
若是你认为它会打印出 bar,那么你就错了。它实际上打印出来的是 foo!
发生这个的缘由是若是你像 then() 传递的并不是是一个函数(好比 promise),它实际上会将其解释为 then(null),这就会致使前一个 promise 的结果会穿透下面。你能够本身测试一下:
Promise.resolve('foo').then(null).then(function (result) { console.log(result); });
添加任意数量的 then(null),它依然会打印 foo。
这实际上又回到了我以前说的 promises vs promise factories。简单的说,你能够直接传递一个 promise 到 then() 函数中,可是它并不会按照你指望的去执行。then() 是指望获取一个函数,所以你但愿作的最多是:
Promise.resolve('foo').then(function () { return Promise.resolve('bar'); }).then(function (result) { console.log(result); });
这样他就会如咱们所想的打印出 bar。
所以记住:永远都是往 then() 中传递函数!
如今咱们了解了关于 promsies 全部的知识(或者接近!),咱们应该能够解决文章最开始我提出的谜题了。
这里是谜题的全部答案,我以图形的格式展现出来方便你查看:
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() 函数而且在浏览器中本身试试看。
声明:在这些例子中,我假定 doSomething() 和 doSomethingElse() 均返回 promises,而且这些 promises 表明某些在 JavaScript event loop (如 IndexedDB, network, setTimeout) 以外的某些工做结束,这也是为什么它们在某些时候表现起来像是并行执行的意义。这里是一个模拟用的 JSBin。
关于更多 promises 的进阶用法,能够参考个人 promise protips cheat sheet
Promises 是很是赞的。若是你还在使用回调模式,我强烈建议你切换到 promises。你的代码会变的更少,更优雅,而且更加容易理解。
若是你不相信我,这里是证实:a refactor of PouchDB’s map/reduce module,使用 promises 替换回调。结果是:新增 290 行,删除 555 行。
顺带一提,写出那使人讨厌的回调代码的人。。是我!所以这是我第一次领会到 promises 的力量,同时我感谢其余 PouchDB 的贡献者们教导我这样作。
固然了,promises 并不是完美。虽然它的确比回调模式要好,可是这样说就比如说给你肚子来一拳会比在你牙齿上踹一脚好。的确,它是会略有优点,可是若是你有选择,你会二者都尽力避免。
做为回调模式的升级版,promises 依然难以理解而且容易误用,证实之一就是我不得不写下这篇博文。初学者与专家都很容易常常将它用错,而且真要说的话,并不是是他们的问题。问题在于 promises 的使用模式与咱们写同步代码很是相似,可是又不尽然。
我也认为 promises 的确难以理解而且容易误用,证实之一就是我不得不翻译这篇博文。 译者注
老实说,你不该该须要去学一堆晦涩难懂的规则和新的 API 去作在同步代码中咱们已经熟稔的 return,catch,throw 和 for 循环。在你的脑中不该该老是要提醒本身要区分有两套并行的系统。
期待 async/await
这是我在 “Taming the asynchronous beast with ES7” 中提到的重点,在这篇文章中我探究了 ES7 的 async/await 关键字,以及它们是如何将 promises 更深度的结合入语言。再也不会要求咱们去编写伪同步的代码(以及一个假的 catch() 函数,虽然像,可是并不是是 catch),ES7 将会容许咱们使用真正的 try/catch/return 关键字,就像咱们在 CS 101 上学的同样。
这对于 Javascript 语言来讲是一个大福音。由于即便到最后,只要咱们的工具不告诉咱们作错了,这些 promise 反模式依然会一直出现。
从 JavaScript 的历史来看,我认为公正的评价来讲 JSLint 与 JSHint 对社区的贡献是高于 JavaScript: The Good Parts 的,虽然他们包含的信息其实是相同的。可是它们的区别在于 被告知你在你代码中犯的错误 与你去阅读一本书籍,去理解其余人犯的错误。
ES7 的 async/await 的美妙在于,你的错误会被做为语法或者编译器错误提示出来,而不是运行时的 bug。不过就目前而言,了解 promise 能够作什么以及如何在 ES5 与 ES6 中正确的使用它们依然是有必要的。
所以当我意识到,就像 JavaScript: The Good Parts 同样,这篇博文可能只会有很是有限的影响的时候,我但愿当你发现其余人在犯一样的错误的时候,你能够将这篇博文提供给他们。由于如今依然有不少同窗须要认可: “I have a problem with promises!”
更新:有人告知我 Bluebird 3.0 将会 打印警告 来避免我文中所列举的这些错误。所以当咱们还在等待 ES7 时,使用 Bluebird 会是另外一个极好的方案。