前言
在上一篇文章末尾处,为了更方便地解释thunk函数的作用,引入了一小部分yield关键字的介绍,但没有具体说明怎么用yield来进行异步编程(yield也是异步编程中的一种方法),所以在这里记录一些学习yield异步编程的心得和一些co库函数的新理解(新坑)。
重识yield关键字
上一篇文章中我们用一个生成斐波那契数列的方式来引入了yield,当时我们理解yield就是一个暂停键,可以把一个生成器函数(generator)在yield关键字处,返回yield后面的内容然后跳出生成器,这里我开始有一些疑惑,“那这和同步阻塞有什么区别?同步阻塞也是将一个函数阻塞住,不就类似一种暂停么?”,其实后来想想阻塞和暂停有本质区别,其区别在于:进程还能不能继续运行其他东西,阻塞以后住进程*暂停在当前的处理逻辑,而经过yield暂停以后进程可以主动去处理其他的运行逻辑,处理完其他逻辑以后再回到当时yield的地方继续执行。
但到现在为止,yield并没有表现出可以进行异步编程的特性,宏观上来看yield还是依据同步数据流来运行的,yield要完成异步编程还需要与Promise结合起来用才行,根据yield有暂停的特性,配合Promise
- 先将异步操作用Promise实现,通过yield带出去
- 然后执行then函数,获取异步处理结果
- 再次执行next,将异步结果传回 generator内部,赋值给yield左边的表达式
这样就可以按照同步执行的逻辑顺序来执行异步代码,我们举个例子
const fs = require(‘fs‘)
function promiseFile() {
return new Promise((resolve, reject) => {
fs.readFile(‘bin.js‘, ‘utf-8‘, (err, data) => {
if(err) reject(err)
resolve(data)
})
})
}
function *generator() {
var data = yield promiseFile()
console.log(data)
console.log(‘After read file do something.‘)
}
var a = generator()
var b = a.next().value
var data = b.then((data) => {
a.next(data).value
})
generator
函数中有一段逻辑,通过异步读取文件内容,并将文件内容输出以后再打印一段After read file do something.
假如没有yield的话,After...
一定是比文件内容先输出的,但经过yield暂停之后,文件内容输出以后才会输出After...
。这样看起来好像用yield配合Promise使用,还不如直接使用Promise链?但其实使用上yield之后,异步编程的数据处理逻辑就可以在generator中保持同步流顺序了,在then链上异步获取数据;而单独使用Promise链的话在链上既要异步获得数据同时还要对数据按同步方式处理,多少会有些混杂,此外yield的大杀器要在co库中才体现出来,在讲co库之前,我们先来解决yield的另外一个问题,提前为引入co库做铺垫。
yield自动化执行
假如我要用生成器的方式读取a、b、c三个文件中的文件内容,那我首先会写出这样的代码(这里用了yield *
,但这个不是重点)
const fs = require(‘fs‘)
const thunkify = function (fn) {
return function () {
var args = [].slice.call(arguments)
return function (cb) {
fn.apply(this, args.concat(cb))
}
}
}
const readFile = thunkify(fs.readFile)
function *generator(){
var a = yield readFile(‘a.txt‘, ‘utf-8‘)
console.log(a)
var c = yield *generator2()
var b = yield readFile(‘b.txt‘, ‘utf-8‘)
console.log(b)
}
function *generator2(){
var c = yield readFile(‘c.txt‘, ‘utf-8‘) // 传回来的data在这里
console.log(c)
}
var example = generator()
var a = example.next().value
a((err, data) => {
var c = example.next(data).value
c((err, data) => {
var b = example.next(data).value
b((err, data) => {
example.next(data)
})
})
})
在每一次的回调函数中,将获得的data值返回给generator函数并输出,但我们仔细观察那条链式调用会发现,其实主要完成的工作就是example一直在调用next方法,来不断获取yield的返回值,那其实可以简化成如下代码(generator1和generator2都相同,就不重复了)
function run(g){
var it = g()
function next(err, data) {
var item = it.next(data)
if(!item.done){
item.value(next)
}
}
next()
}
run(generator)
个人认为如上的这个yield自动化函数设计得是很巧妙的,抽离出来一步一步分析
- 将生成器函数(generator)作为参数传入
run
函数 - 利用生成器函数生成一个迭代器it
- 然后是最精妙的地方,
next()
表示调用next
函数,next
函数在设计上就是一个回调函数的样子,接收err
和data
参数(如果调用时没有明确提交这两个参数,该参数使用默认值),var item = it.next(data)
执行第一次next
获得第一次yield的返回值,注意这里data其实是undefined
但这并不影响yield运行,因为生成器是会忽略第一次传入的值的;根据done
的值确定生成器函数是否执行完成,如果没有执行完成就直接将next
函数(这就是为什么要将next设计成一个回调函数的样子的原因)作为参数调用readFile
函数(即item.value
),这样又会继续调用var item = it.next(data)
使迭代器获得下一个yield返回值,直到done
为true
跳出。
co库
经过上面的代码处理,我们已经知道可以用一个自动化函数来处理生成器函数,让生成器不断执行next函数,直到done
为true
为止,上面我们已经介绍了yield关键字可以返回thunk函数或者一个Promise对象,而co库也正好可以针对thunk&Promise进行自动化处理,这篇文章有一些对co库源码的分析,在此基础上我们来看一些文章中没有提到的其他源码,例如文中提到co库使用并发可以这么写
co(function *() {
var res = yield [Promise.resolve(‘hello‘), Promise.resolve(‘world!‘)]
console.log(res)
})
这里文档中提到,要利用co来实现并发处理,需将并发对象或者值都放到一个数组里,但此时就有两个问题,
- co是怎么处理在数组里的待并发对象的呢?
- 此处是一个yield,但我们知道res其实是交回控制权时,next(data)传递data传回来的值,那co又是怎么自动化理解这个步骤的呢?
先讲第一个问题,在最开始讲co的时候我们有提到co接收一个thunk函数或者一个Promise对象,但其实在co代码中会将thunk函数自动转为一个Promise对象,然后统一调用Promise对象,此处还有一个坑我们后面再表,在co的源代码中加入接收到一个数组那么会先将数组转为一个Promise数组,然后用Promise.all
来处理
function arrayToPromise(obj) {
return Promise.all(obj.map(toPromise, this));
}
Promise.all
在处理Promise数组时,会按顺序处理每个Promise对象,就类似按顺序对每一个Promise对象作异步处理,注意这里虽然处理完的结果(仍然是个数组)还是按照原数组格式映射的,但其实执行流程并不是按顺序的,例如如下代码
const fs = require(‘fs‘)
const process = require(‘child_process‘)
const thunkify = require(‘thunkify‘)
const co = require(‘co‘)
const exec = thunkify(process.exec)
const readFile = thunkify(fs.readFile)
function promiseExec() {
return new Promise(resolve => {
exec(‘sleep 3;ls‘)((err, stdout, stderr) => {
console.log(‘Exec‘)
resolve(stdout)
})
})
}
function promiseReadFile() {
return new Promise(resolve => {
readFile(‘bin.js‘, ‘utf-8‘)((err, data) => {
console.log(‘Read file.‘)
resolve(data)
})
})
}
var promiseList = [promiseExec(), promiseReadFile()]
Promise.all(promiseList).then((value => {
console.log(value)
}))
在变量promiseList
是用于执行shell命令和读取文件的Promise对象,但在执行shell时故意等待3s,会发现执行过程中Read file.
先行打印出来,Exec
后打印出来,但其实最终的value当中还是shell的执行结果在靠前的位置上。
同理co用Promise.all
来处理Promise数组。
然后是第二个问题,res
和promiseList之后是怎样执行流程呢,这里有一些co库中一些关键代码的注释
function co(gen) {
var ctx = this;
var args = slice.call(arguments, 1);
// 一开始就返回一个 Promise 对象
return new Promise(function(resolve, reject) {
// 如果输入是一个 GeneratorFunction,则先得到其执行后的 Generator 对象
if (typeof gen === ‘function‘) gen = gen.apply(ctx, args);
// 如果 gen 不是一个 Generator,则 Promise 的状态变成 fulfilled,并将 gen 作为返回值
if (!gen || typeof gen.next !== ‘function‘) return resolve(gen);
// 启动遍历 Generator 的过程
onFulfilled();
function onFulfilled(res) {
var ret;
try {
// 获取 Generator 中下一个值
ret = gen.next(res);
} catch (e) {
// 在执行过程中出现任何错误, 都直接让外围 Promise 的状态变成 rejected
return reject(e);
}
next(ret);
return null;
}
// 退出 Generator, 并让外围的 Promise 的状态变成 rejected
function onRejected(err) {
var ret;
try {
ret = gen.throw(err);
} catch (e) {
return reject(e);
}
next(ret);
}
// 这个是 co 中最关键的函数
// 接收一个 Generator 遍历出来的值 { value, done }
// 并将 value 作为下一个 .next() 方法的输入
// 这里造成的效果是, yield 语句后面跟着的值(即 value)会成为上一个 yield 语句的返回值
function next(ret) {
if (ret.done) return resolve(ret.value);
// 封装成 Promise
var value = toPromise.call(ctx, ret.value);
// 继续进行 Generator 的遍历
if (value && isPromise(value)) return value.then(onFulfilled, onRejected);
// 如果 value 的值的类型不是 Function/Promise/Generator/GeneratorFunction/Array/Object 的话
// 则中断整个 Generator 并让 Promise 状态为 rejected
return onRejected(new TypeError(‘You may only yield a function, promise, generator, array, or object, ‘
+ ‘but the following object was passed: "‘ + String(ret.value) + ‘"‘));
}
})
}
这里关键处理点在function next(ret)
这里,每一次处理Promise对象时,其实都会把上一次处理的结果作为data传入gen.next(res)
,这样就使得promiseList处理完以后也是对应一个返回结果的数组。
记一次困惑
这是我最开始使用co库时候遇到问题,我当时是想用co来并行处理读取文件内容,代码如下
const fs = require(‘fs‘)
const thunkify = require(‘thunkify‘)
const co = require(‘co‘)
const readFile = thunkify(fs.readFile)
co(function *() {
var fileNameList = [‘a.txt‘, ‘c.txt‘]
var res = yield fileNameList.map(value => readFile(value))
console.log(res)
})
虽然上面的代码没什么问题,当我当时着实没能理解map的处理过程,在官方文档中关于map的操作定义是会对按照一个函数关系来处理每一个数组内的值做一次处理,但我们知道readFile
其实是一个thunk函数,那其实经过map作用以后,数组里的值映射的其实就是两个thunk函数,那为什么会能输出文件内容呢?
在上文中我们解释了为什么yield后面接一个promiseList时,会将上一次yield的值传递回来给res,其中代码中有一个关键函数是toPromise
,来看一下toPromise
的源码
function toPromise(obj) {
if (!obj) return obj;
if (isPromise(obj)) return obj;
if (isGeneratorFunction(obj) || isGenerator(obj)) return co.call(this, obj);
if (‘function‘ == typeof obj) return thunkToPromise.call(this, obj);
if (Array.isArray(obj)) return arrayToPromise.call(this, obj);
if (isObject(obj)) return objectToPromise.call(this, obj);
return obj;
}
针对上文已经是Promise对象的情况下,在第二行的逻辑就执行返回了,但假如是一个thunk函数那if (‘function‘ == typeof obj)
逻辑就起到了作用,会执行thunkToPromise
将thunk函数转为一个Promise对象,来看一下thunkToPromise
函数的代码
function thunkToPromise(fn) {
var ctx = this;
return new Promise(function (resolve, reject) {
fn.call(ctx, function (err, res) {
if (err) return reject(err);
if (arguments.length > 2) res = slice.call(arguments, 1);
resolve(res);
});
});
}
给thunk函数添加了(err, res)
的回调函数(因为大多数的回调函数都是双参数类型,但针对单参数类型的回调函数就不支持了,这个问题有其他的解决办法),然后组合成一个Promise对象,这样就回到了我们上面解答的yield和Promise配合的问题,这样就把map的映射问题解决了。
结语
yield关键字自身只是一种暂停机制,yield要和Promise配合才能实现异步编程的效果;此外yield这种暂定又和同步编程的阻塞不同,阻塞是一种*的等待,而yield暂停以后进程可以去执行其他的操作,例如又进行多个异步进程等(然后设置回调函数等待回调),经过一段时间处理以后再将控制权交回生成器函数;co库是一个针对yield自动化异步执行的库,其要求yield返回thunk函数或者一个Promise对象,并可以多次自动化执行next方法。