奇光异彩--JS中Generator 函数异步应用

前言

本文前承拙文《异步翊驱--Generator 函数JS中的异步方案》,将继续看一下Generator函数的一些异步应用。并穿插讲述一些概念,如Thunk函数、co模块等。希望各位在读完本文之后,可以结合前文将Generator函数充分理解。


一、首先来看一下传统方法:

ES6直接将 JavaScript 异步编程带入了一个全新的阶段,在此之前,异步编程的方法,无出于:

回调函数、事件监听、发布/订阅、Promise对象

而现在,我们拥有了Generator函数,以及async函数。(随后将详细介绍)

如果不了解异步的话,请看一眼下面的描述

所谓异步,简单说就是一个任务不是连续完成的,可以理解成该任务被人为分成两段,先执行第一段,然后转而执行其他任务,等做好了准备,再回过头执行第二段。

假如说「同步」类似【打电话】,如果打不通就一直打,直到对方接了电话,完成了动作后,才继续执行下一步。
那么「异步」就像【发短信】,发了短信后继续执行手里的任务,而对方空闲时收到短信后,才完成相应动作。这样一来便消弭了阻塞。

回调函数的方式实现异步

JavaScript 语言对异步编程的实现,就是回调函数。所谓「回调函数」,就是把任务的第二段单独写在一个函数里面,等到重新执行这个任务的时候,就直接调用这个函数。

回调函数的英语名字callback,直译过来就是“重新调用”。读取文件进行处理,是这样写的:

fs.readMyFile('/mc/password', 'utf-8', (err, data) => {
  if (err) throw err
  console.log(data)
})

上面代码中,readMyFile函数的第三个参数,就是回调函数,也就是任务的第二段。等操作系统返回了/mc/passwd这个文件以后,回调函数才会执行。

一个有趣的问题是,为什么Node约定,回调函数的第一个参数,必须是错误对象err(如果没有错误)

这里回调函数的第一个参数,必须是错误对象err。

原因是执行分成两段,第一段执行完以后,任务所在的上下文环境就已经结束了。在这以后抛出的错误,原来的上下文环境已经无法捕捉,只能当作参数,传入第二段。

回调函数本身并没有问题,它的问题出现在多个回调函数嵌套。假定读取A文件之后,再读取B文件,代码如下。

fs.readFile(fileA, 'utf-8', (err, data) => {
  fs.readFile(fileB, 'utf-8', (err, data) => {
    // ...
  })
})

不难想象,如果依次读取两个以上的文件,就会出现多重嵌套。代码不是纵向发展,而是横向发展,很快就会乱成一团,无法管理。这样的多个异步操作形成了强耦合,只要有一个操作需要修改,它的上层回调函数和下层回调函数,可能都要跟着修改。这种情况就非常不理想,写法上及其丑陋。

如果换作用promise对象去处理的话:

Promise 对象就是为了解决这个问题而提出的。它不是新的语法功能,而是一种新的写法,允许将回调函数的嵌套,改成链式调用。采用 Promise,连续读取多个文件,写法如下。

let readFile = require('mc-promise')

readFile(mcA)
.then((data) => {
  console.log(data.toString())
})
.then(() => {
  return readFile(fileB)
})
.then((data) => {
  console.log(data.toString())
})
.catch((err) => {
  console.log(err)
})

上面使用了mc-promise模块,它的作用就是返回一个Promise版本的readFile函数。Promise提供then方法加载回调函数,catch方法捕捉执行过程中抛出的错误。

可以看到,Promise的写法仅仅是回调函数的改进,使用then方法之后,异步任务的两段执行看得更清楚了,除此之外并无新意。

Promise 的最大问题是代码冗余,原来的任务被 Promise 包装了一下,不管什么操作,一眼看去都是一堆then,原来的语义变得很不清楚。

那么,有没有更好的写法呢?

二、看一看Generator 函数是怎么处理的:

传统的编程语言,早有异步编程的解决方案(或者说是多任务的解决方案)。其中有一种叫做「协程」(coroutine),意思是多个线程互相协作,进而完成异步任务。

协程有点像函数,又有点像线程。它的运行流程大致如下:

1、协程A开始执行。

2、协程A执行到一半,进入暂停,执行权转移到协程B

3、(一段时间后)协程B交还执行权。

4、协程A恢复执行。

上面流程的协程A,就是异步任务,因为它分成两段(或多段)执行。

举例来说,读取文件的协程写法如下:

function* asyncJob() {
  // ...其他代码
  let f = yield readFile(fileA)
  // ...其他代码
}

上面代码的函数 asyncJob 是一个协程,它的奥妙就在于其中的yield命令。他表示执行到此处,执行权将交给其他协程。也就是说,yield命令是异步两个阶段的分界线。

协程遇到 yield 命令就暂停,等到执行权返回,再从暂停的地方继续执行。它最大的优点,就是代码的写法非常像同步操作,如果去除yield命令,几乎可以乱真。

协程的 Generator 函数实现

Generator 函数是协程在 ES6 的实现,最大特点就是可以交出函数的执行权。(即暂停执行,前文已表)

整个 Generator 函数 就是一个封装的异步任务,或者说异步任务的容器。异步任务需要暂停的地方,都用 yield 语句注明。

function* gen(x) {
  let y = yield x + 2
  return y
}

let g = gen(1)
g.next() // { value: 3, done: false }
g.next() // { value: undefined, done: true }

上面代码中,调用 Generator 函数,会返回一个内部指针(即遍历器)g。这是 Generator 函数不同于普通函数的另一个地方,即执行它不会返回结果,返回的是指针对象。调用指针g的next方法,会移动内部指针(即执行异步任务的第一段),指向第一个遇到的yield语句,上面的代码是执行到 x + 2 为止。

换言之,next方法的作用是分阶段执行 Generator 函数。每次调用 next 方法,会返回一个对象 { value, done } ,表示当前阶段的信息。

value属性:yield 语句后面表达式的值,表示当前阶段的值;

done属性:一个布尔值,表示 Generator函数是否执行完毕,即是否还有下一个阶段。

Generator 函数的数据交换和错误处理

Generator 函数可以暂停执行和恢复执行,这是它能封装异步任务的根本原因。

除此之外,它还有两个特性,使它可以作为异步编程的完整解决方案:

1、「 函数体内外的数据交换 」

2、「 错误处理机制 」

第一点在前文已经有了很详细的描述,这里主要说一下第二点:

Generator 函数内部还可以部署错误处理代码,捕获函数体外抛出的错误。

function* gen(x){
  try {
    let y = yield x + 2
  } catch (e){
    console.log(e)
  }
  return y
}

let g = gen(1)
g.next()
g.throw('出错了')
// 出错了

上面代码的最后一行,Generator函数体外,使用指针对象throw方法抛出的错误,可以被函数体内的 try...catch 代码块捕获。这意味着,出错的代码与处理错误的代码,实现了时间和空间上的分离,这对于异步编程无疑是很重要的。

异步任务的封装

下面看看如何使用 Generator 函数,执行一个真实的异步任务。

var fetch = require('node-fetch')

function* gen(){
  let url = 'https://github.com/MingEmperor?tab=repositories'
  let result = yield fetch(url)
  console.log(result.bio)
}

上面代码中,Generator 函数封装了一个异步操作,该操作先读取一个远程接口,然后从 JSON 格式的数据解析信息。

就像前面说过的,这段代码非常像同步操作,除了加上了 yield 命令。

执行这段代码的方法如下:

let g = gen()
let result = g.next()

result.value.then((data) => {
  return data.json()
}).then((data) =>{
  g.next(data)
})

上面代码中,首先执行 Generator 函数,获取遍历器对象,然后用next方法,执行异步任务的第一阶段。由于fetch模块返回的是一个 Promise 对象,因此要用 then 方法调用下一个 next 方法。

可以看到,虽然 Generator 函数将异步操作表示得很简洁,但是流程管理却不方便(即何时执行第一阶段、何时执行第二阶段)。

三、Thunk 函数是自动执行 Generator 函数的一种方法

要理解Thunk函数,首先来看一下“求值策略”的问题。

上世纪六十年代,编程语言刚刚起步,计算机学家还在研究,编译器怎么写比较好。一个争论的焦点是"求值策略",即函数的参数到底应该何时求值。

let x = 1

function f(m) {
  return m * 2
}

f(x + 5)

上面代码先定义函数f,然后向它传入表达式x + 5。请问,这个表达式应该何时求值?

一种意见是"传值调用"(call by value),即在进入函数体之前,就计算x + 5的值(等于 6),再将这个值传入函数f。C 语言就采用这种策略。

f(x + 5)
// 传值调用时,等同于
f(6)

另一种意见是“传名调用”(call by name),即直接将表达式x + 5传入函数体,只在用到它的时候求值。Haskell 语言采用这种策略。

f(x + 5)
// 传名调用时,等同于
(x + 5) * 2

传值调用和传名调用,哪一种比较好?

回答是各有利弊。传值调用比较简单,但是对参数求值的时候,实际上还没用到这个参数,有可能造成性能损失。

function f(a, b){
  return b
}

f(3 * x * x - 2 * x - 1, x)

上面代码中,函数f的第一个参数是一个复杂的表达式,但是函数体内根本没用到。对这个参数求值,实际上是不必要的。因此,有一些计算机学家倾向于"传名调用",即只在执行时求值。

Thunk 函数的含义

编译器的“传名调用”实现,往往是将参数放到一个临时函数之中,再将这个临时函数传入函数体。这个临时函数就叫做 Thunk 函数。

function f(m) {
  return m * 2
}

f(x + 5)

// 等同于

let thunk = function () {
  return x + 5
}

function f(thunk) {
  return thunk() * 2
}

上面代码中,函数 f 的参数x + 5被一个函数替换了。凡是用到原参数的地方,对Thunk函数求值即可。

这就是 Thunk 函数的定义,它是“传名调用”的一种实现策略,用来替换某个表达式。

JavaScript 语言中的 Thunk 函数

JavaScript 语言是传值调用,它的 Thunk 函数含义有所不同。在 JavaScript 语言中,Thunk 函数替换的不是表达式,而是多参数函数,将其替换成一个只接受回调函数作为参数的单参数函数。

// 正常版本的readFile(多参数版本)
fs.readFile(fileName, callback)

// Thunk版本的readFile(单参数版本)
let Thunk = function (fileName) {
  return function (callback) {
    return fs.readFile(fileName, callback)
  }
}

let readFileThunk = Thunk(fileName)
readFileThunk(callback)

上面代码中,fs 模块的 readFile 方法是一个多参数函数,两个参数分别为文件名和回调函数。经过转换器处理,它变成了一个单参数函数,只接受回调函数作为参数。这个单参数版本,就叫做 Thunk 函数。

任何函数,只要参数有回调函数,就能写成 Thunk 函数的形式。下面是一个简单的 Thunk 函数转换器。

const Thunk = function(fn) {
  return function (...args) {
    return function (callback) {
      return fn.call(this, ...args, callback)
    }
  }
}

使用上面的转换器,生成 fs.readFile 的 Thunk 函数。

let readFileThunk = Thunk(fs.readFile)
readFileThunk(fileA)(callback)

下面是另一个完整的例子。

function f(a, cb) {
  cb(a)
}
const ft = Thunk(f)

ft(1)(console.log) // 1

生产环境的转换器,建议使用 Thunkify 模块

首先是安装:

$ npm install thunkify

使用方式如下:

let thunkify = require('thunkify')
let fs = require('fs')

let read = thunkify(fs.readFile)
read('package.json')(function(err, str){
  // ...
})

Thunkify 的源码:

function thunkify(fn) {
  return function() {
    let args = new Array(arguments.length)
    let ctx = this

    for (let i = 0; i < args.length; ++i) {
      args[i] = arguments[i]
    }

    return function (done) {
      let called

      args.push(function () {
        if (called) return
        called = true
        done.apply(null, arguments)
      })

      try {
        fn.apply(ctx, args)
      } catch (err) {
        done(err)
      }
    }
  }
}

它的源码主要多了一个检查机制,变量called确保会滴哦啊函数只运行一次。这样的设计与下文的 Generator 函数相关。请看下面的例子:

function f(a, b, callback){
  let sum = a + b
  callback(sum)
  callback(sum)
}

let ft = thunkify(f)
let print = console.log.bind(console)
ft(1, 2)(print)
// 3

上面代码中,由于 thunkify 只允许回调函数执行一次,所以只输出一行结果。

Generator 函数的流程管理

你可能会问, Thunk 函数有什么用?回答是以前确实没什么用,但是 ES6 有了 Generator 函数,Thunk 函数现在可以用于 Generator 函数的自动流程管理。

Generator 函数可以自动执行了:

function* gen() {
  // ...
}

let g = gen()
let res = g.next()

while(!res.done){
  console.log(res.value)
  res = g.next()
}

上面代码中,Generator 函数 gen 会自动执行完所有步骤。

但是,这不适合异步操作。如果必须保证前一步执行完,才能执行后一步,上面的自动执行就不可行。这时,Thunk 函数就能派上用处。以读取文件为例。下面的 Generator 函数封装了两个异步操作。

let fs = require('fs')
let thunkify = require('thunkify')
let readFileThunk = thunkify(fs.readFile)

let gen = function* (){
  let r1 = yield readFileThunk('/etc/fstab')
  console.log(r1.toString())
  let r2 = yield readFileThunk('/etc/shells')
  console.log(r2.toString())
}

上面代码中,yield命令用于将程序的执行权移出 Generator 函数,那么就需要一种方法,将执行权再交还给 Generator 函数。

这种方法就是 Thunk 函数,因为它可以在回调函数里,将执行权交还给 Generator 函数。为了便于理解,我们先看如何手动执行上面这个 Generator 函数。

let g = gen()

let r1 = g.next()
r1.value(function (err, data) {
  if (err) throw err
  let r2 = g.next(data)
  r2.value(function (err, data) {
    if (err) throw err
    g.next(data)
  })
})

上面代码中,变量g是Generator 函数的内部指针,表示目前执行到哪一步。next方法负责将指针移动到下一步,并返回该步的信息(value属性和done属性)。

仔细查看上面的代码,可以发现 Generator 函数的执行过程,其实是将同一个回调函数,反复传入next方法的value属性。这使得我们可以用递归来自动完成这个过程。

Thunk 函数的自动流程管理

Thunk 函数真正的威力,在于可以自动执行 Generator 函数。下面就是一个基于 Thunk 函数的 Generator 执行器。

function run(fn) {
  let gen = fn()

  function next(err, data) {
    let result = gen.next(data)
    if (result.done) return
    result.value(next)
  }

  next()
}

function* g() {
  // ...
}

run(g)

上面代码的run函数,就是一个 Generator 函数的自动执行器。内部的 next 函数就是 Thunk 的回调函数。 next 函数先将指针移到 Generator 函数的下一步(gen.next方法),然后判断 Generator 函数是否结束(result.done属性),如果没结束,就将 next 函数再传入 Thunk 函数 (result.value属性),否则就直接退出。

有了这个执行器,执行 Generator 函数方便多了。不管内部有多少个异步操作,直接把 Generator 函数传入 run 函数即可。当然,前提是每一个异步操作,都要是 Thunk 函数,也就是说,跟在 yield 命令后面的必须是 Thunk 函数。

let g = function* (){
  let f1 = yield readFileThunk('fileA')
  let f2 = yield readFileThunk('fileB')
  // ...
  let fn = yield readFileThunk('fileN')
}

run(g)

上面的函数g 封装了 n 个异步的读取文件操作,只要执行 run 函数,这些操作就会自动完成。这样一来,异步操作不仅可以写得像同步操作,而且一行代码就可以执行。

Thunk函数并不是 Generator 函数自动执行的唯一方案。因为自动执行的关键是,必须有一种机制,自动控制 Generator 函数的流程,接收和交还程序的执行权。回调函数可以做到这一点,Promise对象也可以做到这一点。

使用co 模块完成自动执行

co 模块是一个小工具,用于 Generator 函数的自动执行。

下面是一个 Generator 函数,用于依次读取两个文件。

let gen = function* () {
  let f1 = yield readFile('/etc/fstab')
  let f2 = yield readFile('/etc/shells')
  console.log(f1.toString())
  console.log(f2.toString())
}

co 模块可以让你不用编写 Generator 函数的执行器。

var co = require('co')
co(gen)

上面代码中,Generator 函数只要传入 co 函数,就会自动执行。

co 函数返回一个 Promise 对象,因此可以用 then 方法添加回调函数。

co(gen).then(function (){
  console.log('Generator 函数执行完成')
})

上面代码中,等到 Generator 函数执行结束,就会输出一行提示。

co 模块的原理

为什么 co 可以自动执行 Generator 函数?

前面说过,Generator 就是一个异步操作的容器。它的自动执行需要一种机制,当异步操作有了结果,能够自动交回执行权。

两种方法可以做到这一点。

(1)回调函数。将异步操作包装成 Thunk 函数,在回调函数里面交回执行权。

(2)Promise 对象。将异步操作包装成 Promise 对象,用then方法交回执行权。

co 模块其实就是将两种自动执行器(Thunk 函数和 Promise 对象),包装成一个模块。使用 co 的前提条件是,Generator 函数的yield命令后面,只能是 Thunk 函数或 Promise 对象。如果数组或对象的成员,全部都是 Promise 对象,也可以使用 co,详见后文的例子。

下面来看,基于 Promise 对象的自动执行器。这是理解 co 模块必须的。

基于 Promise 对象的自动执行

还是沿用上面的例子。首先,把 fs 模块的 readFile 方法包装成一个 Promise 对象。

let fs = require('fs')

let readFile = function (fileName){
  return new Promise(function (resolve, reject){
    fs.readFile(fileName, function(error, data){
      if (error) return reject(error)
      resolve(data)
    })
  })
}

let gen = function* (){
  let f1 = yield readFile('/mc/show')
  let f2 = yield readFile('/mc/shells')
  console.log(f1.toString())
  console.log(f2.toString())
}

然后,手动执行上面的 Generator 函数。

let g = gen()

g.next().value.then((data) => {
  g.next(data).value.then((data) => {
    g.next(data)
  })
})

手动执行其实就是用 then 方法,层层添加回调函数。理解了一点,就可以写出一个自动执行器。

function run(gen){
  let g = gen()

  function next(data){
    let result = g.next(data)
    if (result.done) return result.value
    result.value.then(function(data){
      next(data)
    })
  }

  next()
}

run(gen)

上面代码中,只要 Generator 函数还没执行到最后一步,next函数就调用自身,以此实现自动执行。

co 模块的源码

co 就是上面那个自动执行器的扩展,它的源码只有几十行,非常简单。

首先,co 函数接受 Generator 函数作为参数,返回一个 Promise 对象。

function co(gen) {
  let ctx = this

  return new Promise((resolve, reject) => {
  })
}

在返回的 Promise 对象里面,co 先检查参数gen是否为 Generator 函数。如果是,就执行该函数,得到一个内部指针对象;如果不是就返回,并将 Promise 对象的状态改为resolved。

function co(gen) {
  let ctx = this

  return new Promise((resolve, reject) => {
    if (typeof gen === 'function') gen = gen.call(ctx)
    if (!gen || typeof gen.next !== 'function') return resolve(gen)
  })
}

接着,co 将 Generator 函数的内部指针对象的next方法,包装成onFulfilled函数。这主要是为了能够捕捉抛出的错误。

function co(gen) {
  let ctx = this

  return new Promise((resolve, reject) => {
    if (typeof gen === 'function') gen = gen.call(ctx)
    if (!gen || typeof gen.next !== 'function') return resolve(gen)

    onFulfilled()
    function onFulfilled(res) {
      let ret
      try {
        ret = gen.next(res)
      } catch (e) {
        return reject(e)
      }
      next(ret)
    }
  })
}

最后,就是关键的next函数,它会反复调用自身。

function next(ret) {
  if (ret.done) return resolve(ret.value)
  let value = toPromise.call(ctx, ret.value)
  if (value && isPromise(value)) return value.then(onFulfilled, onRejected)
  return onRejected(
    new TypeError(
      '你只能 yield 一个promise, 或者 generator, array, object, '
      + '但以下对象是: "'
      + String(ret.value)
      + '"'
    )
  )
}

上面代码中, next 函数的内部代码,一共只有四行命令。

第一行,检查当前是否为 Generator 函数的最后一步,如果是就返回。

第二行,确保每一步的返回值,是 Promise 对象。

第三行,使用then方法,为返回值加上回调函数,然后通过onFulfilled函数再次调用next函数。

第四行,在参数不符合要求的情况下(参数非 Thunk 函数和 Promise 对象),将 Promise 对象的状态改为rejected,从而终止执行。

处理并发的异步操作

co 支持并发的异步操作,即允许某些操作同时进行,等到它们全部完成,才进行下一步。

这时,要把并发的操作都放在数组或对象里面,跟在yield语句后面。

// 数组的写法
co(function* () {
  let res = yield [
    Promise.resolve(1),
    Promise.resolve(2)
  ]
  console.log(res)
}).catch(onerror)

// 对象的写法
co(function* () {
  let res = yield {
    1: Promise.resolve(1),
    2: Promise.resolve(2),
  }
  console.log(res)
}).catch(onerror)

下面是另一个例子。

co(function* () {
  let values = [n1, n2, n3]
  yield values.map(somethingAsync)
})

function* somethingAsync(x) {
  // do something async
  return y
}

上面的代码允许并发三个somethingAsync异步操作,等到它们全部完成,才会进行下一步。

你可能感兴趣的:(Javascript,ES6,JS,Javascript,JS,异步,Generator,异步应用)