原文:ES6 Promises: Patterns and Anti-Patterns
做者:Bobby Brennanjavascript
当几年前,第一次使用 NodeJS 的时候,对如今被称为“ 回调地狱 ”的写法感到很困扰。幸运的是,如今是 2017 年了,NodeJS 已经采用大量 JavaScript 的最新特性,从 v4 开始已经支持 Promise。html
尽管 Promise 可让代码更加简洁易读,但对于只熟悉回调函数的人来讲,可能对此仍是会有所怀疑。在这里,将列出我在使用Promise 时学到的一些基本模式,以及踩的一些坑。java
注意:在本文中将使用箭头函数 ,若是你还不是很熟悉,其实很简单,建议先读一下使用它们的好处node
若是使用的是已经支持 Promise 的第三方库,那么使用起来很是简单。只需关心两个函数:then()
和 catch()
。例如,有一个客户端 API 包含三个方法,getItem()
,updateItem()
,和deleteItem()
,每个方法都返回一个 Promise:es6
Promise.resolve() .then(_ => { return api.getItem(1) }) .then(item => { item.amount++ return api.updateItem(1, item); }) .then(update => { return api.deleteItem(1); }) .catch(e => { console.log('error while working on item 1'); })
每次调用 then()
会在 Promise 链中建立一个新的步骤,若是链中的任何一个地方出现错误,就会触发接下来的 catch()
。then()
和 catch()
均可以返回一个值或者一个新的 Promise,结果将被传递到 Promise 链的下一个then()
。web
为了比较,这里使用回调函数来实现相同逻辑:数据库
api.getItem(1, (err, data) => { if (err) throw err; item.amount++; api.updateItem(1, item, (err, update) => { if (err) throw err; api.deleteItem(1, (err) => { if (err) throw err; }) }) })
要注意的第一个区别是,使用回调函数,咱们必须在过程的每一个步骤中进行错误处理,而不是用单个的 catch-all 来处理。回调函数的第二个问题更直观,每一个步骤都要水平缩进,而使用 Promise 的代码则有显而易见的顺序关系。api
须要学习的第一个技巧是如何将回调函数转换为 Promise。你可能正在使用仍然基于回调的库,或是本身的旧代码,不过不用担忧,由于只须要几行代码就能够将其包装成一个 Promise。这是将 Node 中的一个回调方法 fs.readFile
转换为 Promise的示例:数组
function readFilePromise(filename) { return new Promise((resolve, reject) => { fs.readFile(filename, 'utf8', (err, data) => { if (err) reject(err); else resolve(data); }) }) } readFilePromise('index.html') .then(data => console.log(data)) .catch(e => console.log(e))
关键部分是 Promise 构造函数,它接收一个函数做为参数,这个函数有两个函数参数:resolve
和 reject
。在这个函数里完成全部工做,完成以后,在成功时调用 resolve
,若是有错误则调用 reject
。promise
须要注意的是只有一个resolve
或者 reject
被调用,即应该只被调用一次。在咱们的示例中,若是 fs.readFile
返回错误,咱们将错误传递给 reject
,不然将文件数据传递给resolve
。
ES6 有两个很方便的辅助函数,用于经过普通值建立 Promise:Promise.resolve()
和 Promise.reject()
。例如,可能须要在同步处理某些状况时一个返回 Promise 的函数:
function readFilePromise(filename) { if (!filename) { return Promise.reject(new Error("Filename not specified")); } if (filename === 'index.html') { return Promise.resolve('<h1>Hello!</h1>'); } return new Promise((resolve, reject) => {/*...*/}) }
注意,虽然能够传递任何东西(或者不传递任何值)给 Promise.reject()
,可是好的作法是传递一个Error
。
Promise.all
是一个并行运行 Promise 数组的方法,也就是说是同时运行。例如,咱们有一个要从磁盘读取文件的列表。使用上面建立的 readFilePromise
函数,将以下所示:
let filenames = ['index.html', 'blog.html', 'terms.html']; Promise.all(filenames.map(readFilePromise)) .then(files => { console.log('index:', files[0]); console.log('blog:', files[1]); console.log('terms:', files[2]); })
我甚至不会使用传统的回调函数来尝试编写与之等效的代码,那样会很凌乱,并且也容易出错。
有时同时运行一堆 Promise 可能会出现问题。好比,若是尝试使用 Promise.all
的 API 去检索一堆资源,则可能会在达到速率限制时开始响应429错误。
一种解决方案是串行运行 Promise,或一个接一个地运行。可是在 ES6 中没有提供相似 Promise.all
这样的方法(为何?),但咱们可使用 Array.reduce
来实现:
let itemIDs = [1, 2, 3, 4, 5]; itemIDs.reduce((promise, itemID) => { return promise.then(_ => api.deleteItem(itemID)); }, Promise.resolve());
在这种状况下,咱们须要等待每次调用 api.deleteItem()
完成以后才能进行下一次调用。这种方法,比为每一个 itemID 写 .then()
更简洁更通用:
Promise.resolve() .then(_ => api.deleteItem(1)) .then(_ => api.deleteItem(2)) .then(_ => api.deleteItem(3)) .then(_ => api.deleteItem(4)) .then(_ => api.deleteItem(5));
ES6 提供的另外一个很方便的函数是 Promise.race
。跟 Promise.all
同样,接收一个 Promise 数组,并同时运行它们,但不一样的是,会在一旦任何 Promise 完成或失败的状况下返回,并放弃全部其余的结果。
例如,咱们能够建立一个在几秒钟以后超时的 Promise:
function timeout(ms) { return new Promise((resolve, reject) => { setTimeout(reject, ms); }) } Promise.race([readFilePromise('index.html'), timeout(1000)]) .then(data => console.log(data)) .catch(e => console.log("Timed out after 1 second"))
须要注意的是,其余 Promise 仍将继续运行 ,只是看不到结果而已。
捕获错误最多见的方式是添加一个 .catch()
代码块,这将捕获前面全部 .then()
代码块中的错误 :
Promise.resolve() .then(_ => api.getItem(1)) .then(item => { item.amount++; return api.updateItem(1, item); }) .catch(e => { console.log('failed to get or update item'); })
在这里,只要有 getItem
或者 updateItem
失败,catch()
就会被触发。可是若是咱们想分开处理 getItem
的错误怎么办?只需再插入一个catch()
就能够,它也能够返回另外一个 Promise。
Promise.resolve() .then(_ => api.getItem(1)) .catch(e => api.createItem(1, {amount: 0})) .then(item => { item.amount++; return api.updateItem(1, item); }) .catch(e => { console.log('failed to update item'); })
如今,若是getItem()
失败,咱们经过第一个 catch
介入并建立一条新的记录。
应该将 then()
语句中的全部代码视为 try
块内的全部代码。return Promise.reject()
和 throw new Error()
都会致使下一个 catch()
代码块的运行。
这意味着运行时错误也会触发 catch()
,因此不要去假设错误的来源。例如,在下面的代码中,咱们可能但愿该 catch()
只能得到 getItem
抛出的错误,可是如示例所示,它还会在咱们的 then()
语句中捕获运行时错误。
api.getItem(1) .then(item => { delete item.owner; console.log(item.owner.name); }) .catch(e => { console.log(e); // Cannot read property 'name' of undefined })
有时,咱们想要动态地构建 Promise 链,例如,在知足特定条件时,插入一个额外的步骤。在下面的示例中,在读取给定文件以前,咱们能够选择建立一个锁定文件:
function readFileAndMaybeLock(filename, createLockFile) { let promise = Promise.resolve(); if (createLockFile) { promise = promise.then(_ => writeFilePromise(filename + '.lock', '')) } return promise.then(_ => readFilePromise(filename)); }
必定要经过重写 promise = promise.then(/*...*/)
来更新 Promise
的值。参看接下来反模式中会提到的 屡次调用 then()。
Promise 是一个整洁的抽象,但很容易陷入某些陷阱。如下是我遇到的一些最多见的问题。
当我第一次从回调函数转到 Promise 时,发现很难摆脱一些旧习惯,仍像使用回调函数同样嵌套 Promise:
api.getItem(1) .then(item => { item.amount++; api.updateItem(1, item) .then(update => { api.deleteItem(1) .then(deletion => { console.log('done!'); }) }) })
这种嵌套是彻底没有必要的。有时一两层嵌套能够帮助组合相关任务,可是最好老是使用 .then()
重写成 Promise 垂直链 。
我遇到的一个常常会犯的错误是在一个 Promise 链中忘记 return
语句。你能发现下面的 bug 吗?
api.getItem(1) .then(item => { item.amount++; api.updateItem(1, item); }) .then(update => { return api.deleteItem(1); }) .then(deletion => { console.log('done!'); })
由于咱们没有在第4行的 api.updateItem()
前面写 return
,因此 then()
代码块会当即 resolove,致使 api.deleteItem()
可能在 api.updateItem()
完成以前就被调用。
在我看来,这是 ES6 Promise 的一个大问题,每每会引起意想不到的行为。问题是, .then()
能够返回一个值,也能够返回一个新的 Promise,undefined
彻底是一个有效的返回值。就我的而言,若是我负责 Promise API,我会在 .then()
返回 undefined
时抛出运行时错误,但如今咱们须要特别注意 return
建立的 Promise。
.then()
根据规范,在同一个 Promise 上屡次调用 then()
是彻底有效的,而且回调将按照其注册顺序被调用。可是,我并未见过须要这样作的场景,而且在使用返回值和错误处理时可能会产生一些意外行为:
let p = Promise.resolve('a'); p.then(_ => 'b'); p.then(result => { console.log(result) // 'a' }) let q = Promise.resolve('a'); q = q.then(_ => 'b'); q = q.then(result => { console.log(result) // 'b' })
在这个例子中,由于咱们在每次调用 then()
不更新 p
的值,因此咱们看不到 'b'
返回。可是每次调用 then()
时更新 q
,因此其行为更可预测。
这也适用于错误处理:
let p = Promise.resolve(); p.then(_ => {throw new Error("whoops!")}) p.then(_ => { console.log('hello!'); // 'hello!' }) let q = Promise.resolve(); q = q.then(_ => {throw new Error("whoops!")}) q = q.then(_ => { console.log('hello'); // We never reach here })
在这里,咱们指望的是抛出一个错误来打破 Promise 链,但因为没有更新 p
的值,因此第二个 then()
仍会被调用。
有可能在一个 Promise 上屡次调用 .then()
有不少理由 ,由于它容许将 Promise 分配到几个新的独立的 Promise 中,可是还没发现真实的使用场景。
很容易进入一种陷阱,在使用基于 Promise 库的同时,仍在基于回调的项目中工做。始终避免在 then()
或 catch()
使用回调函数 ,不然 Promise 会吞噬任何后续的错误,将其做为 Promise 链的一部分。例如,如下内容看起来是一个挺合理的方式,使用回调函数来包装一个 Promise:
function getThing(callback) { api.getItem(1) .then(item => callback(null, item)) .catch(e => callback(e)); } getThing(function(err, thing) { if (err) throw err; console.log(thing); })
这里的问题是,若是有错误,咱们会收到关于“Unhandled promise rejection”的警告,即便咱们添加了一个 catch()
代码块。这是由于,callback()
在 then()
和 catch()
都会被调用,使之成为 Promise 链的一部分。
若是必须使用回调来包装 Promise,可使用 setTimeout
(或者是 NodeJS 中的 process.nextTick
)来打破 Promise:
function getThing(callback) { api.getItem(1) .then(item => setTimeout(_ => callback(null, item))) .catch(e => setTimeout(_ => callback(e))); } getThing(function(err, thing) { if (err) throw err; console.log(thing); })
JavaScript 中的错误处理有点奇怪。虽然支持熟悉的 try/catch
范例,可是没有办法强制调用者以 Java 的方式处理错误。然而,使用回调函数,使用所谓的“errbacks”,即第一个参数是一个错误回调变得很常见。这迫使调用者至少认可错误的可能性。例如,fs
库:
fs.readFile('index.html', 'utf8', (err, data) => { if (err) throw err; console.log(data); })
使用 Promise,又将很容易忘记须要进行错误处理,特别是对于敏感操做(如文件系统和数据库访问)。目前,若是没有捕获到 reject 的 Promise,将在 NodeJS 中看到很是丑的警告:
(node:29916) UnhandledPromiseRejectionWarning: Unhandled promise rejection (rejection id: 1): Error: whoops! (node:29916) DeprecationWarning: Unhandled promise rejections are deprecated. In the future, promise rejections that are not handled will terminate the Node.js process with a non-zero exit code.
确保在主要的事件循环中任何 Promise 链的末尾添加 catch()
以免这种状况。
但愿这是一篇有用的关于常见 Promise 模式和反模式的概述。若是你想了解更多,这里有一些有用的资源: