从今年过完年回来,三月份开始,就一直在作重构相关的事情。
就在今天刚刚上线了最新一次的重构代码,但愿高峰期安好,接近半年的Node.js代码重构。
包含从callback
+async.waterfall
到generator
+co
,通通升级为了async
,还顺带推进了TypeScript
在我司的使用。
这些日子也踩了很多坑,也总结了一些小小的优化方案,进行精简后将一些比较关键的点,拿出来分享给你们,但愿有一样在作重构的小伙伴们能够绕过这些。javascript
首先仍是要谈谈改代码的理由,毕竟重构确定是要有合理的理由的。
若是单纯想看升级相关事项能够直接选择跳过这部分。html
从最原始的开始提及,期间确实遇到了几个年代久远的项目,Node 0.x
,使用的普通callback
,也有一些会应用上async.waterfall这样在当年看起来很优秀的工具。java
// 普通的回调函数调用 var fs = require('fs') fs.readFile('test1.txt', function (err, data1) { if (err) return console.error(err) fs.readFile('test2.txt', function (err, data2) { if (err) return console.error(err) // 执行后续逻辑 console.log(data1.toString() + data2.toString()) // ... }) }) // 使用了async之后的复杂逻辑 var async = require('fs') async.waterfall([ function (callback) { fs.readFile('test1.txt', function (err, data) { if (err) callback(err) callback(null, data.toString()) }) }, function (result, callback) { fs.readFile('test2.txt', function (err, data) { if (err) callback(err) callback(null, result + data.toString()) }) } ], function (err, result) { if (err) return console.error(err) // 获取到正确的结果 console.log(result) // 输出两个文件拼接后的内容 })
虽然说async.waterfall
解决了callback hell
的问题,不会出现一个函数前边有二三十个空格的缩进。
可是这样的流程控制在某些状况下会让代码变得很诡异,例如我很难在某个函数中选择下一个应该执行的函数,而是只能按照顺序执行,若是想要进行跳过,可能就要在中途的函数中进行额外处理:node
async.waterfall([ function (callback) { if (XXX) { callback(null, null, null, true) } else { callback(null, data1, data2) } }, function (data1, data2, isPass, callback) { if (isPass) { callback(null, null, null, isPass) } else { callback(null, data1 + data2) } } ])
因此极可能你的代码会变成这样,里边存在大量的不可读的函数调用,那满屏充斥的null
占位符。面试
因此callback
这种形式的,必定要进行修改, 这属于难以维护的代码。express
实际上generator
是依托于co
以及相似的工具来实现的将其转换为Promise
,从编辑器中看,这样的代码可读性已经没有什么问题了,可是问题在于他始终是须要额外引入co
来帮忙实现的,generator
自己并不具有帮你执行异步代码的功能。
不要再说什么async/await是generator的语法糖了npm
由于我司Node
版本已经统一升级到了8.11.x
,因此async/await
语法已经可用。
这就像若是document.querySelectorAll
、fetch
已经能够知足需求了,为何还要引入jQuery
呢。json
因此,将generator
函数改造为async/await
函数也是势在必行。api
将callback
的升级为async
/await
其实并无什么坑,反却是在generator
+ co
那里遇到了一些问题:数组
在co
的代码中,你们应该都见到过这样的:
const results = yield list.map(function * (item) { return yield getData(item) })
在循环中发起一些异步请求,有些人会告诉你,从yield
改成async
/await
仅仅替换关键字就行了。
那么恭喜你获得的results
其实是一个由Promise
实例组成的数组。
const results = await list.map(async item => { return await getData(item) }) console.log(results) // [Promise, Promise, Promise, ...]
由于async
并不会判断你后边的是否是一个数组(这个是在co
中有额外的处理)而仅仅检查表达式是否为一个Promise
实例。
因此正确的作法是,添加一层Promise.all
,或者说等新的语法await*
,Node.js 10.x
貌似还不支持。。
// 关于这段代码的优化方案在下边的建议中有提到 const results = await Promise.all(list.map(async item => { return await getData(item) })) console.log(results) // [1, 2, 3, ...]
这个通常来讲遇到的几率不大,可是若是真的遇到了而栽了进去就欲哭无泪了。
首先这样的代码在执行上是没有什么区别的:
yield 123 // 123 await 123 // 123
这样的代码也是没有什么区别的:
yield Promise.resolve(123) // 123 await Promise.resolve(123) // 123
可是这样的代码,问题就来了:
yield true ? Promise.resolve(123) : Promise.resolve(233) // 123 await true ? Promise.resolve(123) : Promise.resolve(233) // Promise<123>
从字面上咱们实际上是想要获得yield
那样的效果,结果却获得了一个Promise
实例。
这个是由于yield
、await
两个关键字执行顺序不一样所致使的。
在MDN的文档中能够找到对应的说明:MDN | Operator precedence
能够看到yield
的权重很是低,仅高于return
,因此从字面上看,这个执行的结果很符合咱们想要的。
而await
关键字的权重要高不少,甚至高于最普通的四则运算,因此必然也是高于三元运算符的。
也就是说await
版本的实际执行是这样子的:
(await true) ? Promise.resolve(123) : Promise.resolve(233) // Promise<123>
那么咱们想要获取预期的结果,就须要添加()
来告知解释器咱们想要的执行顺序了:
await (true ? Promise.resolve(123) : Promise.resolve(233)) // 123
这个其实算不上升级时的坑,在使用co
时也会遇到,可是这是一个很严重,并且很容易出现的问题。
若是有一个异步的操做用来返回一个布尔值,告诉咱们他是否为管理员,咱们可能会写这样的代码:
async function isAdmin (id) { if (id === 123) return true return false } if (await isAdmin(1)) { // 管理员的操做 } else { // 普通用户的操做 }
由于这种写法接近同步代码,因此遗漏关键字是颇有可能出现的:
if (isAdmin(1)) { // 管理员的操做 } else { // 普通用户的操做 }
由于async
函数的调用会返回一个Promise
实例,得益于我强大的弱类型脚本语言,Promise
实例是一个Object
,那么就不为空,也就是说会转换为true
,那么全部调用的状况都会进入if
块。
那么解决这样的问题,有一个比较稳妥的方式,强制判断类型,而不是简单的使用if else
,使用相似(a === 1)
、(a === true)
这样的操做。eslint、ts 之类的都很难解决这个问题
首先,async
函数的执行返回值就是一个Promise
,因此能够简单地理解为async
是一个基于Promise
的包装:
function fetchData () { return Promise().resolve(123) } // ==> async function fetchData () { return 123 }
因此能够认为说await
后边是一个Promise
的实例。
而针对一些非Promise
实例则没有什么影响,直接返回数据。
在针对一些老旧的callback
函数,当前版本的Node
已经提供了官方的转换工具util.promisify,用来将符合Error-first callback
规则的异步操做转换为Promise
实例:
而一些没有遵照这样规则的,或者咱们要自定义一些行为的,那么咱们会尝试手动实现这样的封装。
在这种状况下通常会采用直接使用Promise
,由于这样咱们能够很方便的控制什么时候应该reject
,什么时候应该resolve
。
可是若是遇到了在回调执行的过程当中须要发起其余异步请求,难道就由于这个Promise
致使咱们在内部也要使用.then
来处理么?
function getList () { return new Promise((resolve, reject) => { oldMethod((err, data) => { fetch(data.url).then(res => res.json()).then(data => { resolve(data) }) }) }) } await getList()
但上边的代码也太丑了,因此关于上述问题,确定是有更清晰的写法的,不要限制本身的思惟。
async
也是一个普通函数,彻底能够放在任何函数执行的地方。
因此关于上述的逻辑能够进行这样的修改:
function getList () { return new Promise((resolve, reject) => { oldMethod(async (err, data) => { const res = await fetch(data.url) const data = await res.json() resolve(data) }) }) } await getList()
这彻底是一个可行的方案,对于oldMethod
来讲,我按照约定调用了传入的回调函数,而对于async
匿名函数来讲,也正确的执行了本身的逻辑,并在其内部触发了外层的resolve
,实现了完整的流程。
代码变得清晰不少,逻辑没有任何修改。
await
只能在async
函数中使用,await
后边能够跟一个Promise
实例,这个是你们都知道的。
可是一样的,有些await
其实并无存在的必要。
首先有一个我面试时候常常会问的题目:
Promise.resolve(Promise.resolve(123)).then(console.log) // ?
最终输出的结果是什么。
这就要说到resolve
的执行方式了,若是传入的是一个Promise
实例,亦或者是一个thenable
对象(简单的理解为支持.then((resolve, reject) => {})
调用的对象),那么resolve
实际返回的结果是内部执行的结果。
也就是说上述示例代码直接输出123
,哪怕再多嵌套几层都是同样的结果。
经过上边所说的,不知你们是否理解了 合理的减小 await 关键字 这句话的意思。
结合着前边提到的在async
函数中返回数据是一个相似Promise.resolve
/Promise.reject
的过程。
而await
就是相似监听then
的动做。
因此像相似这样的代码彻底能够避免:
const imgList = [] async function getImage (url) { const res = await fetch(url) return await res.blob() } await Promise.all(imgList.map(async url => await getImage(url))) // ==> async function getImage (url) { const res = fetch(url) return res.blob() } await Promise.all(imgList.map(url => getImage(url)))
上下两种方案效果彻底相同。
首先,Express
是经过调用response.send
来完成请求返回数据的。
因此直接使用async
关键字替换原有的普通回调函数便可。
而Koa
也并非说你必需要升级到2.x
才可以使用async
函数。
在Koa1.x
中推荐的是generator
函数,也就意味着其内部是调用了co
来帮忙作转换的。
而看过co
源码的小伙伴必定知道,里边同时存在对于Promise
的处理。
也就是说传入一个async
函数彻底是没有问题的。
可是1.x
的请求上下文使用的是this
,而2.x
则是使用的第一个参数context
。
因此在升级中这里多是惟一须要注意的地方,在1.x
不要使用箭头函数来注册中间件。
// express express.get('/', async (req, res) => { res.send({ code: 200 }) }) // koa1.x router.get('/', async function (next) { this.body = { code: 200 } }) // koa2.x router.get('/', async (ctx, next) => { ctx.body = { code: 200 } })
重构项目是一件颇有意思的事儿,可是对于一些注释文档都很缺失的项目来讲,重构则是一件痛苦的事情,由于你须要从代码中获取逻辑,而做为动态脚本语言的JavaScript
,其在大型项目中的可维护性并非很高。
因此若是条件容许,仍是建议选择TypeScript
之类的工具来帮助更好的进行开发。