原文: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()函数内部返回或抛出。我建议你也这么做。
-
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是另一个很好的选择!