Generator 函数的异步应用

2019-10-18  本文已影响0人  了凡和纤风

一、传统方法

ES6 诞生以前,异步编程的方法大概有下面4种

二、基本概念

2.1 异步

所谓“异步”,简单来说就是一个任务不是连续完成的,可以理解成该任务被人分为两段先执行第一段,然后转而执行其他任务,等做好准备后再执行第二段。
相应的,连续执行叫做同步。由于是连续执行,不能插入其他任务,所以操作系统从硬盘读取文件的这段时间,程序只能等待。

2.2、回调函数

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

fs.readFile('/xxx', 'utf-8', function(err, data) {
  if (err) throw err
  console.log(data)
})

一个有趣的问题是,为什么 Node 约定回调函数的第一个参数必须是错误对象err(错误优先)呢?
因为,执行分为两段,第一段执行完以后,任务所在的上下文环境就已经结束了。在这以后抛出的错误,其原本的上下文环境已经无法捕捉,因此只能当做参数被传入第二段。

2.3、Promise

回调函数本身没有问题,它的问题出现在多个回调函数嵌套上。

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

如上,如果出现多重嵌套。代码不是纵向发展,而是横向发展,很快就会乱成一团。因为多个异步操作形成了强耦合,只要有一个操作需要修改,它的上层回调函数和下层回调函数就都跟着修改。这种情况就称为回调函数地狱(callback hell)

Promise 对象就是为了解决这个问题而被提出的。他不是新的语法功能,而是一种新的写法,允许将回调函数的嵌套改写成链式调用。

采用 Promise 连续读取多个文件的语法如下。

var readFile = require('fs-readfile-promise')

readFile(fileA)
  .then(data => {
    console.log(data.toString())
    return readFile(fileB)
  })
  .then(data => {
    console.log(data.toSting())
  })
  .catch(function(err) {
    console.error(err)
  })

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

Promise 的最大问题是:代码冗余,原来的任务被Promise 包装之后,无论什么操作,一眼看去都是许多 then 的堆积,原来的语义变得很不清楚。

三、Generator 函数

3.1、协程

传统的编程语言中早有异步编程的解决方案(其实是多任务的解决方案),其中一种叫做“协程”(coroutine),意思是多个线程互相协作,完成异步任务

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

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

function* asyncJob() {
  // ...
  var f = yield readFile(fileA)
  // ...
}

asyncJob 是一个协程,它的奥妙在于其中的 yield 命令。他执行到此处时,执行权将交给其他协程。也就是说,yield 命令式异步两个阶段的分界线。协程遇到 yield 命令就暂停,等执行权返回,再从暂停的地方继续往后执行,它的最大优点是,代码的写法非常像同步操作,如果去除 yield 命令,几乎一模一样。

3.2、协程的 Generator 函数实现

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

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

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

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

Gemerator 函数可以暂停执行和恢复执行,这是它能封装异步任务的根本原因。除此之外,还有两个特性使它可以作为异步编程的完整解决方案:函数体内的数据交换和错误处理机制。

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

var g = gen(1)
g.next()
g.next(2) // {value: 2, done: true}

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

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

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

出错的代码与处理错误的代码实现了时间和空间上的分离,这对于异步编程式很重要的。

3.4、异步任务的封装

使用 Generator 函数执行一个异步任务

var fetch = require('node-fetch')

function* gen() {
  var url = 'https://api.github.com/users/github'
  var result = yield fetch(url)
  console.log(result.bio)
}

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

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

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

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

四、Thunk 函数

Thnunk 函数是自动执行的 Generator 函数的一种方法

4.1、参数的求值策略

Thunk 函数早很早就存在了,编程语言起步初期,一个争论的焦点时“求值策略”,即函数的参数到底应该在何时求值

var x = 1

function f(m) {
  return m * 2
}

f(x+5)

一种意见是“传值调用”(call by value),即在进入函数体之前计算 x + 5的值,再将这个值传入函数 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(2 * x + 45 * 32 - x + 10, x)

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

4.2、Thunk 函数的含义

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

function f(x) {
  return m * 2
}

f(x + 5)

// 等同于

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

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

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

4.3、JavaScript 语言的 Thunk 函数

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

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

// Thunk 版本的 readFile
var Thunk = function (fileName) {
  return function(callback) {
    return fs.readFIle(fileName, callback)
  }
}
var readFileThunk = Thunk(fileName)
readFileThunk(callback)

任何函数,只要参数㕛回调函数,就能写成 Thunk 函数的形式。

// ES5 版本
var Thunk = function(fn) {
  return function() {
    var args = Array.prototype.slice.call(arguments)
    return function(callback) {
      args.push(callback)
      return fn.apply(this, args)
    }
  }
}

// ES6 版本
const Thunk = function(fn) {
  return function (...args) {
    return function (callback) {
      return fn.call(this, ...args, callback)
    }
  }
}
// 也就相当于将所有参数累加到最开始传入的函数中作为参数

// 使用
var readFileThunk = Thunk(fs.readFile)
readFileThunk(fileA)(callback)

4.4、Thunkify 模块

生产环境中的转换器建议使用 Thunkify 模块。
基本使用方式如下:

var thunkify = require('thunkify')
var fs = require('fs')

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

Thunkify 的源码与上一节中的简单转换器非常像,区别在于多了一个检查机制。

4.5、Generator 函数的流程管理

ES6 中有了 Generator 函数,Thunk 函数可以用于 Generator 函数的自动流程管理,Generator 函数可以自动执行。

下面的Generator 函数封装了两个 异步操作

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

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

yield 命令用于将程序的执行权移出 Generator 函数,就需要一种方法将执行权再交给 Generator 函数.

这种方法就是使用 Thunk 函数,因为它可以在毁掉和桉树里将执行权交给 Generator 函数。

var g = gen()

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

g 是 Generator 函数的内部指针,标明目前执行到哪一步。next 方法负责将指针移动到下一步,并返回该步的信息(value 属性和 done 属性)
可以发现 Generator 函数的执行过程其实是将同一个回调函数反复传入 next 方法的value
属性。这使得我们可以用递归来自动完成这个过程。

4.6、Thunk 函数的自动流程管理

Thunk 函数真正的威力在于可以自动执行 Generator 函数。

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

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

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

上面代码,会判断 Generator 函数是否结束(result.done属性),如果没有结束,就将 next 函数再传入 Thunk 函数(result.value 属性)

前提是每一个异步操作都要是 Thunk 函数,也就是说,yield 后面的必须是 Thunk 函数。

五、co 模块

5.1、基本用法

co 模块 是著名程序员 TJ Holowaychuk 于 2013年 6月发布的一个小工具,用于 Generator 函数的自动执行

var co = require('co')

var gen = function* () {
  var f1 = yield readFile('/etc/fstab')
  var f2 = yield readFile('/etc/shells')

  console.log(f1.toString())
  console.log(f2.toString())
}

co(gen)

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

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

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

5.2、co 模块的原理

Generator 就是一个异步操作的容器。它的自动执行需要一种机制,当异步操作有了结果,这种机制要自动交会执行权

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

co 模块其实就是将两种自动执行器(Thunk 函数和 Promise对象)包装成一个模块。co的前提条件是,Generator 函数的yield 命令后面只能是 Thunk 函数或 Promise 对象。(co v4.0 版本以后,yield 命令后面只能是 Promise 对象,不在支持 Thunk 函数)

5.3、基于 Promise 对象的自动执行

var fs = require('fs')

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

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

然后手动执行上面的函数

var g = gen()

g.next().value.then(function(data) {
  g.next(data).value.then(function(data) {
    g.next(data)
  })
})

手动执行其实就是用 then 方法层层添加回调函数,由此可以书写如下自动执行器

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

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

run(gen)

只要 Generator 函数还没有执行到最后一步,next 函数就调用自身,以此实现自动执行

上一篇 下一篇

猜你喜欢

热点阅读