浅谈ES6 Generator函数的异步应用与co模块的实现原理
一.Generator函数的概念
Generator函数是 ES6 提供的一种异步编程解决方案。前面讨论过的Promise对象也是ES6提供的异步解决方案,为什么还要提出Generator呢。
使用Promise对象处理异步固然有不少优势,尤其是可以将回调地狱的处理变为then的链式调用。但也不可避免的存在一些缺点,例如经过Promise包装的异步会包含大量的Promise名词(resolve,reject,then...),可读性不好。
其实,异步任务的最佳处理方式应当是像操作同步任务那样操作异步任务,即异步任务之后的代码直接写在异步下面,而不是写在回调函数或then方法中。Generator 函数的提出就是为了解决这个问题。如何做到将异步的操作同步化呢。试想,我们如果能赋予函数'暂停'执行的功能,即遇到异步任务时,将当前上下文的状态暂存起来,等到异步任务结束后,拿到异步结果再继续向下执行,这样就能实现上述需求。这就是Generator 函数的异步处理思想。
如何能实现函数的‘暂停'执行?这里要引出Iterator接口(遍历器)的概念
二.Iterator的概念
Iterator是一种接口,它为不同的数据结构提供统一的访问机制。任何数据结构只要部署了Iterator 接口,就可以完成遍历操作。
Iterator可以认为是一个指针对象,通过next方法对数据结构进行遍历,每次调用next方法,指针就指向数组的下一个成员并返回数据结构的当前成员的信息。该信息是一个对象,包含value和done两个属性。其中,value属性是当前成员的值,done属性是一个布尔值,表示遍历是否结束。
ES6规定,Iterator 接口部署在数据结构的Symbol.iterator属性,调用这个接口,就会返回一个遍历器对象。
下面用数组为栗子演示
let arr = [1, 2, 3];
// 返回遍历器对象
let it = arr[Symbol.iterator]();
// 通过next方法遍历
console.log(it.next()) // { value: 1, done: false }
console.log(it.next()) // { value: 2, done: false }
console.log(it.next()) // { value: 3, done: false }
console.log(it.next()) // { value: undefined, done: true }
并不是所有的数据结构都原生具备 Iterator 接口。ES6中原生具备 Iterator 接口的数据结构如下。
- Array
- Map
- Set
- String
- TypedArray
- 函数的 arguments 对象
- NodeList 对象
Iterator 接口用于for...of循环,也就是说,一个数据结构只要部署了Iterator 接口,他就可以被for...of遍历。反之则无法遍历(如object)。
不过,我们可以给没有原生Iterator 接口的数据结构手动部署该接口。具体来说,就是给其添加Symbol.iterator属性,它是一个函数,调用该函数,返回遍历器对象。这样,我们用for...of对其遍历时,就会手动调用我们部署的Iterator 接口。下面演示给object部署Iterator 接口。
const obj = {
a: 'a',
b: 'b',
c: 'c',
[Symbol.iterator]: function () {
let keys = Object.keys(this);
let index = 0;
return {
next: function () {
return index < keys.length
? {
value: this[keys[index++]],
done: false,
}
: {
value: undefined,
done: true,
};
}.bind(this),
};
},
};
for (const it of obj) {
console.log(it)
}
// a
// b
// c
经过上面讨论我们知道,对于遍历器,只有执行next方法,才会继续向下遍历。Generator 函数正是利用这一点,实现异步操作的同步化。进一步讲,执行 Generator 函数会返回一个遍历器对象。它可以遍历Generator 函数内部封装的多个状态。下面具体分析。
三.Generator函数的形式与基本使用
1. Generator 函数的形式。
Generator函数有两个区别于普通函数的明显特征。
- function关键字与函数名之间有一个星号。
- 函数体内部使用yield表达式划分不同部状态。
function*gen(){
yield 1
yield 2
}
// 得到遍历器对象
let g = gen()
console.log(g.next()) // { value: 1, done: false }
console.log(g.next()) // { value: 2, done: false }
console.log(g.next()) // { value: undefined, done: true }
2. yield表达式
通过上面例子我们能看出,yield表达式就是用来划分Generator 函数的各个状态,他可以理解为函数暂停的标志。当执行next()方法,遇到yield表达式时,就暂停执行后面的操作,并将yield表达式的值作为next方法返回的信息对象的value属性值。下次调用next()方法,继续执行yield表达式后面的操作。这一点很重要,我们将利用这一点实现像操作同步那样操作异步。
function*gen(){
yield 1+2
yield 2+3
}
// 得到遍历器对象
let g = gen()
console.log(g.next()) // { value: 3, done: false }
console.log(g.next()) // { value: 5, done: false }
console.log(g.next()) // { value: undefined, done: true }
四.Generator函数的异步应用
我们已经了解了Generator 函数的基本特点,回到最开始的问题,如何实现异步操作的同步化。我们的需求是在异步操作结束后,再执行后面的操作,而Generator 函数的特点是只有在执行next方法后,函数从当前状态变为下一状态。因此我们只需用yield,将每个异步操作划为一个状态,这样就可以保证遇到异步操作时函数暂停执行。而在每个异步操作结束的时,调用next方法,使得函数继续执行,这就实现了用同步操作的逻辑来操作异步。
要实现上述,还需解决两个问题。
1. 传递异步结果
我们知道,异步操作之后的处理往往需要异步的返回结果,那么一个首要问题就是如何将异步返回结果传递出来。
我们要明确一点,yield表达式是没有返回值的(undefinded),也就是说直接使用下面这种方式是行不通的。
function*gen(){
const res = yield async1()
yield async2(res)
}
要传递结果,我们要借助next方法。next方法如果有入参,该参数会被当作上一个yield表达式的返回值。
function*gen(){
const res1 = yield 1
const res2 = yield res1+1
yield res2+2
}
// 得到遍历器对象
let g = gen()
console.log(g.next()) // { value: 1, done: false }
// next方法传入3 认为res1=3 3+1=4
console.log(g.next(3)) // { value: 4, done: false }
console.log(g.next(4)) // { value: 6, done: false }
因此,我们只需将异步的返回结果传入next方法即可
2. 异步结束后调用next方法
我们日常对异步的处理无非是回调函数和Promsie两种方式,因此也就有两种思路解决该问题。
2.1 基于回调函数的Generator异步流程处理
我们只需在异步的回调函数中调用next方法,即可实现异步结束后继续执行Generator函数。
const async1 = () => {
setTimeout(() => {
// 执行next方法 传递异步结果
g.next(1);
});
};
const async2 = (res) => {
setTimeout(() => {
console.log(res + " from async1");
g.next(2);
});
};
const async3 = (res) => {
setTimeout(() => {
console.log(res + " from async2");
});
};
function* gen() {
const res1 = yield async1();
const res2 = yield async2(res1);
yield async3(res2);
}
// 得到遍历器对象
let g = gen();
g.next();
//1 from async1
//2 from async2
上面的代码基本实现了需求,我们发现Generator函数内部的异步逻辑处理,如果去掉yield就基本和同步操作一样了。
不过,上面代码的问题也很明显,我们需要对每个异步的回调进行处理。这样是很低效的,因为我们发现在回调中做的其实是同一件事,即执行next方法并传入异步返回结果。我们如果能将这个过程抽离出来,并自动执行。将使得代码逻辑大为简化。下面依次解决这两个问题。
- 抽取回调函数的处理
如何能将回调函数的处理抽离出来?
以setTimeot函数为例,它接受两个参数,分别是回调函数和延时时间。而我们希望将这个两个参数分开传入,单独处理。这里就可以想到前面讨论过的柯里化函数。柯里化函数可以将接受多个参数的函数变换成接受一个单一参数,并返回接受余下参数的函数。
还是以setTimeot函数为例,如果经过柯里化处理,我们可以先传入延时时间,再向返回的函数中传入回调,这就实现了上述需求。像下面这样
function currying(time) {
return (cb) => {
return setTimeout(cb, time);
};
}
const curryTimeout = currying(500);
curryTimeout(() => {
console.log("timeOut");
});
接下来的问题是,在什么地方处理异步回调。我们知道,next方法返回值的value属性,就是yield表达式的执行结果。我们如果在yield后面执行经过柯里化处理过的异步(如上例中的currying(500)),就会使得next方法返回值的value属性是一个函数,可以传入异步的回调。因此我们只需将回调函数传入next方法的value属性即可。下面就基于上述对上例进行改造。
function currying(time) {
return (cb) => {
return setTimeout(cb, time);
};
}
function* gen() {
const res1 = yield currying(500);
const res2 = yield currying(res1);
yield currying(res2);
}
const g = gen();
g.next().value(() => {
console.log("async1");
g.next(500).value(() => {
console.log("async2");
g.next(500).value(() => {
console.log("async3");
});
});
});
//每隔0.5秒依次打印async1 async2 async3
可以看到代码逻辑清晰了很多。这里还要说明一点,事实上前面所谓的经过柯里化处理的异步,就是Thunk 函数。所谓的Thunk 函数,其实就是一个临时函数,它可以把一个多参数函数,替换成一个只接受回调函数作为参数的单参数函数。如上例中的curryTimeout函数,它就是一个只接受回调函数作为参数的中间函数,也就是Thunk 函数。用阮一峰老师的话说就是:任何函数,只要参数有回调函数,就能写成 Thunk 函数的形式。上面的例子相当于手动实现了一个丐版Thunk 函数转换器,生产环境中一般使用nodejs的Thunkify模块,它可以实现Thunk 函数的转换。
接下来要做的就是变手动执行为自动执行。
- 自动执行
仔细观察手动执行Generator 函数的代码会返现,我们做的其实只有一件事,即把同一个回调传入next方法的value属性,而回调要做的就是执行next方法并传递异步结果。
基于上述,我们可以实现Generator 函数按照既定逻辑自动执行的程序。它只需判断next方法返回值的done属性,只要不为true,就一直将回调传入next方法的value属性。
下面用node.js fs模块的readFileAPI演示,使用thunkify模块将异步API转换为Thunk函数。准备两个文本文件,内容分别是'对酒当歌' '人生几何'。
const thunkify = require("thunkify");
const fs = require("fs");
const readFileThunk = thunkify(fs.readFile)
function* gen() {
yield readFileThunk("./text1.txt");
yield readFileThunk("./text2.txt");
}
function run(fn) {
const gen = fn();
function next(err, data) {
// 错误优先的回调
if (data) console.log(data.toString());
const res = gen.next(data);
if (res.done) return;
res.value(next);
}
next();
}
run(gen);
// 对酒当歌
// 人生几何
可以看到,有了自动执行器,我们只管在Generator函数内部处理异步,最后直接把 Generator 函数传入run函数即可,当然前提是yield表达式必须是Thunk函数。
2.2 基于Promise的Generator异步流程处理
通过观察前面实现的基于回调的Generator函数自动执行器不难看出,自动执行的关键其实就是在异步结束后调用next方法,让Generator函数继续执行。同样,利用Promise.then方法也能做到这一点。
沿用上面例子对其进行改造,我们要做的其实很简单
- 将readFile函数包装为Promise
- 利用Promise.then方法自动执行
const fs = require("fs");
function promisify_readFile(path) {
return new Promise((resolve, reject) => {
fs.readFile(path, (err, data) => {
if (err) reject(err);
else resolve(data);
});
});
}
function* gen() {
yield promisify_readFile("./text1.txt");
yield promisify_readFile("./text2.txt");
}
function run(fn) {
const gen = fn();
function next(data) {
if (data) console.log(data.toString());
const res = gen.next(data);
if (res.done) return;
//res.value返回的是Promise,可以通过then方法继续执行Generator
res.value.then(next,(r)=>console.log(r))
}
next();
}
run(gen);
至此我们已经基本实现了像文章开头的需求,并实现了自动执行,其实这就是著名的co模块的核心实现原理。
五.co模块及其实现原理
co模块一个著名的用于Generator函数自动执行的模块。它的使用非常简单,只需将Generator函数传入co,即可自动执行。
const co = require("co");
function* gen() {
const res1 = yield promisify_readFile("./text1.txt");
console.log(res1.toString())
const res2 = yield promisify_readFile("./text2.txt");
console.log(res2.toString())
}
co(gen)
// 对酒当歌
// 人生几何
实现原理
其实,经过上面对Generator函数自动执行的讨论我们能够知道,co模块核心实现原理就是我们实现的run函数的扩展。具体如下
- co返回的是Promise 对象,因此要添加一些改变Promise状态的逻辑
- 要确保每一步的返回值都是Promise
下面实现一个丐版的co模块
function co(gen) {
return new Promise(function (resolve, reject) {
gen = gen();
if (!gen || typeof gen.next !== "function") return resolve(gen);
function next(data) {
const res = gen.next(data);
if (res.done) {
return resolve(res.value);
} else {
// 确保每一步的返回值都是Promise
const value = Promise.resolve(res.value);
value.then(next, (r) => reject(r));
}
}
next();
});
}
// 由于co返回的是Promise,因此可以指定then方法使得
// 在Generator执行完成后进行一些操作
co(gen).then(()=>console.log('end'))
// 对酒当歌
// 人生几何
// end
co模块是async/await关键字的前身,async/await被誉为异步编程的终极解决方案,后面会着重介绍。
发表评论 (审核通过后显示评论):