ES6 中的 generator 函数究竟是什么
我们在学习 js 的时候应该都知道一个概念:一旦函数开始执行,它将运行直至完成,没有其他的代码可以在运行期间干扰它。
但是在 ES6 中引入了一种新型的函数,它不按照"运行至完成"的规则。这种新型的函数称为"generator"。
js
var a = 1;
function func() {
a++;
func2();
console.log(a)
}
function func2() {
a++;
}
func();
// 3
在上面的 func 函数中,a++ 运行完后会执行 func2() 函数,最终 a 的值为 3。
要是 func2() 不存在,但以某种方式依然可以在 a++ 和 console.log(a) 语句之间运行呢?这可能吗?
在抢占式(preemptive) 多线程语言中,func2() 去"干扰"并正好在两个语句之间那一时刻运行,实质上是可能的。但 JS 不是抢占式的,也不是多线程的。但是,如果 func() 本身可以用某种办法在代码的这一部分指示一个"暂停",那么这种"干扰"(并发)的协作形式就是可能的。
比如:
js
var a = 1;
function *func() {
a++;
yield;
console.log(a);
}
function bar() {
a++
}
我们很可能在大多数其他的JS文档/代码中看到,一个 generator 的声明的格式为
function* func() { .. }
而不是上面的function *func() { .. }
,它们之间唯一的区别是*
的位置不同。这两种形式在功能性/语法上是完全一样的,还有第三种function*foo() { .. }
(没空格)形式。
现在我们来运行一下上面的代码:
js
// 构建一个迭代器it来控制generator
let it = func();
a; // 1
it.next();
a; // 2
bar();
a; // 3
it.next();
// 3
看起来很陌生对吧,我们来讲解一下这个过程:
- it = func(); func 不是一个普通函数,func() 并不会运行它,而是构建了一个用来控制它执行的迭代器(iterator);
- 我们观察 a 的值,还是 1;
- it.next() 启动了 func 函数的执行,并且运行到 func 函数的第一行,也就是 a++;之后在 yield 语句暂停,此时第一个 it.next() 调用结束,此时 func 函数还是运行的,不会被垃圾回收机制回收掉,但是它现在处于暂停节点,等待下一次的 next() 方法重新启动;
- 再次观察 a 的值,变成 2;说明运行了 a++;
- 此时执行 bar() 方法,再次对 a 进行递增;
- 最后执行 it.next(),重新从暂停的地方启动函数,执行 console.log(a),把 a 的值打印出来。
generator 是一种函数,它可以开始和停止一次或多次(在遇到 yield 关键词时会暂停,执行 next() 方法启动),甚至没必要一定要完成。
generator 是一个函数,这也就意味着它是可以接受参数及返回值的:
js
function *func(x, y) {
return x * y;
}
// 传参跟普通函数一样
let it = func(1, 2)
it.next() // {value: 2, done: true}
我们可以很明显发现,虽然 func(1, 2)
这样的传参跟普通函数一样,但是它实际上并不会执行,我们只是创建了迭代器对象,将它赋值给变量 it,当我们调用 it.next() 时,它指示 func(...) 从现在的位置向前推进,直到遇到一个 yield 或者到函数的最后。
next(...) 调用的结果是一个带有 value 属性的对象,它持有从 func(...) 返回的任何值(如果有的话)。换句话说,yield 导致在 generator 运行期间,一个值被从中发送出来,有点儿像一个中间的 return。
generator 除了接收参数和拥有返回值,它们还内置有更强大,更吸引人的输入/输出消息能力,这是通过使用 yield 和 next(...) 实现的:
js
function *func(x) {
let y = x * (yield);
return y;
}
let it = func( 1 );
// 开始执行 func(..)
it.next(); // {value: undefined, done: false}
it.next( 2 ); // {value: 2, done: true}
在 it = func(1)
中将 1 作为参数 x 传入。之后调用 it.next()
开始启动 func()
。
之后在 func
内部,开始执行 let y = x * (yield)
,但是它遇到 yield 后暂停了,在下一个 next 方法中传递了一个值 2,此时 2 作为 yield 的结果。因此,赋值语句实际上是 let y = 1 * 2
,最后把 y return 出去,作为 next() 方法的结果。
从上面的几个实例中我们可以看到,next 总是比 yield 多一个,因为第一次 next 是用于启动 generator 的,之后的其他 next 才会跟 yield 相对应。
除了可以通过 next 给 yield 传值外,还可以通过 yield 给 next 的结果赋值:
js
function *func(x) {
let y = x * (yield "leo");
return y;
}
let it = func(1);
it.next(); // {value: 'leo', done: false}
it.next(2); // {value: 2, done: true}
因为只有一个暂停的 yield 才能接收这样一个被 next(...) 传递的值,但是当我们调用第一个 next() 时,在generator 的最开始并没有任何暂停的 yield 可以接收这样的值。语言规范和所有兼容此语言规范的浏览器只会忽略任何传入第一个 next() 的值。传递这样的值是一个坏主意,因为我们只不过创建了一些令人困惑的代码。所以,我们要记得总是用一个无参数的 next() 来启动 generator。
迭代器
一个 generator 本身在技术上讲并不是一个 iterable,但是当我们执行 generator 时,我们就能得到一个迭代器:
我们写一个无限数字序列生成器:
js
function *genNumber() {
let nextNumber;
while(true) {
if(nextNumber === undefined) {
nextNumber = 1;
} else {
nextNumber += 1;
}
yield nextNumber;
}
}
通常来说在一个真实的 JS 程序中含有一个 while...true 循环通常是一件非常不好的事情,如果它没有一个 break 或 return 语句,那么它就很可能永远运行,并同步阻塞/锁定浏览器 UI。但是在 generator 函数中,如果循环中含有 yield,那它就是完全没有问题的,因为 generator 将在每次迭代后暂停,可以重新回到主程序或事件循环队列中。
js
let gen = genNumber();
for (var v of gen) {
console.log( v );
// 停止循环
if (v > 500) {
break;
}
}
genNumber 是一个 generator 函数,调用这个 generator 可以生成一个迭代器给 for...of 使用,这个迭代器中有一个 Symbol.iterator 函数,会把 next 方法中的 value 读取出来。
而在循环中的 break 被调用后,func 实例基本上被留在了一个永远挂起的状态。
js
gen // genNumber {<closed>}
gen.next(); // {value: undefined, done: true}
for...of 循环的"异常完成"(或者叫做 "提前终结"),一般是由break,return,或未捕捉的异常导致的------会向 generator 的迭代器发送一个信号,以使它终结。技术上讲,for...of 循环也会在循环正常完成时向迭代器发送这个信号。
虽然一个 for...of 循环将会自动发送这种信号,但是我们也可以通过调用 generator 的 return() 方法来手动发送:
js
let gen = genNumber();
for (var v of gen) {
console.log( v );
// 停止循环
if (v > 500) {
console.log(gen.return('停止循环')) // {value: '停止循环', done: true}
}
}
在 generator 中还有个特性,如果在内部指定一个 try...finally ,它将总是被执行,即便是 generator 从外部被完成。
js
function* genNumber() {
try {
let nextNumber;
while (true) {
if (nextNumber === undefined) {
nextNumber = 1;
} else {
nextNumber += 1;
}
yield nextNumber;
}
} finally {
console.log('finally');
}
}
let gen = genNumber();
for (var v of gen) {
console.log( v );
// 停止循环
if (v > 500) {
console.log(gen.return('停止循环'))
}
}
// ...
// finally
// {value: '停止循环', done: true}
从上面的打印可以看到,在执行 generator 的 return 方法后,会先触发内部的 finally 块(如果它存在的话),之后才打印出 return 的返回结果(返回的 value 设置为传入 return(...) 的任何值)。我们现在也不必再包含一个 break,因为 generator 的迭代器会被设置为 done:true,所以 for...of 循环会在下一次迭代时终结。
generator 处理异步
我们先来看一段代码:
js
function foo(x,y,cb) {
ajax(
"http://xxxx?x=" + x + "&y=" + y,
cb
);
}
foo( 1, 2, function(err,text) {
if (err) {
console.error( err );
}
else {
console.log( text );
}
});
使用 generator 实现相同的逻辑:
js
function foo(x,y,cb) {
ajax(
"http://xxxx?x=" + x + "&y=" + y,
function (err, data) {
if(err) {
it.throw(err);
} else [
it.next(data)
]
}
);
}
function *main() {
try {
let data = yield foo(1 ,2);
console.log(data);
} catch(err) {
console.log(err);
}
}
var it = main();
it.next();
在generator内部的代码看起来完全是同步的(除了yield关键字本身),但实际上在 foo(...) 内部,操作可以完全是异步的。
除了写法上看起来跟同步的一样外,它还可以使用 try...catch 捕获。
看上面的代码,在 yield 之后,我们在 foo 函数内部使用 it.throw 抛出一个错误,使得这个错误被传递给 yield,最终被 try...catch 捕获。同样,也可以通过 yield 把错误传递给 next:
js
function *main() {
var x = yield "leo";
yield x.toLowerCase(); // 引发一个异常
}
var it = main();
it.next().value; // leo
try {
// 给 yield 赋值为 2,导致 x 没有 toLowerCase 方法
it.next( 42 );
}
catch (err) {
console.error( err ); // TypeError
}
与 Promise 结合
在 async/await 出现之前,最有意思的就将 generator 与 Promise 进行结合使用:
js
function sendRequest(x, y) {
return request('http:/xxx/?x=' + x + '&y=' + y);
}
function *main() {
try {
let data = yield sendRequest(1, 2);
console.log(data);
} catch(err) {
console.log(err)
}
}
let it = main();
let p = it.next().value;
p.then(
function fulfilled(data) {
it.next(data);
},
function rejected(err) {
it.throw(err);
}
)
在 async/await 出现之后,我们就有更简便的写法了:
js
async function main() {
try {
let data = await sendRequest(1, 2);
console.log(data);
} catch(err) {
console.log(err);
}
}