刚开始接触生成器时,我对着function*和yield这两个奇怪的语法发呆了好久。尤其是看到 "生成器不是真正的函数" 这种说法,更是一头雾水。直到亲手写了几个例子,才慢慢摸透了它的脾气。
一、生成器(带星号的 "特殊函数")
先看一个最简单的生成器代码:
js
function* generator() {
console.log("enter");
let a = yield 1;
let b = yield (function () { return 2 })();
return 3;
}
// 调用生成器函数,得到一个迭代器对象
var g = generator();
console.log(typeof g); // 输出object,不是function!
这第一行就颠覆了我的认知 ------ 调用生成器函数居然不执行函数体,还返回了个对象。后来才知道,生成器函数的作用不是直接执行代码,而是生成一个 "控制器"(迭代器),通过这个控制器来控制函数的执行节奏。
当我们调用g.next()时,神奇的事情发生了:
lua
console.log(g.next());
// 先输出"enter",再返回{ value: 1, done: false }
console.log(g.next());
// 返回{ value: 2, done: false }
console.log(g.next());
// 返回{ value: 3, done: true }
console.log(g.next());
// 返回{ value: undefined, done: true }
这里的执行规律可以总结成三句话:
-
生成器函数被调用时,函数体不会执行,只会返回
迭代器
-
每次调用迭代器的next(),函数体才会执行到
下一个yield
处暂停 -
next()的返回值里,value是当前yield后面的结果,done表示是否执行完毕(遇到return后变成true)
最让我困惑的是let a = yield 1这句 ------ 第一次调用next()时,yield 1会返回 1,但a的值并没有被赋值。直到第二次调用next()时,传入的参数才会成为a的值。这个 "延迟赋值" 的特性,正是生成器能处理复杂逻辑的关键。
二、生成器的执行流程
为了理解生成器的执行流程,我画了个步骤图(纯手画的那种):
-
调用generator()生成迭代器g,函数体处于 "未执行" 状态
-
第一次g.next():函数开始执行,打印 "enter",遇到yield 1后暂停,返回{ value: 1, done: false }
-
第二次g.next():从暂停处继续,将
next()的参数
(这里没传,所以a是undefined)赋值给a,然后执行到yield 2处暂停,返回{ value: 2, done: false } -
第三次g.next():继续执行,将
参数
赋值给b,直到遇到return 3,返回{ value: 3, done: true } -
再调用next():函数已执行完毕,返回{ value: undefined, done: true }
如果给next()传参数,效果会更明显:
js
const g = generator();
g.next(); // 到第一个yield暂停,如果传了参数会被忽略,因为没有上一个yield
g.next(10); // 把10赋值给a,然后执行到第二个yield
g.next(20); // 把20赋值给b,执行到return
这里的参数就像 "接力棒",每次调用next()时传给上一个yield的返回值。理解了这一点,才算真正入门生成器。
三、yield*
当一个生成器需要调用另一个生成器时,直接调用是没用的。比如我想让两个生成器按顺序执行:
js
function* gen1() {
yield 1;
yield 4;
}
function* gen2() {
yield 2;
yield 3;
}
如果直接在gen1里写gen2(),调用时只会得到一个迭代器对象,不会执行gen2里的yield。这时候就需要yield*出场了:
js
function* gen1() {
yield 1;
yield* gen2(); // 用yield*调用另一个生成器
yield 4;
}
const g = gen1();
console.log(g.next().value); // 1
console.log(g.next().value); // 2(来自gen2)
console.log(g.next().value); // 3(来自gen2)
console.log(g.next().value); // 4
yield*的作用就像一根 "接力棒",把执行权交给被调用的生成器,直到它执行完毕,再把权交回来。这在处理复杂的迭代逻辑时特别有用,能让代码结构更清晰。
四、生成器的执行机制
为什么生成器能暂停和恢复执行?这就要说到 "协程
" 这个概念了。
协程是比线程更轻量的存在,它运行在线程中,一个线程可以有多个协程,但同一时间只能有一个
协程在工作。就像办公室里的打印机(线程),一次只能处理一个打印任务(协程),但任务可以暂停(比如中途换纸),然后继续执行。
生成器本质上就是协程的一种实现,它的执行过程可以用 "父子协程" 来理解:
js
function* A() {
console.log("我是A");
yield B(); // A暂停,把执行权交给B
console.log("结束了");
}
function B() {
console.log("我是B");
return 100; // B执行完毕,把执行权还给A
}
const gen = A();
gen.next(); // 输出"我是A" → 执行B → 输出"我是B"
gen.next(); // 输出"结束了"
这里的 A 是父协程,B 是子协程。当 A 执行到yield B()时,会暂停自己的执行,把线程的控制权交给 B。B 执行完毕后,又会把控制权还给 A,让 A 继续执行剩下的代码。
这种协作式的执行方式,没有线程切换的开销,效率非常高。这也是生成器能高效处理分步任务的原因。
五、让生成器的异步代码按顺序执行
生成器最实用的场景,就是处理异步操作。但它本身并不能直接处理异步,需要结合一定的技巧。
比如我们有两个异步读取文件的操作,第二个必须等第一个完成后才能执行。用回调函数会写成嵌套的样子,而用生成器可以写成 "同步" 的形式:
js
// 模拟异步读取文件的函数
const readFile = (filename) => {
return new Promise((resolve) => {
setTimeout(() => {
resolve(`内容:${filename}`);
}, 1000);
});
};
// 生成器函数
function* asyncGen() {
const data1 = yield readFile('file1.txt');
console.log(data1);
const data2 = yield readFile('file2.txt');
console.log(data2);
}
但问题来了,生成器不会自动执行,每次都要调用next(),怎么让它一口气跑完呢?
1. 手动调用 next ()
最直接的方法是手动嵌套调用next():
js
const gen = asyncGen();
gen.next().value.then(data1 => {
gen.next(data1).value.then(data2 => {
gen.next(data2);
});
});
但这样写还是有嵌套,而且如果有多个异步操作,代码会很难看。
2. 封装自动执行函数
我们可以写一个工具函数,自动帮我们调用next():
js
function run(gen) {
const next = (data) => {
let res = gen.next(data);
if(res.done) return;
// 处理普通值
if(typeof res.value !== 'function') {
console.log(res.value); // 输出yield的值
next();
}
// 处理函数(原逻辑)
else {
res.value(next);
}
}
next();
}
// 调用
run(asyncGen());
这个函数用递归的方式,自动处理每个异步操作的结果,直到生成器执行完毕。
3. 使用 co 库
其实社区已经有成熟的工具了,比如 co 库,它的原理和我们上面写的run函数类似,但处理了更多边界情况:
js
const co = require('co');
co(asyncGen).then(() => {
console.log('所有异步操作完成');
});
一行代码就能让生成器自动执行完毕,是不是很方便?
六、总结一下
生成器虽然语法有点特别,但核心逻辑并不复杂:
-
生成器函数用
function*
声明,调用后返回迭代器 -
通过
next()
控制执行,yield
负责暂停,return
结束执行 -
yield*
用于调用另一个生成器,实现迭代接力 -
背后的协程机制让它能高效地进行分步执行
-
结合 Promise 和自动执行工具(如 co 库),能优雅地处理异步流程
另外记住:参数是上一个yield的返回值 这句话,就能慢慢搞懂Generator。