Node.js异步漫谈

使用 node,异步处理是无论如何都规避不了的点,如果只是为了实现功能大可以使用层层回调(回调地狱),但我们是有追求的程序员…

本文以一个简单的文件读写为例,讲解了异步的不同写法,包括 普通的 callback、ES2016中的Promise和Generator、 Node 用于解决回调的co 模块、ES2017中的async/await。适合初步接触 Node.js以及少量 ES6语法的同学阅读。

一个范例

以一个范例做为例,我们要实现的功能如下:

  1. 读取 a.md 文件,得到内容
  2. 把内容转换成 HTML 字符串
  3. 把HTML 字符串写入 b.html

一、callback回调地狱

var fs = require('fs')
var markdown = require( "markdown" ).markdown
fs.readFile('a.md','utf-8', function(err, str){
  if(err){
    return console.log(err)
  }
  var html = markdown.toHTML(str)
  fs.writeFile('b.html', html, function(err){
    if(err){
      return console.log(err)
    }
    console.log('write success')
  })
})

既然在 Node 环境下执行,那我们就尽量多使用 ES6的语法,比如letconst箭头函数,上述代码改写如下

const fs = require('fs')
const markdown = require( "markdown" ).markdown
fs.readFile('a.md','utf-8', (err, str)=>{
  if(err){
    return console.log(err)
  }
  let html = markdown.toHTML(str)

  fs.writeFile('b.html', html, (err)=>{
    if(err){
      return console.log(err)
    }
    console.log('write success')
  })
})

看起来还不错哦,那是因为我们的回调只有两层,如果是七层、十层呢?这不是开玩笑。

二、Promise 处理回调

关于 Promise 规范大家可以参考阮一峰老师的[教程](ECMAScript 6入门),这里不作赘述。

这里我们把上述代码改写为 Promise 规范的调用方式,其中文件的读写需要进行包装,调用后返回 Promise 对象

const fs = require('fs')
const markdown = require( "markdown" ).markdown

readFile("a.md")
  .then((mdStr)=>{
    return markdown.toHTML(mdStr)  //返回的结果作为下个回调的参数
  }).then(html=>{
    writeFile('b.html', html)
  }).catch((e)=>{
    console.log(e)
  });

function readFile(url) {
  var promise = new Promise((resolve, reject)=>{
    fs.readFile(url,'utf-8', (err, str)=>{
      if(err){
        reject(new Error('readFile error'))
      }else{
        resolve(str)
      }
    })
  })
  return promise
}

function writeFile(url, data) {
  var promise = new Promise((resolve, reject)=>{
    fs.writeFile(url, data, (err, str)=>{
      if(err){
        reject(new Error('writeFile error'))
      }else{
        resolve()
      }
    })
  })
  return promise
}

上述代码把 callback 的嵌套执行改为 then 的串联执行,看起来舒服了一些。代码中我们对文件的读写函数进行了 Promise 化包装,其实可以使用一些现成的模块来做这个事情,继续改写代码

const markdown = require('markdown').markdown
const fsp = require('fs-promise')   //用于把 fs 变为 promise 化,内部处理逻辑和上面的例子类似
let onerror = err=>{
  console.error('something wrong...')
}

fsp.readFile('a.md', 'utf-8')
  .then((mdStr)=>{
    return markdown.toHTML(mdStr)  //返回的结果作为下个回调的参数
  }).then(html=>{
    fsp.writeFile('b.html', html)
  }).catch(onerror);

代码一下子少了很多,结构清晰,但一堆的 then 看着还是碍眼…

三、Generator

Generator 函数是 ES6 提供的一种异步编程解决方案,也是刚刚接触的同学难以理解的点之一,在看下面的代码之前可以参考阮老师的[教程](ECMAScript 6入门), 当然这里也会先用一些简单的范例做引导便于大家去理解

先看一个范例:

function fn(a,b){
  console.log('fn..')
  return a + b
}

function* gen(x) {
  console.log(x)
  let y = yield fn(x,100) + 3
  console.log(y)
  return 200
}

上述声明了一个普通函数 fn,和一个 Generator 函数 gen,先执行如下代码

let g = gen(1)

调用Generator 函数,返回一个存储状态对象的引用,这个时候 gen 这个函数是没执行的,所以当你执行上面这行代码不会有任何输出

console.log( g.next() )

当调用g.next()时,gen 函数开始执行,执行到第一个yield 为止,并把 yield 表达式的值作为状态对象的值。更具体一点,上例先输出x也就是1,然后执行 fn(x, 100) 输出 fn..并返回101, 然后加3。这时候停止执行,把结果103赋值给状态对象 g,g 的结果变 {value: 103, done: false}。需要注意,yied表达式的优先级极其低,yield fn(x,100) + 3相当于 yield (fn(x,100) + 3)

console.log( g.next() )

这次执行g.next()的时候,代码由上次暂停处开始执行,但此时 yield 表达式的值并不是使用刚刚计算的结果,而是使用 g.next的参数undefined, 所以 y的值变为undefined,输出undeined。执行到return 200时,状态对象知道执行结束了,会把return的200赋值到状态对象,结果为 { value: 200, done: true }

有同学会问,如何把刚刚计算的中间值103给下个yield来用呢?好问题,我们可以这样

g.next(g.next().value)

想想为什么。现在可以回到我们的主题了,看看实现代码

const fs = require('fs')
const markdown = require("markdown").markdown

function readFile(url) {
  fs.readFile(url, 'utf8', (err, str)=>{
    if(err){
      g.throw('read error');
    }else{
      g.next(str)  //line4
    }
  })
}

function writeFile(url, data) {
  fs.writeFile(url, data, (err, str)=>{
    if(err){
      g.throw('write error');
    }else{
      g.next()  //line5
    }
  })
}

let gen = function* () {
  try{
    let mdStr = yield readFile('aa.md', 'utf-8')   //line3
    console.log(mdStr)
    let html = markdown.toHTML(mdStr)
    yield fs.writeFile('b.html', html)
  }catch(e){
    console.log('error occur...') //line6
  }
}

let g = gen()  //line1
let result = g.next()  //line2

为了便于描述,我们在代码的关键行加了行号标记,代码执行流程如下:

  1. line1: 执行Generator,创建一个状态对象,此时函数内部并没有执行
  2. line2: 调用g.next(),gen函数开始执行,此时会执行line3的readFile函数,而 gen 函数的控制权交出代码暂停
  3. line4: 当文件读取后会调用 g.next(str), 此时会把控制权再次交给 gen,并把文件结果str做为参数交给Generator状态对象g
  4. line3: 此时yield的结果就是刚刚传递的str,赋值给mdStr
  5. … ,写文件的逻辑类似
  6. line6: 当中间出现错误时,g会抛出异常,控制权交给gen后会捕获异常,处理报错

如果能看懂上面的代码,说明对 Generator函数就理解了

但虽然感觉用了更“高级”的技术,但与前面两种方法相比这种写法反而更丑陋难用。状态对象竟然在 readFile 和 writeFile 这两个普通函数里面调用…

我们可以先做一些优化

function readFile(url) {
  return (callback)=>{
    fs.readFile(url, 'utf-8', (err, str)=>{
      if(err) throw err
      callback(str)
    })
  }
}
//readFile('a.md')( (err, str)=>{ console.log(str)} ) 
//将多个参数的调用转换成单个参数的调用,回想想那些常常提到的概念,如闭包、函数柯里化


function writeFile(url, data){
  return (callback)=>{
    fs.writeFile(url, data, (err, str)=>{
      if(err) throw err
      callback()
    })
  }
}
// writeFile('b.html')( (err)=>{console.log('write ok')} )

let gen = function* () {
  try{
    let mdStr = yield readFile('a.md', 'utf-8') //line4
    let html = markdown.toHTML(mdStr)
    yield writeFile('b.html', html)
  }catch(e){
    console.log('error occur...')
  }
}

let g = gen()   //line1
g.next().value(str=>{    //line2
  g.next(str).value(()=>{  //line3
    console.log('write success')
  })
})
  1. line1: 执行Generator,创建一个状态对象,此时函数内部并没有执行,此时状态对象{value:undefined, done: false}
  2. line2: 执行g.next()的时候开始执行gen函数,此时会执行readFile(), 而这个函数的执行会返回一个匿名函数。遇到yield 后gen函数暂停,把readFile()返回的匿名函数存储到状态对象的value里。所以g.next().value() 其实就是执行那个匿名函数,即 调用fs.readFile。当文件读取后,会调用fs.readFile里的 callback,而这个 callback 就是刚刚 g.next().value()的参数
  3. line3: 调用g.next(str)让 gen 函数继续执行,同时把yield语句的结果用 str 来替换,代码继续往下走,到writeFile停止执行… 同步骤2

真的是很绕,头都绕晕了。上面的写法除了稍微解耦以为,仍然很丑陋,主功能异步的执行需要 Generator不断的回调调用next才可以,如果有七层十层…

下面做个个简单的优化,让Generator自动调用,知道状态变为done,原理大家自己好好想想

function run(fn) {
  let gen = fn()
  function next(data) {
    let result = gen.next(data)
    if (result.done) return
    console.log(result.value)
    result.value(next)
  }
  next()
}

run(gen)

再也不想用 Generator 了!

四、co 模块

co 模块是用于处理异步的一个node包,用于 Generator 函数的自动执行。NPM 地址,模块内部原理可[参考这里](ECMAScript 6入门-模块), 本质上就是 Promise 和 Generator 的结合,和我们上个范例还是很像的。

类似处理异步的比较出名的模块还有 async模块(注意不是ES2017的async语法)、bluebird

const fs = require('fs')
const markdown = require('markdown').markdown
const co = require('co')
const thunkify = require('thunkify')

let readFile = thunkify(fs.readFile)
let writeFile = thunkify(fs.writeFile)
let onerror = err=>{
  console.error('something wrong...')
}

let gen = function* () {
    let mdStr = yield readFile('a.md', 'utf-8')
    let html = markdown.toHTML(mdStr)
    yield writeFile('b.html', html)
}

co(gen).catch(onerror)

例子中 thunkify模块用于把一个函数thunk化,也就是我们上例中如下形式对异步函数进行包装。gen 的启动由 co(gen)来开启,和我们上一个范例类似

function writeFile(url, data){
  return (callback)=>{
    fs.writeFile(url, data, (err, str)=>{
      if(err) throw err
      callback()
    })
  }
}

就像回到了男耕女织的田园生活,感觉世界一下子清爽了许多。

五、async/await

ES2017 标准引入了 async 函数,用于更方便的处理异步。 这个特性太新了,真要用需要babel来转码。

const markdown = require('markdown').markdown
const fsp = require('fs-promise')
let onerror = err=>{
  console.error('something wrong...')
}

async function start () {
    let mdStr = await fsp.readFile('a.md', 'utf-8')
    let html = markdown.toHTML(mdStr)
    await fsp.writeFile('b.html', html)
}
start().catch(onerror)

async函数是对 Generator 函数的改进,实际上就是把Generator自动执行给封装起来,同时返回的是 Promise 对象更便于操作。

用的时候需要注意await命令后面是一个 Promise 对象。

上例中 fsp的作用是把内置的fs模块Promise 化,这个其实刚刚做过。

var readFile = function (fileName) {
  return new Promise(function (resolve, reject) {
    fs.readFile(fileName,'utf-8', function(error, data) {
      if (error) reject(error);
      resolve(data);
    });
  });
}

总结

上面几个例子实际上是异步处理的发展过程,从丑陋到精美,从引入各种乱七八糟的无关代码到精简到只保留核心业务功能,这也是任何框架和标准发展的趋势。

有什么预见和期待?

可以预见的是async/await慢慢会变成主流,现阶段用 co 也挺方便的,因为它们都很美。

期待node内置的涉及异步操作的模块都逐步提供对Promise的规范的支持,期待 ES2017的快速普及,那世界就美好了。

上面我们的功能不需要任何『外挂』将简化成

let mdStr = await fs.readFile('a.md', 'utf-8')
let html = markdown.toHTML(mdStr)
await fs.writeFile('b.html', html)
fs.onerror = ()=>{console.log('error')}

作者:若愚

6 thoughts on “Node.js异步漫谈”

  1. Wow that was strange. I just wrote an really long comment but after I clicked submit
    my comment didn’t show up. Grrrr… well I’m not writing all that over again. Anyway, just wanted
    to say great blog!

  2. I don’t know whether it’s just me or if everybody else encountering issues with your website.
    It seems like some of the written text in your content are running off the screen. Can someone else please comment and let me know if this is happening
    to them too? This may be a issue with my internet browser because I’ve had this happen before.
    Thanks

  3. Greetings from Ohio! I’m bored to tears at work so I decided to check out your site on my iphone during lunch break.
    I enjoy the information you provide here and can’t wait to take a look
    when I get home. I’m amazed at how quick your blog loaded on my phone ..
    I’m not even using WIFI, just 3G .. Anyways, excellent
    site!

发表评论

电子邮件地址不会被公开。