我们也许并不了解Promise
译者: 辣椒炒肉
原文地址:pouchdb.com/...
JavaScript 开发者们,承认一个事实吧:我们也许并不了解Promise。
众所周知,A+规范所定义的Promise,非常棒。
有个很大的问题是,在我过去使用Promise的一年中,看到很多开发者们,在用 PouchDB API或者是其他的Promise API,但是却不理解其中的原理。
不相信么?那请看我最近发布的这个推特。(可惜链接失效了,辣椒本人也没看到)
问题:这四个Promise有什么区别?
doSomething().then(function () {
return doSomethingElse();
});
doSomething().then(function () {
doSomethingElse();
});
doSomething().then(doSomethingElse());
doSomething().then(doSomethingElse);
如果你知道答案,那么恭喜你!你是一个Promise大神!下面的内容可以不看了。
至于其他99.99%的人,也恭喜你们了,你们上对车了。我在推特上发布的那个问题,还没有人给我一个完美的回答。我对自己的#3回答也感到不可思议,即使我写了测试。
答案在这篇文章的最后。但首先,我想探讨一下,为什么Promise如此棘手?为什么这么多人都为它所困惑?我也将提供一些解释,相信会让Promise不那么难理解。
首先我们尝试一些假设。
所以Promise是什么?
如果你读过一些Promise的文章,你肯定会找到很多回调地狱的引用,它们稳定地延伸到屏幕的右边,这很糟糕!
Promise 确实可以解决这个问题,但它不仅仅是起到缩进的作用。正如它被盛誉的那样:回调地狱的救赎。回调函数带来的问题就是,剥夺了我们对return和throw的掌控,而且还有一个副作用,一个函数意外地调用了另外一个函数。
事实上,回调函数还做了更加让人讨厌的事情:它丢失了原来的栈,这是我们 在编程语言中通常认为理所当然的事情。编写代码丢失了对栈的掌控,就像驾驶一辆没有刹车的汽车那样,你不知道它会驶向哪里。
Promise的重点是,把异步所丢失的return, throw, 和栈还给我们。但是你必须知道如何正确使用promises才能利用它们。
新手的错误
有的人试图将Promise解释为卡通,或者这样形容:“哦,这个返回值就是异步回来的结果”
我觉得这种解释不是很有帮助。对我来说,Promises都是关于代码结构和流程的。所以我认为,最好回顾一些常见的错误并且想想怎么修复它们。我称之为“菜鸟错误”的意思是:“你现在是一个菜鸟,但你很快会成为一个职业选手”
Promise对很多人来说意味着不同的东西,但就本文而言,我只谈论官方规范,在现代浏览器中暴露为window.Promise
新手错误1: 厄运金字塔
基于Promise的PouchDB,看看下面一个糟糕的例子:
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) {
// ...
如果你认为这样的写法只限于初学者,那你错了。我在官方的BlackBerry开发者博客中发现了这样的代码!旧的回调习惯很难消亡。(致上面代码的作者:抱歉,但您的代码很有借鉴意义)
更好的例子是这样:
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);
});
这是Promise链式写法,只有前一个promise执行完后面一个才会执行,并将前一个的返回值作为参数。稍后会详细介绍。
新手错误2: 怎么把forEach和Promise一起使用?
这是大多数人对Promise的理解开始崩溃的地方。一旦用到他们熟悉的forEach和while循环时,他们就不知道怎么和Promise一起使用。所以他们这样写:
// 我想删除全部的doc
db.allDocs({include_docs: true}).then(function (result) {
result.rows.forEach(function (row) {
db.remove(row.doc);
});
}).then(function () {
// 我天真地以为我删除了全部的doc
});
这段代码有什么问题?其实第一个函数返回undefined。这意味着第二个函数不会等待全部执行完db.remove(), 事实上它啥也不用等待。
这是一个很隐蔽的错误,因为PouchDB如果足够快删除这些文档并更新UI, 你可能不会注意到任何错误。这个错误可能会在奇怪的条件下或者某些浏览器中暴露。这时候来调试几乎是不可能的。
所有这些for/forEach/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) {
// 现在这些doc真的全部被删除了!
});
这里发生了什么?Promise.all接受一个promise数组作为参数,然后当每个promsie都resolve了,返回一个新的promise,包括了每个promise的resolve结果。它是for循环的异步等价物。
Promise.all()还将一个结果数组传递给下一个函数,这可能非常有用。例如,当你试图从PouchDB中获取多个东西, 如果任何一个子promise被rejected,那么all()的promise也会被拒绝,这更有用。
新手错误3: 忘记catch()
这也是一个常见的错误。自信地认为他们的代码不会发生异常。不幸的是,这意味着任何的错误都会被吞下,你甚至都不会在控制台中看到它们,这才是最痛苦的。
为了避免这种情况,我已经习惯在promise链中添加这样的代码:
somePromise().then(function () {
return anotherPromise();
}).then(function () {
return yetAnotherPromise();
}).catch(console.log.bind(console)); // <-- this is badass
即使你非常确定不会发生任何错误,最好还是添加一个catch(),让生活更美好。
新手错误4: 使用“deferred”
【辣椒没看懂这段。大概是,用Promise封装异步的操作吧(这不是很常规的操作么)】
new Promise(function (resolve, reject) {
fs.readFile('myfile.txt', function (err, file) {
if (err) {
return reject(err);
}
resolve(file);
});
}).then(/** */);
【辣椒不喜欢上面这样写。我自己会封装起来这段,return这个promise在别的地方await 这个promise获取返回。我知道我在说es7的async/await, 我就是看不惯这种写法。】
新手错误5:“using side effects instead of returning”
下面这段代码有什么问题?
somePromise().then(function () {
someOtherPromise();
}).then(function () {
// 哎呀,我希望someOtherPromise()已经resolved了!
// 剧透:并没有
});
正如我之前所说,Promise的魔力在于它们将我们的return和throw带回来。 但这在实践中实际上是什么样的?
每个promise都会给你一个then()方法(或者catch(),是语法糖,可以在then的第二个参数处理错误then(null,...))。 这里我们在then()函数内:
somePromise().then(function () {
// 我在then里面
});
我们在这儿可以做三件事:
-
返回另一个promise
-
返回一个同步值(或者是undefined)
-
抛出一个同步异常
一旦你理解了这个技巧,你就理解了Promise。 下面我们具体说说这三点:
1. 返回另一个promise
getUserByName('nolan').then(function (user) {
return getUserAccountById(user.id);
}).then(function (userAccount) {
// 我拿到了一个用户账号!
});
请注意,我正在返回第二个promise。 return至关重要!! 如果我没有写return,那么getUserAccountById()实际上是effect,而下一个函数将接收undefined而不是userAccount。
2.返回一个同步值(或者是undefined)
getUserByName('nolan').then(function (user) {
if (inMemoryCache[user.id]) {
return inMemoryCache[user.id]; // 返回一个同步值!
}
return getUserAccountById(user.id); // 返回一个promise!
}).then(function (userAccount) {
// 我拿到了一个userAccount!
});
是不是很棒!第二个函数不关心userAccount是同步还是异步获取的。第一个函数可以自由返回同步或异步值。
不幸的是,有一个事实是,JavaScript中的非返回函数在技术上返回undefined,这意味着当你想要返回一些内容时,很容易意外地引入effect。
出于这个原因,我总是习惯在then()函数内return或throw,建议你也这样做。
3.抛出一个同步异常
getUserByName('nolan').then(function (user) {
if (user.isLoggedOut()) {
throw new Error('user logged out!'); // 抛出一个同步异常!
}
if (inMemoryCache[user.id]) {
return inMemoryCache[user.id]; // 返回一个同步值!
}
return getUserAccountById(user.id); // 返回一个promise!
}).then(function (userAccount) {
// 我拿到了userAccount!
}).catch(function (err) {
// 砰! 我拿到一个异常!
});
如果用户注销,我们的catch()将收到同步错误,如果任何promise被拒绝,它将收到异步错误。 同样,该函数不关心它获得的错误是同步还是异步。
这特别有用,因为它可以帮助识别开发过程中的编码错误。 例如,如果在then()函数内部的任何一点,我们执行JSON.parse(),如果JSON无效,它可能会抛出同步错误。 有了回调,这个错误就会被吞噬,但是使用promise,我们可以在catch()函数中简单地处理它。
高级一点的错误
好的,现在你已经学会了一些基本的promise技巧,那我们就聊聊边缘情况吧。
我将这些错误归类为“高级”,因为我只看到了那些已经相当擅长Promise的程序员犯的错误。 但是,如果我们希望能够解决我在本文开头提出的问题,我们还需要继续讨论一下。
高级错误1:不知道Promise.resolve()
上面我已经讲过,promises对于将异步代码包装为同步代码非常有用。 但是,如果你发现自己会这样写:
new Promise(function (resolve, reject) {
resolve(/** 同步值*/);
}).then(/* ... */);
你可以使用Promise.resolve()更简洁地这样写:
Promise.resolve(/** 同步值*/).then(/* ... */);
这对于捕获任何同步错误也非常有用。 它非常有用,我养成了几乎所有promise-api都写return的习惯:
function somePromiseAPI() {
return Promise.resolve().then(function () {
doSomethingThatMayThrow();
return 'foo';
}).then(/* ... */);
}
请记住,任何可能throw同步错误的代码,都可能会发生“难以调试”的吞噬错误。如果你将所有的代码都包装在Promise.resolve()中,那么就总是可以确保稍后捕获到。
类似地,有一个Promise.reject()可用于返回立即拒绝的promise:
Promise.reject(new Error('some awful error'));
高级错误2:then(resolveHandler).catch(rejectHandler) 和 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) {
// 我捕获了一个异常
});
somePromise().then(function () {
throw new Error('oh noes');
}, function (err) {
// 我没有捕获到异常
});
事实证明,当您使用then(resolveHandler,rejectHandler)格式时,如果由resolveHandler本身抛出,则rejectHandler实际上不会捕获错误。【辣椒个人os:这好理解,resolveHandler和rejectHandler是同一级的,捕获不到应该是合理的,rejectHandler只能捕获somePromise发生的异常。所以,你可以用catch啊!】
出于这个原因,我已经习惯于永远不要使用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项目有一些示例测试可以帮助你入门。
高级错误3:Promise与Promise工厂
假设你想按顺序依次执行一系列的promises。 也就是说,你想要像Promise.all()这样的东西,但它不会并行执行promises。
你可能天真地写这样的东西:
function executeSequentially(promises) {
var result = Promise.resolve();
promises.forEach(function (promise) {
result = result.then(promise);
});
return result;
}
不幸的是,这不会按照你的想法执行。 传递给executeSequentially()的promise仍然会并行执行。
发生这种情况的原因是你根本不想操作数组里的promise。 根据promise规范,一旦创建了promise,它就会开始执行。 所以你需要的是数组promise工厂:
function executeSequentially(promiseFactories) {
var result = Promise.resolve();
promiseFactories.forEach(function (promiseFactory) {
result = result.then(promiseFactory);
});
return result;
}
我知道你在想什么:“这个Java程序员到底是谁,为什么他在谈论工厂呢?” promise工厂很简单,它只是一个返回promise的函数:
function myPromiseFactory() {
return somethingThatCreatesAPromise();
}
为什么这样写就有用?它起作用是因为promise工厂在被调用之前不会创建promise。 它的工作方式与当时的功能相同,实际上,它是一个东西!
如果你看一下上面的executeSequentially()函数,然后想象myPromiseFactory在result.then(...)中被替换,那么希望你会灵光一闪,得到promise启蒙。
高级错误4:好的,如果我想要两个promise的结果怎么办?
通常,一个promise将取决于另一个promise,但我们希望得到两个promise的输出。 例如:
getUserByName('nolan').then(function (user) {
return getUserAccountById(user.id);
}).then(function (userAccount) {
// 我也需要“user”对象!
});
想要成为优秀的JavaScript开发人员并避免厄运的金字塔,我们可能只是将用户对象存储在更高范围的变量中:
var user;
getUserByName('nolan').then(function (result) {
user = result;
return getUserAccountById(user.id);
}).then(function (userAccount) {
// 好的,我拿到了user和userAccount
});
这是可以的,但我个人觉得它有点笨拙。 我推荐的策略:放下你的先入之见,使用金字塔写法:
getUserByName('nolan').then(function (user) {
return getUserAccountById(user.id).then(function (userAccount) {
// 好的,我拿到了user和userAccount
});
});
或者你这样写:
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 () {
// 在这一点上,doSomething()完成了,我们又回到了缩进0
});
随着你的promise代码变得越来越复杂,可能会发现自己将越来越多的函数提取到命名函数中。 我发现这样的代码非常美观,像这样:
putYourRightFootIn()
.then(putYourRightFootOut)
.then(putYourRightFootIn)
.then(shakeItAllAbout);
这就是promise的全部。
高级错误5:promise失败
最后,当我介绍上面的promise难题时,这就是我提到的错误。 这是一个非常深奥的用例,你可能永远不会遇到,但它确实让我感到惊讶。
你认为此代码打印出来的是什么?
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);
});
它仍会打印foo。
这实际上回到了我之前关于promise与promise工厂的观点。 简而言之,你可以将promise直接传递给then()方法,但它不会按照您的想法执行。 then()应该接受一个函数,所以很可能你打算这样做:
Promise.resolve('foo').then(function () {
return Promise.resolve('bar');
}).then(function (result) {
console.log(result);
});
这会如我们所期望的那样,打印bar。
所以只需提醒自己:将函数传递给then()!
解决难题
现在我们已经学会了所有关于promise的知识,我们应该能够解决我在本文开头提出的难题。【就是推特那个】
难题1
doSomething().then(function () {
return doSomethingElse();
}).then(finalHandler);
答案:
doSomething
|-----------------|
doSomethingElse(undefined)
|------------------|
finalHandler(resultOfDoSomethingElse)
|------------------|
难题2
doSomething().then(function () {
doSomethingElse();
}).then(finalHandler);
答案:
doSomething
|-----------------|
doSomethingElse(undefined)
|------------------|
finalHandler(undefined)
|------------------|
难题3
doSomething().then(doSomethingElse())
.then(finalHandler);
答案:
doSomething
|-----------------|
doSomethingElse(undefined)
|---------------------------------|
finalHandler(resultOfDoSomething)
|------------------|
难题4
doSomething().then(doSomethingElse)
.then(finalHandler);
答案
doSomething
|-----------------|
doSomethingElse(resultOfDoSomething)
|------------------|
finalHandler(resultOfDoSomethingElse)
|------------------|
如果这些答案仍然没有起到作用,那么我建议你重新阅读帖子,或者定义doSomething()和doSomethingElse()方法,并在浏览器中自行尝试。
【文章年代久远,那时候es7还没出,但是依然有些参考意义。现在异步编程的解决办法,大多是Promise+async/await, 即:用Promise封装异步api(比如fs.readFile),在外部的async方法中await刚才封装好的方法,爽爽的!这将重新审视这篇文章的作者提出的一些异步写法】
转载自:https://juejin.cn/post/6844903881894264845