# Generator
异步编程一直是 JS 的核心之一,业界也是一直在探索不同的解决方法,从「回调地狱」到发布订阅模式,再到 Promise,都是在优化异步编程。尽管 Promise 已经很优秀了,也不会陷入「回调地狱」,但是嵌套层数多了也会有一连串的 then,始终不能像同步代码那样直接往下写就行了。Generator 是 ES6 引入的进一步改善异步编程的方案,下面我们先来看看基本用法。
# 基本用法
Generator 的中文翻译是「生成器」,其实他要干的事情也是一个生成器,一个函数如果加了 *,他就会变成一个生成器函数,他的运行结果会返回一个迭代器对象,比如下面的代码:
// gen 是一个生成器函数
function* gen() {
let a = yield 1;
let b = yield a + 2;
yield b + 3;
}
let itor = gen(); // 生成器函数运行后会返回一个迭代器对象,即 itor。
# next
ES6 规范中规定 (opens new window)迭代器必须有一个 next 方法,这个方法会返回一个对象,这个对象具有 done 和 value 两个属性,done 表示当前迭代器内容是否已经执行完,执行完为 true,否则为 false,value 表示当前步骤返回的值。在 generator 具体运用中,每次遇到 yield 关键字都会暂停执行,当调用迭代器的 next 时,会将 yield 后面表达式的值作为返回对象的 value,比如上面生成器的执行结果如下:
> itor.next()
> {value: 1, done: false}
我们可以看到第一次调 next 返回的就是第一个 yeild 后面表达式的值,也就是 1。需要注意的是,整个迭代器目前暂停在了第一个 yield 这里,给变量 a 赋值都没执行,要调用下一个 next 的时候才会给变量 a 赋值,然后一直执行到第二个 yield。那应该给 a 赋什么值呢?从代码来看,a 的值应该是 yield 语句的返回值,但是 yield 本身是没有返回值的,或者说返回值是 undefined,如果要给 a 赋值需要下次调 next 的时候手动传进去,我们这里传一个 4,4 就会作为上次 yield 的返回值赋给 a:
> itor.next(4)
> {value: 6, done: false}
可以看到第二个 yield 后面的表达式 a + 2 的值是 6,这是因为我们传进去的 4 被作为上一个 yield 的返回值了,然后计算 a + 2 自然就是 6 了。
我们继续 next,把这个迭代器走完:
> itor.next()
> {value: NaN, done: false}
> itor.next()
> {value: undefined, done: false}
以上是接着前面运行的,第一个 next 返回的 value 是 NaN 是因为我们调 next 的时候没有传参数,也就是说 b 为 undefined,undefined + 3 就为 NaN 了 。最后一个 next 其实是把函数体执行完了,这时候的 value 应该是这个函数 return 的值,但是因为我们没有写 return,默认就是 return undefined 了,执行完后 done 会被置为 true。
# throw
迭代器还有个方法是 throw,这个方法可以在函数体外部抛出错误,然后在函数里面捕获,还是上面那个例子:
function* gen() {
let a = yield 1;
let b = yield a + 2;
yield b + 3;
}
let itor = gen();
我们这次不用 next 执行了,直接 throw 错误出来:
> itor.throw('test error')
> Uncaught test error
这个错误因为我们没有捕获,所以直接抛到最外层来了,我们可以在函数体里面捕获他,稍微改下:
function* gen() {
try {
let a = yield 1;
let b = yield a + 2;
yield b + 3;
} catch (e) {
console.log(e);
}
}
let itor = gen();
然后再来 throw 下:
> itor.next()
> {value: 1, done: false}
> itor.throw('test error');
> test error
> {value: undefined, done: true}
以上输出可以看出来,错误在函数里里面捕获了,走到了 catch 里面,这里面只有一个 console 同步代码,整个函数直接就运行结束了,所以 done 变成 true 了,当然 catch 里面可以继续写 yield 然后用 next 来执行。
# return
迭代器还有个 return 方法,这个方法就很简单了,他会直接终止当前迭代器,将 done 置为 true,这个方法的参数就是迭代器的 value,还是上面的例子:
function* gen() {
let a = yield 1;
let b = yield a + 2;
yield b + 3;
}
let itor = gen();
这次我们直接调用 return:
> itor.return(456)
> {value: 456, done: true}
# yield*
简单理解,yield* 就是在生成器里面调用另一个生成器,但是他并不会占用一个 next,而是直接进入被调用的生成器去运行。
function* gen() {
let a = yield 1;
let b = yield a + 2;
}
function* gen2() {
yield 10 + 5;
yield* gen();
}
let itor = gen2();
上面代码我们第一次调用 next,值自然是 10 + 5,即 15,然后第二次调用 next,其实就走到了 yield* 了,这其实就相当于调用了 gen,然后执行他的第一个 yield,值就是 1。
> itor.next()
> {value: 15, done: false}
> itor.next()
> {value: 1, done: false}
# 协程
其实 Generator 就是实现了协程,协程是一个比线程还小的概念。一个进程可以有多个线程,一个线程可以有多个协程,但是一个线程同时只能有一个协程在运行。这个意思就是说如果当前协程可以执行,比如同步代码,那就执行他,如果当前协程暂时不能继续执行,比如他是一个异步读文件的操作,那就将它挂起,然后去执行其他协程,等这个协程结果回来了,可以继续再来执行他。yield 其实就相当于将当前任务挂起了,下次调用再从这里开始。协程这个概念其实很多年前就已经被提出来了,其他很多语言也有自己的实现。 Generator 相当于 JS 实现的协程。
# 异步应用
前面讲了 Generator 的基本用法,我们用它来处理一个异步事件看看。我还是使用前面文章用到过的例子,三个网络请求,请求 3 依赖请求 2 的结果,请求 2 依赖请求 1 的结果,如果使用回调是这样的:
const request = require('request');
const url = 'https://www.baidu.com';
request(url, function (error1, response1) {
if (error1 || response1.statusCode !== 200) return;
request(url, function (error2, response2) {
if (error2 || response2.statusCode !== 200) return;
console.log(response1.body);
request(url, function (error2, response2) {
if (error3 || response3.statusCode !== 200) return;
console.log(response2.body);
console.log(response3.body);
});
});
});
我们这次使用 Generator 来解决「回调地狱」:
const request = require('request');
function* requestGen() {
function sendRequest(url) {
request(url, function (error, response) {
if (!error && response.statusCode == 200) {
console.log(response.body);
// 注意这里,引用了外部的迭代器 itor
itor.next(response.body);
}
})
}
const url = 'https://www.baidu.com';
// 使用 yield 发起三个请求,每个请求成功后再继续调 next
yield sendRequest(url);
yield sendRequest(url);
yield sendRequest(url);
}
const itor = requestGen();
// 手动调第一个 next
itor.next();
这个例子中我们在生成器里面写了一个请求方法,这个方法会去发起网络请求,每次网络请求成功后又继续调用 next 执行后面的 yield,最后是在外层手动调一个 next 触发这个流程。这其实就类似一个尾调用,这样写可以达到效果,但是在 requestGen 里面引用了外面的迭代器 itor,耦合很高,而且不好复用。
# 简易实现
以下简易实现来自于异步编程二三事 | Promise/async/Generator实现原理解析 | 9k字 (opens new window),这个实现可以帮我们理清 Generator 的 yield 返回值用 ES5 怎么模拟,但是因为 next 不能接收参数,还是做不到后面要讲的 thunk 函数自动执行:
// 生成器函数根据 yield 语句将代码分割为 switch-case 块,
// 后续通过切换 _context.prev 和 _context.next 来分别执行各个 case
function gen$(_context) {
while (1) {
switch (_context.prev = _context.next) {
case 0:
_context.next = 2;
return 'result1';
case 2:
_context.next = 4;
return 'result2';
case 4:
_context.next = 6;
return 'result3';
case 6:
case 'end':
return _context.stop();
}
}
}
// 低配版context
var context = {
next: 0,
prev: 0,
done: false,
stop: function stop() {
this.done = true;
}
}
// 低配版 invoke
let gen = function() {
return {
next: function() {
value = context.done ? undefined: gen$(context);
done = context.done;
return {
value,
done
}
}
}
}
// 测试使用
var g = gen();
g.next(); // {value: "result1", done: false}
g.next(); // {value: "result2", done: false}
g.next(); // {value: "result3", done: false}
g.next(); // {value: undefined, done: true}
# thunk 函数
为了解决前面说的耦合高,不好复用的问题,就有了 thunk 函数。thunk 函数理解起来有点绕,我先把代码写出来,然后再一步一步来分析它的执行顺序:
function Thunk(fn) {
return function(...args) {
return function(callback) {
return fn.call(this, ...args, callback);
}
}
}
function run(fn) {
let gen = fn();
function next(err, data) {
let result = gen.next(data);
if(result.done) return;
result.value(next);
}
next();
}
// 使用 thunk 方法
const request = require('request');
const requestThunk = Thunk(request);
function* requestGen() {
const url = 'https://www.baidu.com';
let r1 = yield requestThunk(url);
console.log(r1.body);
let r2 = yield requestThunk(url);
console.log(r2.body);
let r3 = yield requestThunk(url);
console.log(r3.body);
}
// 启动运行
run(requestGen);
这段代码里面的 Thunk 函数返回了好几层函数,我们从他的使用入手一层一层剥开看:
requestThunk是Thunk运行的返回值,也就是第一层返回值,参数是request,也就是:function(...args) { return function(callback) { return request.call(this, ...args, callback); // 注意这里调用的是 request } }run函数的参数是生成器,我们看看他到底干了啥:run里面先调用生成器,拿到迭代器
gen,然后自定义了一个next方法,并调用这个next方法,为了便于区分,我这里称这个自定义的next为局部next局部
next会调用生成器的next,生成器的next其实就是yield requestThunk(url),参数是我们传进去的url,这就调到我们前面的那个方法,这个yield返回的value其实是:function(callback) { return request.call(this, url, callback); }检测迭代器是否已经迭代完毕,如果没有,就继续调用第二步的这个函数,这个函数其实才真正的去
request,这时候传进去的参数是局部next,局部next也作为了request的回调函数。这个回调函数在执行时又会调
gen.next,这样生成器就可以继续往下执行了,同时gen.next的参数是回调函数的data,这样,生成器里面的r1其实就拿到了请求的返回值。
Thunk 函数就是这样一种可以自动执行 Generator 的函数,因为 Thunk 函数的包装,我们在 Generator 里面可以像同步代码那样直接拿到 yield 异步代码的返回值。
# co 模块
co 模块是一个很受欢迎的模块,他也可以自动执行 Generator,他的 yield 后面支持 thunk 和 Promise,我们先来看看他的基本使用,然后再去分析下他的源码。
官方GitHub:https://github.com/tj/co (opens new window)
# 基本使用
# 支持 thunk
前面我们讲了 thunk 函数,我们还是从 thunk 函数开始。代码还是用我们前面写的 thunk 函数,但是因为 co 支持的 thunk 是只接收回调函数的函数形式,我们使用时需要调整下:
// 还是之前的thunk函数
function Thunk(fn) {
return function(...args) {
return function(callback) {
return fn.call(this, ...args, callback);
}
}
}
// 将我们需要的 request 转换成 thunk
const request = require('request');
const requestThunk = Thunk(request);
// 转换后的 requestThunk 其实可以直接用了
// 用法就是 requestThunk(url)(callback)
// 但是我们 co 接收的 thunk 是 fn(callback) 形式
// 我们转换一下
// 这时候的 baiduRequest 也是一个函数,url 已经传好了,他只需要一个回调函数做参数就行
// 使用就是这样:baiduRequest(callback)
const baiduRequest = requestThunk('https://www.baidu.com');
// 引入 co 执行, co 的参数是一个 Generator
// co 的返回值是一个 Promise,我们可以用 then 拿到他的结果
const co = require('co');
co(function* () {
const r1 = yield baiduRequest;
const r2 = yield baiduRequest;
const r3 = yield baiduRequest;
return {
r1,
r2,
r3,
}
}).then((res) => {
// then里面就可以直接拿到前面返回的{r1, r2, r3}
console.log(res);
});
# 支持 Promise
其实 co 官方是建议 yield 后面跟 Promise 的,虽然支持 thunk,但是未来可能会移除。使用 Promise,我们代码写起来其实更简单,直接用 fetch 就行,不用包装 Thunk。
const fetch = require('node-fetch');
const co = require('co');
co(function* () {
// 直接用 fetch,简单多了,fetch 返回的就是 Promise
const r1 = yield fetch('https://www.baidu.com');
const r2 = yield fetch('https://www.baidu.com');
const r3 = yield fetch('https://www.baidu.com');
return {
r1,
r2,
r3,
}
}).then((res) => {
// 这里同样可以拿到{r1, r2, r3}
console.log(res);
});
# 源码分析
本文的源码分析基于 co 模块 4.6.0 版本,源码:https://github.com/tj/co/blob/master/index.js (opens new window)
仔细看源码会发现他代码并不多,总共两百多行,一半都是在进行 yield 后面的参数检测和处理,检测他是不是 Promise,如果不是就转换为 Promise,所以即使你 yield 后面传的 thunk,他还是会转换成 Promise 处理。转换 Promise 的代码相对比较独立和简单,我这里不详细展开了,这里主要还是讲一讲核心方法 co(gen)。下面是我复制的去掉了注释的简化代码:
function co(gen) {
var ctx = this;
var args = slice.call(arguments, 1);
return new Promise(function(resolve, reject) {
if (typeof gen === 'function') gen = gen.apply(ctx, args);
if (!gen || typeof gen.next !== 'function') return resolve(gen);
onFulfilled();
function onFulfilled(res) {
var ret;
try {
ret = gen.next(res);
} catch (e) {
return reject(e);
}
next(ret);
return null;
}
function onRejected(err) {
var ret;
try {
ret = gen.throw(err);
} catch (e) {
return reject(e);
}
next(ret);
}
function next(ret) {
if (ret.done) return resolve(ret.value);
var value = toPromise.call(ctx, ret.value);
if (value && isPromise(value)) return value.then(onFulfilled, onRejected);
return onRejected(new TypeError(
'You may only yield a function, promise, generator, array, or object, '
+ 'but the following object was passed: "'
+ String(ret.value) + '"'
));
}
});
}
从整体结构看,
co的参数是一个Generator,返回值是一个Promise,几乎所有逻辑代码都在这个Promise里面,这也是我们使用时用then拿结果的原因。Promise里面先把Generator拿出来执行,得到一个迭代器gen手动调用一次
onFulfilled,开启迭代onFulfilled接收一个参数res,第一次调用是没有传这个参数,这个参数主要是用来接收后面的then返回的结果。- 然后调用
gen.next,注意这个的返回值ret的形式是{value, done},然后将这个ret传给局部的 next
然后执行局部
next,他接收的参数是yield返回值{value, done}- 这里先检测迭代是否完成,如果完成了,就直接将整个 promise resolve。
- 这里的
value是yield后面表达式的值,可能是thunk,也可能是promise - 将
value转换成promise - 将转换后的
promise拿出来执行,成功的回调是前面的onFulfilled
我们再来看下
onFulfilled,这是第二次执行onFulfilled了。这次执行的时候传入的参数res是上次异步 promise 的执行结果,对应我们的fetch就是拿回来的数据,这个数据传给第二个gen.next,效果就是我们代码里面的赋值给了第一个yield前面的变量r1。然后继续局部next,这个next其实就是执行第二个异步Promise了。这个 promise 的成功回调又继续调用gen.next,这样就不断的执行下去,直到done变成true为止。最后看一眼
onRejected方法,这个方法其实作为了异步 promise 的错误分支,这个函数里面直接调用了gen.throw,这样我们在Generator里面可以直接用try...catch...拿到错误。需要注意的是gen.throw后面还继续调用了next(ret),这是因为在Generator的catch分支里面还可能继续有yield,比如错误上报的网络请求,这时候的迭代器并不一定结束了。
# async/await
最后提一下 async/await,先来看一下用法:
const fetch = require('node-fetch');
async function sendRequest () {
const r1 = await fetch('https://www.baidu.com');
const r2 = await fetch('https://www.baidu.com');
const r3 = await fetch('https://www.baidu.com');
return {
r1,
r2,
r3,
}
}
// 注意 async 返回的也是一个 promise
sendRequest().then((res) => {
console.log('res', res);
});
咋一看这个跟前面 promise 版的 co 是不是很像,返回值都是一个 promise,只是 Generator 换成了一个 async 函数,函数里面的 yield 换成了 await,而且外层不需要 co 来包裹也可以自动执行了。其实 async 函数就是 Generator 加自动执行器的语法糖,可以理解为从语言层面支持了 Generator 的自动执行。上面这段代码跟 co 版的 promise 其实就是等价的。
# 总结
Generator是一种更现代的异步解决方案,在 JS 语言层面支持了协程Generator的返回值是一个迭代器- 这个迭代器需要手动调
next才能一条一条执行yield next的返回值是{value, done},value是yield后面表达式的值yield语句本身并没有返回值,下次调next的参数会作为上一个yield语句的返回值Generator自己不能自动执行,要自动执行需要引入其他方案,前面讲thunk的时候提供了一种方案,co模块也是一个很受欢迎的自动执行方案- 这两个方案的思路有点类似,都是先写一个局部的方法,这个方法会去调用
gen.next,同时这个方法本身又会传到回调函数或者 promise 的成功分支里面,异步结束后又继续调用这个局部方法,这个局部方法又调用gen.next,这样一直迭代,直到迭代器执行完毕。 async/await其实是Generator和自动执行器的语法糖,写法和实现原理都类似co模块的 promise 模式。