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/'

剩下的代码不一一解释,大致做了以下工作:

  1. 先读取指定目录的所有文件 (fs.readdir)
  2. 使用 async.each 遍历获取的结果
  3. 判断每个文件的 stat 是不是目录 (fs.stat)
    1. 若是,检查符不符合条件,符合则进入下一轮递归 (readFiles)
    2. 若否,检查符不符合条件,符合则添加到最终结果的数组中 (res.push())
  4. 重复 1-3 直至遍历完所有文件

需注意的是:

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.definePropertyObject.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

合理利用 constObject.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 的优势,就需要正确利用 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 里加入了一个新特性——GeneratorKoa 已经开始使用 generator 来构建项目,具体怎么用,下一篇说吧。

 
22
Kudos
 
22
Kudos

Now read this

Markdown and CommonMark

CommonMark,最早的名字叫 Standard Markdown,后来迫于 Markdown 原作者 John Gruber 的压力而改名。 虽然这已经是今年九月初的事情了,而且我在当时就已经表达了我的看法,但还是完整说说我的观点。 我对的 Markdown 的看法是初始版本加上 GitHub Flavored Markdown 的代码块语法即可,其他的,算了吧,何必这么复杂?— Chris (@chrisyipw) September 4, 2014... Continue →