Generator 函数的异步应用
一、传统方法
ES6 诞生以前,异步编程的方法大概有下面4种
- 回调函数
- 事件监听
- 发布/订阅
- Promise 对象
二、基本概念
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),意思是多个线程互相协作,完成异步任务
协程类似函数,又有点像线程。运行流程大致如下:
- 第一步,协程A开始执行
- 第二步,协程A执行到一半,进入暂停状态,执行权移动协程B中
- 第三步,(一段时间后)协程B交换执行权
- 第四步,协程A回复执行
举例来说,读取文件的协程写法如下
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 就是一个异步操作的容器。它的自动执行需要一种机制,当异步操作有了结果,这种机制要自动交会执行权
有两种方法可以做到这一点。
- 回调函数。将异步操作包装成 Thunk 函数,在回调函数里面交会执行权
- Promise 对象。将异步操作包装成 Promise 对象,用 then 方法交会执行权
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 函数就调用自身,以此实现自动执行