Node.js 101: Promise and async
先回顾一下 Sagase 的项目结构:
lib/
cli.js
sagase.js
Gruntfile.js
package.json
上一篇讲了 package.json
,这一篇讲 lib/sagase.js
。
因为代码比较长,就分开一节节地讲,完整的点开 GitHub 看吧。
'use strict';
通知编译器进入 strict mode,主要的作用是让编译器帮你检查一些坑,有经验的 JavaScript 开发者可以忽略。
var fs = require('fs-extra'),
async = require('async'),
_ = require('lodash'),
Promise = require('bluebird'),
path = require('path')
引用所有依赖项,如果是使用 --harmony
,建议使用 const
代替 var
关键字,可避免变量被修改。
var mar = 'March'
mar = 'June'
console.log(mar) // 'June'
const july = 'July'
july = 'May'
console.log(july) // 'July'
function readFiles (opts) {}
包含很多信息,一个一个说。
return new Promise(function (resolve, reject) {}
返回一个 Promise 对象。
由于 Node.js 的特点是异步,一般都需要通过异步来处理:
// get all files in DIR
fs.readdir(DIR, function (err, files) {
if (err) {
return errorHandler(err)
}
// loop files
files.forEach(function (file) {
// get the stat of each files
fs.stat(file, function (err, stat) {
// if it's file
if (stat.isFile()) {
// get content of file
fs.readFile(file, function (err, buff) {
// do whatever you want
})
}
})
})
})
如果需要在一个函数里处理很多事情,甚至说需要让这个函数的返回结果可在多个文件里使用,只靠回调会很吃力——不知道哪个文件在什么时候才需要使用它的返回结果。
如果使用 Promise,就会简单很多:
// in a.js
var Promise = require('bluebird'),
readFile
module.exports = new Promise(function (resolve, reject) {
fs.readdir(DIR, function (err, files) {
err ? reject(err) : resolve(files)
})
})
// in b.js
var readFile = require('./a.js')
readFile
.then(function (files) {
// do something with files
return NEW_RESULT;
}, function (err) {
// handle error here
})
.then(function (data) {
// do something with NEW_RESULT
}, function (err) {
// handle error here
})
// in c.js
var readFile = require('./a.js')
readFile.then(function (files) {
// the files still exist and accessable
})
通过 Promise 的封装,就可以让 resolve
的结果跨不同的文件,不需要在一个回调函数里处理所有事情;另外,通过 .then()
的第二个函数处理 reject
的错误结果,避免了多重判断。
注意的是,在这里引入了一个叫 bluebird 的第三方 Promise 库,如果 Node.js 版本是 >= 0.11.13
的话,是不需要引入第三方库的。
async.each(
files,
function (file, cb) {
},
function (err) {
err ? reject(err) : resolve(res)
}
)
async 是一个改善异步回调流程、把异步处理能力赋予普通数组处理函数的库,比如 async.each
就相当于多线程版的 Array.forEach
,不过在实际使用中,不要期待执行顺序是乱序或者正序,关键是第三个参数。
async.each([1, 2, 3], function (item, next) {
console.log(item)
next()
}, function (err) {
console.log('all tasks done')
})
// output:
// 1
// 2
// 3
// "all tasks done"
一般来说,因为 fs.stat
是异步调用的,所以 Array.forEach
遍历完数组之后,很难保证里面的任务是否全部已完成,这时候调用 Promise.resolve()
就无法保证数据的正确性。而通过 async.each
的第三个参数,就可以得知任务的状态,并保证 Promise 可以得到正确的数据。
path.resolve(opts.folder + '/' + file).replace(process.cwd(), '.')
path
是一个用于解决和目录有关问题的库。path.resolve
会将 ./dir
转变为 /Users/USER/PATH/TO/dir
(Mac) 格式的完整目录。
process.cwd()
会返回调用这个脚本的进程所在目录;另外,还有一个 __dirname
是指脚本所在目录:
假设有如下文件:
lib/index.js
index.js
// in lib/index.js
module.exports = function () {
return __dirname
}
// in index.js
console.log(process.cwd()) // '/Users/USER/PATH/'
console.log(require('./lib')()) // '/Users/USER/PATH/lib/'
剩下的代码不一一解释,大致做了以下工作:
- 先读取指定目录的所有文件 (
fs.readdir
) - 使用
async.each
遍历获取的结果 - 判断每个文件的 stat 是不是目录 (
fs.stat
)- 若是,检查符不符合条件,符合则进入下一轮递归 (
readFiles
) - 若否,检查符不符合条件,符合则添加到最终结果的数组中 (
res.push()
)
- 若是,检查符不符合条件,符合则进入下一轮递归 (
- 重复 1-3 直至遍历完所有文件
需注意的是:
cb()
在多处出现,用于通知async.each
这个任务已执行完毕- 递归中用 Promise
readFiles.then()
的第一个参数处理res
的返回、用readFiles.catch
处理error
、用readFiles.finally
处理cb()
(因必须通知 async 任务已完成,在此处统一处理) Promise.finally
是 bluebird 特有的 API,原生 Promise 需这样实现:readFiles.then().catch().then()
,第二个then
相当于finally
(不过不够直观)
function formatOptions (opts) {}
用于格式化传入的参数,就不多解释了。
需要注意的是,在这里用了一个 opts
对象来包含所有参数并传递给 readFiles
。由于 JavaScript 的特性,用一个 JSON 对象传递参数会方便很多,比如说:
function formatOptions (folder, pattern, ignoreCase, nameOnly, exclude, excludeNameOnly, recusive) {}
由于 Node.js 用的 V8 仍未包含函数参数默认值,所以最方便的做法是用 JSON 对象:
var options = _.assign({}, {
key_1: default_value_1,
key_2: default_value_2
key_3: default_value_3
}, opts)
并且 JSON 对象也利于扩展——无论是增加还是删除 key,都不需要更改接口。
最后,定义了一个 Sagase
类,并在外部调用这个类时,创建一个新的 Sagase
对象:
function Sagase () {
Object.defineProperties(
this,
{
find: {
enumerable: true,
value: function (opts) {
return readFiles(formatOptions(opts))
}
}
}
)
}
module.exports = new Sagase()
Object.defineProperty
和 Object.defineProperties
是 ECMAScript 5 中新加入的特性,通过它们就可以创建很多好玩的东西,比如传统的 jQuery.fn.size()
:
jQuery.fn.size = function () {
return Number.MAX_SAFE_INTEGER
}
var body = $('body')
body.length // 1
body.size() // 9007199254740991
换成 ES 5 的写法:
Object.defineProperties(
jQuery.fn,
{
size: {
enumerable: true,
get: function () {
return this.length
}
}
}
)
var body = $('body')
body.length // 1
body.size // 1
jQuery.fn.size = function () {
return Number.MAX_SAFE_INTEGER
}
body.size // 1
body.size() // TypeError: number is not a function
合理利用 const
和 Object.defineProperty
可以避开一些非预期的情况,保证程序健壮性。
从 lib/sagase.js
的代码可以看出,Node.js 的异步特性导致函数是一层套一层 (`fs.readdir -> fs.stat -> isDirectory()) ,写起来其实不好看,也不利于理解,比如:
function getJSON () {
var json
fs.readFile('demo.json', function (err, buff) {
json = JSON.parse(buff.toString())
})
return json
}
getJSON() // undefined
当然,要在 Node.js 里使用同步接口也是可以的,如 fs.readdirSync
,但:
- Node.js 同步接口不见得比 Python、Ruby 等语言高效
- 不是所有接口都有同步版本,如
child_process.exec
为了发挥 Node.js 的优势,就需要正确利用 Promise、async 来编写程序。比如说有这样的一个场景,浏览器端需要获取购物车里所有商品、赠品的数据,常见的步骤大概是:找商品数据,通过商品 ID 找促销规则得到赠品,计算总价,返回结果。这些步骤可以通过多次请求数据库最后用后端语言拼接;如果是 RESTful API 模式,也可以发起多次请求,最后在浏览器端拼接。
如果在浏览器端和服务器端加入异步处理呢?
var router = express.Router()
router.get('/cart', function (req, res, next) {
async.parallel(
[
// get all products data
function (next) {
request('/api/products', OPTIONS)
.then(function (data) {
next(null, data)
})
},
// get products gifts
function (next) {
async.map(
PRODUCT_LIST,
function (p, cb) {
request('/api/product/:id/gifts', OPTIONS)
.then(function (data) {
cb(null, data)
})
},
function (err, results) {
next(null, results)
}
)
}
],
function (err, results) {
RESPONSE_BODY = {
products: results[0],
gifts: results[1],
total: calcTotal(results[0])
}
res.send(RESPONSE_BODY)
}
)
})
$.ajax('/cart').then(function () {
// handle products and gifts here
})
通过异步的处理,浏览器就可以用一次请求完成多次请求的效果,并且不会破坏 RESTful API 的结构。这对于资源紧张、网络环境多变的移动端来说,是非常有利的;而对于电脑端则通过减少请求时间来提高交互响应速度,提高用户体验。
这一篇主要内容是怎样利用 Promise、async 等库绕开 Node.js 的回调函数坑。回调函数算是 Node.js 最多人黑的地方,如果不能掌控它,写出来的 Node.js 代码将会相当丑陋、不易维护。
而为了让代码好看一些,ECMAScript 6 里加入了一个新特性——Generator。Koa 已经开始使用 generator 来构建项目,具体怎么用,下一篇说吧。