callback&Promise&Generator&async/await

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

一个范例

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

  • 读取 a.md 文件,得到内容
  • 把内容转换成 HTML 字符串
  • 把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 规范大家可以参考阮一峰老师的教程,这里不作赘述。

这里我们把上述代码改写为 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 提供的一种异步编程解决方案,也是刚刚接触的同学难以理解的点之一,在看下面的代码之前可以参考阮老师的教程, 当然这里也会先用一些简单的范例做引导便于大家去理解

先看一个范例:


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

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

  • line1: 执行Generator,创建一个状态对象,此时函数内部并没有执行
  • line2: 调用g.next(),gen函数开始执行,此时会执行line3的readFile函数,而 gen 函数的控制权交出代码暂停
  • line4: 当文件读取后会调用 g.next(str), 此时会把控制权再次交给 gen,并把文件结果str做为参数交给Generator状态对象g
  • line3: 此时yield的结果就是刚刚传递的str,赋值给mdStr ... ,写文件的逻辑类似
  • 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')
  })
})
  • line1: 执行Generator,创建一个状态对象,此时函数内部并没有执行,此时状态对象{value:undefined, done: false}
  • line2: 执行g.next()的时候开始执行gen函数,此时会执行readFile(), 而这个函数的执行会返回一个匿名函数。遇到yield 后gen函数暂停,把readFile()返回的匿名函数存储到状态对象的value里。所以g.next().value() 其实就是执行那个匿名函数,即 调用fs.readFile。当文件读取后,会调用fs.readFile里的 callback,而这个 callback 就是刚刚 g.next().value()的参数
  • 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 地址,模块内部原理可参考这里, 本质上就是 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 化,这个其实刚刚做过。

function delay(time) {
  return new Promise((resolve,reject) => {
    setTimeout(()=>{
      if(Math.random()>0.8){
        resolve(time)
      }else{
        reject('error..')
      }
    }, time)
  })
}

async function fn() {
  console.log('start')
  let time = await delay(1000)
  console.log(`${time}ms passed`)
  let time2 = await delay(3000)
  console.log(`${time2}ms passed`)
}
fn().catch(err=>console.log(err))

总结

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

作者:若愚@饥人谷

results matching ""

    No results matching ""