promises 很酷,但很多人并没有理解就在用了
JavaScript 开发者们,现在是时候承认一个事实了:我们在 promises 的使用上还存在问题。但并不是 promises 本身有问题,被A+标准定义的 promises 是极好的。
在过去一年的课程中揭示给我的一个比较大的问题是,正如我所看到的,很多程序员在使用 PouchDB API 以及与其他重 promise 的 API 的过程中存在的一个问题是:
如果你觉得这不可思议,那么考虑下我最近在Twitter上的写的一个比较难的题目:
1
2
3
4
5
6
7
8
9
10
11
|
doSomething().then(function () {
return doSomethingElse();
});
doSomething().then(function () {
doSomethingElse();
});
doSomething().then(doSomethingElse());
doSomething().then(doSomethingElse);
|
如果你知道答案,那么恭喜你:你是一个 promises 武士。我觉得你可以不用再继续读这篇博客文章。
对其他的 99.99% 的人,你们都在很好的公司上班。但是在回复我推特的人中没有人能解决这个问题,而且我对3楼的回答感到特别惊讶。是的,尽管我写了测试案例!
答案在这篇文章的末尾,但是首先,我想在第一时间搞清楚为什么promises是如此的棘手,以及为什么我们这么多人,不管是新手还是像专家的人,都会被它们搞晕。我也将提供我认为的我对其独特的洞察力,用一个独特的技巧,使得对promises的理解成为有把握的事情。是的,在这之后我认为它们真的不是那么难。
但是在开始之前,让我们挑战一下对于promises常见的一些假设。
如果你读过关于promises的一些文章,你经常会发现对《世界末日的金字塔》这篇文章的引用,会有一些可怕的逐渐延伸到屏幕右侧的回调代码。
promises确实解决了这个问题,但它并不只是关乎于缩进。正如在优秀的演讲《回调地狱的救赎》中所解释的那样,回调真正的问题在于它们剥夺了我们对一些像return和throw的关键词的使用能力。相反,我们的程序的整个流程都会基于一些副作用。一个函数单纯的调用另一个函数。
事实上,回调做了很多更加险恶的事情:它们剥夺了我们的堆栈,这些是我们在编程语言中经常要考虑的。没有堆栈来书写代码在某种程度上就好比驾车没有刹车踏板:你不会知道你是多么需要它,直到你到达了却发现它并不在这。
promises的全部意义在于它给回了在函数式语言里面我们遇到异步时所丢失的return,throw和堆栈。为了更好的从中获益你必须知道如何正确的使用promises。
一些人尝试解释promises是卡通,或者是一种名词导向的方式:“你可以传递的东西就是代表着异步值”。
我并没有发现这些这些解释多么有帮助。对于我来说,promises就是关乎于代码结构和流程。因此我认为,过一下一些常见的错误以及展示出如何修复它们是更好的方式。我把这称为“新手常犯的错误”的意义就在于,“你现在是个新手,初出茅庐,但你很快会成为专业人士”。
说一点题外话:“promises”对于不同的人有不同的理解,但是这篇文章的目的在于,我只是谈论官方标准,正如在现代浏览器中所暴露的window.Promise API。尽管不是所有的浏览器都支持window.Promise,对于一个很好的补充,可以查看名为Lie的项目,它是一个实现promises的遵循规范的最小集。
看下开发人员怎么使用拥有大量基于promise的API的PouchDB,我发现了很多不好的promise模式。最常见的不好实践是这个:
1
2
3
4
5
6
7
8
9
10
11
12
13
|
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的,而且是的,那有点像使用一个很有威力的磨砂机来打磨你的指甲,但是你是可以做到的。
如果你认为这一类型的错误会仅限于绝对的新手,你会惊讶的发现上面的代码就来自于黑莓开发者的官方博客。老的回调习惯很难改(致开发者:很抱歉拿你来举例,但是你的代码很有教育意义)。
一个更好的方式是:
1
2
3
4
5
6
7
8
9
|
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);
});
|
这被称为组成式promises(composing promises),它是有超能力的promises之一。每一个函数都在前面的promise被resolve之后被调用,而且将前面的promise的输出作为参数被调用。稍后将详细介绍。
这是大多数人开始理解Promises要突破的地方。尽管他们能熟悉forEach()循环(或者for循环,或者while循环),他们并不知道如何对Promises使用这些循环。此时,他们写的代码会像是这样:
1
2
3
4
5
6
7
8
|
// 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可能只出现在奇怪的竞态条件,或者特定的浏览器中,此时要去做debug是不可能的。
这所有的症结其实在于forEach()/for/while并不是你要寻找的构想。你需要的是Promise.all():
1
2
3
4
5
6
7
|
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都resolve之后执行resolve。它是for循环的一个异步等价物。
Promises.all()将一个数组作为结果传给下一个函数,这是很有用的,比如你正在试图从PouchDB获取一些东西的时候。如果任意一个all()的子promise被执行了reject,all()也会被执行reject,这甚至是更有用的。
这是另一个常见的错误。许多开发者会很自豪的认为他们的promises代码永远都不会出错,于是他们忘记在代码中添加.catch()方法。不幸的是,这会导致任何被抛出的错误都会被吞噬掉,甚至在你的控制台你也不会发现有错误输出。这在debug代码的时候真的会非常痛苦。
为了避免这种讨厌的场景,我已经习惯了在我的promise链中简单的添加如下代码:
1
2
3
4
5
|
somePromise().then(function () {
return anotherPromise();
}).then(function () {
return yetAnotherPromise();
}).catch(console.log.bind(console)); // <-- this is badass
|
甚至是你从未预料到会出错,添加.catch()方法都是很精明的做法。如果你的假设曾经被证明是错误的,它会让你的生活变的更简单。
这是一个我总是会看到的错误,我甚至都不愿意在这里重复,为了以防万一,像阴间大法师那样,仅仅是提到它的名字就能得到更多的实例。
长话短说,promise有个很长的传奇的历史,JavaScript社区花了很长的时间来使得它的实现是正确的。在早期,jQuery和Angular到处都在使用这个“deferred”模式,现在已被更换为ES6 Promise标准,正如一些很好的库如Q, When, RSVP, Bluebird, Lie以及其他库所实现的那样。
如果你正在你的代码里写deferred这种模式(我不会再重复第三次),那么你做的都是错的。下面是如何来避免这种错误。
首先,大多数的promise库提供了一种方式从第三方库中导入promises。例如,Angular的$q模块允许你使用$q.when()来封装非$q的模块。因此Angular的用户可以以这种方式来封装PouchDB的promises:
1
|
$q.when(db.put(doc)).then(/* ... */); // <-- this is all the code you need
|
另一个策略是使用揭示构造函数,这种策略对于封装非promise的API非常有用。例如,封装基于回调的API比如Node的fs.readFile(),你可以简单的这样做:
1
2
3
4
5
6
7
8
|
new Promise(function (resolve, reject) {
fs.readFile('myfile.txt', function (err, file) {
if (err) {
return reject(err);
}
resolve(file);
});
}).then(/* ... */)
|
完成!我们已经击败了可怕的def…我住嘴:)
为什么这是一种反模式更多的信息可以查看:the Bluebird wiki page on promise anti-patterns。
下面的代码有什么问题?
1
2
3
4
5
6
|
somePromise().then(function () {
someOtherPromise();
}).then(function () {
// Gee, I hope someOtherPromise() has resolved!
// Spoiler alert: it hasn't.
});
|
这是一个很好的点来谈论你所需要知道的所有关于promise的东西。
认真一点,这是一个有点奇怪的技巧,一旦你理解了它,就会避免我所谈论的所有的错误。你准备好了吗?
正如我之前所说,promises的神奇之处在于它给回了我们之前的return和throw。但是在实际的实践中它看起来会是什么样子呢?
每一个promise都会给你一个then()方法(或者catch,它们只是then(null,…)的语法糖)。这里我们是在then()方法的内部来看:
1
2
3
|
somePromise().then(function () {
// I'm inside a then() function!
});
|
我们在这里能做什么呢?有三种事可以做:
1、返回另一个promise;
2、返回一个同步值(或者undefined);
3、抛出一个同步错误。
就是这样。一旦你理解了这个技巧,你就明白了什么是promises。让我们一条条来说。
在promise的文档中这是一种常见的模式,正如上面的“组成式promise”例子中所看到的:
1
2
3
4
5
|
getUserByName('nolan').then(function (user) {
return getUserAccountById(user.id);
}).then(function (userAccount) {
// I got a user account!
});
|
注意,我正在返回第二个promise-return是很关键的。如果我没有说返回,getUserAccountById()方法将会产生一个副作用,下一个函数将会接收undefined而不是userAccount。
返回undefined通常是一个错误,但是返回一个同步值则是将同步代码转化为promise代码的绝好方式。比如说有一个在内存里的用户的数据。我们可以这样做:
1
2
3
4
5
6
7
8
|
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可以做到更棒。比如为了避免用户被登出我们想抛出一个同步错误。这很简单:
1
2
3
4
5
6
7
8
9
10
11
12
13
|
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参数不合法那么它就会抛出一个同步错误。用回调的话该错误就会被吞噬掉,但是用promises我们可以轻松的在catch()方法里处理掉该错误。
好的,现在你已经学习了一个单一的技巧来使得promises变动极其简单,现在让我们来谈论一些边界情况。因为在编码过程中总存在一些边界情况。
这些错误我把它们归类为高级错误,因为我只在一些对于promise非常熟悉的程序员的代码中发现。但是,如果我们想解决我在文章开头提出的疑惑的话,我们需要讨论这些高级错误。
正如我上面提到的,promises在封装同步代码为异步代码上是非常有用的。然而,如果你发现自己打了这样一些代码:
1
2
3
|
new Promise(function (resolve, reject) {
resolve(someSynchronousValue);
}).then(/* ... */);
|
你可以使用Promise.resolve()来更简洁的表达:
1
|
Promise.resolve(someSynchronousValue).then(/* ... */);
|
而且这在捕捉任意的同步错误上会难以置信的有用。它是如此有用,以致于我习惯于几乎将我所有的基于promise返回的API方法以下面这样开始:
1
2
3
4
5
6
|
function somePromiseAPI() {
return Promise.resolve().then(function () {
doSomethingThatMayThrow();
return 'foo';
}).then(/* ... */);
}
|
记住:对于被彻底吞噬的错误以致于不能debug的任意代码,做同步的错误抛出都是一个很好的选择。但是你把每个地方都封装为Promise.resolve(),你要确保后面你都会执行caotch()。
类似的,有一个Promise.reject()方法可以返回一个立即被拒绝的promise:
1
|
Promise.reject(new Error('some awful error'));
|
我在上面说过catch()只是一个语法糖。下面两个代码片段是等价的:
1
2
3
4
5
6
7
|
somePromise().catch(function (err) {
// handle error
});
somePromise().then(null, function (err) {
// handle error
});
|
然而,这并不意味着下面两个片段也是等价的:
1
2
3
4
5
6
7
8
9
10
11
|
somePromise().then(function () {
return someOtherPromise();
}).catch(function (err) {
// handle error
});
somePromise().then(function () {
return someOtherPromise();
}, function (err) {<a target=_blank href="http://mochajs.org/" target="_blank">点击打开链接</a>
// handle error
});
|
如果你疑惑为什么它们不是等价的,思考第一个函数抛出一个错误会发生什么:
1
2
3
4
5
6
7
8
9
10
11
|
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测试的时候,我可能写一个测试来保证错误被抛出:
1
2
3
4
5
6
7
|
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项目有很多你可以入手的简单的测试。
我们假定你想要一个接一个的,在一个序列中执行一系列的promise。就是说,你想要Promise.all()这样的东西,不会并行的执行promises。
你可能会单纯的这样写一些东西:
1
2
3
4
5
6
7
|
function executeSequentially(promises) {
var result = Promise.resolve();
promises.forEach(function (promise) {
result = result.then(promise);
});
return result;
}
|
不幸的是,它并不会按你所期望的那样工作。你传递给executeSequentially()的promises会并行执行。
之所以会这样是因为其实你并不想操作一个promise的数组。每一个promise规范都指定,一旦一个promise被创建,它就开始执行。那么,其实你真正想要的是一个promise工厂数组:
1
2
3
4
5
6
7
|
function executeSequentially(promiseFactories) {
var result = Promise |