前言
Generator
,生成器函数,ES6中最难理解的语法,没有之一。从我的亲身经历来说,至少80%的前端不知道Generator
的语法特征(小公司这个比例可能会更高),一方面是因为Generator
这个语法在绝大部分的场景下,可以用别的语法平替(比如Async
函数),导致很多人觉得理解它的代价与收获不成正比,所以还不如不学。另一方,前端使用状态模式的频率不高,对于迭代器模式来说的话,完全可以有更好的平替,比如数组或者对象。再者,因为Generator
的语法牵涉到的知识点太多,容易让人抓不着重点,就好比一坨线团,没有理清它,强行去理的话,只会越理越乱。
我是通过学习阮一峰老师的《ECMAScript 6 入门》这本网络书籍,加上自己的一些编程和融会贯通理解到的,我个人的观点是阮一峰老师的书籍有些措辞有些过于专业了,导致我们以为JS引擎可能存在什么黑魔法,而越理越不清,所以本文旨在以接地气的方式向大家阐述Generator
的一些技术特点。
但是,ES6推出这个东西肯定是有它的道理的,所谓存在即合理,为了提升我们的编程能力,攻坚克难,一起来搞懂Generator
。
单线程的JS
为什么要先聊这个知识点呢,我旨在向大家说明,其实Generator
并没有逃出我们一直接受的单线程理论。
众所周知,假设我们在浏览器执行一段死循环代码,那浏览器当前的页面肯定卡死的。
比如下面的这段代码:
js
function fn() {
while(true) {
// 大家不要执行这个代码,仅仅是个演示。
}
}
fn();
因为你每时每刻都在让浏览器的渲染进程处理这个任务,渲染进程根本没有办法去响应你的其它操作,所以表现出来页面就是卡死了。
因为代码执行速度非常快,只要你不是死循环,在很短的时间内,浏览器就把你交给它的任务干了,所以它就有时间空出来干别的了。
所以,即便是单线程,但是可以让代码运行起来看起来像是多线程一样,我们拿下面这个图举例:
在上面的这个图中,每截红线表示浏览器那个时刻做的任务,每一截红线如果加起来的话,刚好等于时间轴的总长度,大家可以看出来,每个时刻,总是只能有一个任务在干活儿。
所以,正是因为浏览器的任务调度,让我们的代码看起来像是多线程运行一样。
迭代器与for-of循环
迭代器接口(Iterable
)、指针对象(Iterator
)和next
方法返回值的规格可以描述如下。
ts
// 迭代器接口,任何实现了这个接口的对象(即有属性[Symbol.iterator],返回值是一个Iterator规格的对象),都可以被迭代(即被for-of消费)的能力
interface Iterable {
[Symbol.iterator](): Iterator;
}
interface Iterator<T, TReturn = any, TNext = undefined> {
// 传入给下一次迭代的初始值
next(...args: [] | [TNext]): IteratorResult<T, TReturn>;
// for-of循环,中断的时候,可以执行的清理操作
return?(value?: TReturn): IteratorResult<T, TReturn>;
// 跟Generator相关
throw?(e?: any): IteratorResult<T, TReturn>;
}
interface IterationResult {
// 每一步迭代的值
value: any;
// 表示迭代是否完成
done: boolean;
}
如果不考虑是ES6环境,也不考虑babel转义,上面的结构仍然是可以被遍历的,只不过这个遍历写起来会比较繁琐。
以下是我们直接操作迭代器进行遍历的代码:
ts
function visitIterable<T>(it: Iterator<T>, fn: (val: T) => void) {
let result = it.next();
while (!result.done) {
fn(result.value);
result = it.next();
}
}
使用这个迭代辅助函数:
ts
const it = [1,2,3,4,5][Symbol.iterator]()
visitIterable(it, (val) => {
// 输出值,1,2,3,4,5
console.log(val);
})
(先给大家解释一下这段代码const it = [1,2,3,4,5][Symbol.iterator]()
,首先创建一个数组,因为数组是实现了迭代器接口的,所以,通过这段代码,我们就创建了一个可迭代的对象,这个可迭代对象迭代的是刚才创建数组的元素。)
如果此刻,我们加需求了。假设,某个值能等于3,需要干点儿别的事儿,那么我的这个visitIterable
函数就要加参数了。
我们也来实现一遍:
ts
function visitIterable<T>(
it: Iterator<T>,
fn: (val: T) => void,
// 增加一个参数,可以让迭代在某个条件下中断
breakPredicate: (val: T) => boolean,
) {
let result = it.next();
while (!result.done) {
fn(result.value);
const flag = breakPredicate(result.value);
if (flag) {
break;
}
result = it.next();
}
}
使用修改过的迭代辅助函数迭代:
ts
const it = [1,2,3,4,5][Symbol.iterator]()
visitIterable(it, (val) => {
// 输出值,1,2,3,4,5
console.log(val);
}, v => v === 3)
上述代码还没有考虑到迭代过程中因为异常导致遍历提前终止的场景,啊,如果再接着考虑更多的场景,麻了麻了。
因此,ES6引入for-of
循环就是为了解决我们实际开发中遍历迭代器繁琐的问题。看到这儿,很多同学就明白了了for-in
和for-of
的区别了吧。
这也是为什么for-of
循环的每项就是目标对象的其中一个值的原因,因为它的本质就是一个遍历语法糖。
对于上述代码,假设用for-of
写的话,那就太简单了。
ts
const it = [1,2,3,4,5][Symbol.iterator]()
for(const o of it) {
if(o === 3) {
break;
}
console.log(o);
}
需要注意一点的是,for-of
循环只会遍历迭代器done
的值为false
的结果
对于迭代器的另外两个可选方法,一个是return
,一个是throw
。
throw
方法迭代器从本身的使用来说的话,暂时还用不到,它需要和Generator
结合使用。
至于return
方法,就是在for-of
中断的时候执行的回调函数,阮一峰老师的博客里面给了另外一个例子,抛出异常打断for-of
循环,会先执行return
函数,再抛出异常。还有就是我们使用break
语句打断。
js
for (let line of readLinesSync(fileName)) {
console.log(line);
throw new Error();
}
在实际的使用过程中,我们几乎不会自己去处理迭代器,所以对于这两个方法,了解即可,遇到实际的场景再结合Chatgpt和搜索引擎便可以解决。
Generator函数的语法
在明白迭代器和for-of
循环的基本知识点以后,我们便可以开始聊Generator
的语法了。
比如我们要定义一个Generator
函数,以下写法都是合法的,并且展示了绝大部分可能出现的场景:
ts
// * 挨着谁没有关系
function* gen1() {}
function *gen2() {}
const gen3 = function *() {}
class A {
* demo() {}
}
const obj = {
*demo() {}
}
const obj2 = {
*["Hello"]() {}
}
const obj3 = {
test: function *() {}
}
如果我们要判断一个对象是否是Generator
函数,可以采取以下方式:
js
const isGenerator = (func) => {
return func && func[Symbol.toStringTag] === "GeneratorFunction";
};
yield表达式
各位读者准备好了,要开始上难度了。
我个人的理解,我们应该把Generator
和迭代器既要分开看,又要结合在一起看。
分开看,就是我们在编写代码的时候,只需要关注我们要做的事儿,Generator
函数,就好像是一本日历,每个yield
表达式,就定义的是一个阶段(return
也可以把它看做是一个阶段,但是return
这个阶段不一定存在)。
比如我们用下面的Generator
函数来描述一年四季(4个阶段)的进程:
js
function* atAllSeasons() {
console.log('spring');
yield;
console.log('summer');
yield;
console.log('autumn');
yield;
console.log('winter');
return;
}
好了,我们现在要忘掉这个东西是个函数,我们需要把它暂时把它想象成链表。
然后,现在,我告诉你,这个东西其实不是链表,它是一个工厂函数,这个函数每次执行完成,都返回上面这样的一个链表。
接着,我又要告诉你,这个链表 是一个可迭代对象,它可以被for-of
遍历。
于是,我们根据已得到的知识点,遍历一下这个链表。
js
const linkedList = atAllSeasons();
for(const a of linkedList) {
console.log(a);
}
然后,你可能对控制台的输出比较好奇,为什么a全部都是undefined
呢?
好了,我们接下来要给大家传递第一个知识点了,yield
表达式后面的内容,可以作为迭代器迭代过程中每次都返回值,即IterationResult
接口中的value字段。
我们把之前写的四季的Generator
函数改写一下,可以使得在for-of
循环的时候,输出'春','夏','秋','冬'的效果。
js
function* atAllSeasons() {
yield '春';
yield '夏';
yield '秋';
yield '冬';
}
为什么我们不用return
改用4个yield
了呢,因为是这样的,对于Generator
得到的迭代器,return
语句算作的效果是IterationResult
为true
的那一次迭代,for-of
不消费那次结果,所以我们就用4个yield
表达式。
所以,现在大家应该知道了吧,yield
的效果有点儿像普通函数的return
语句,它的效果就是让Generator
得到的迭代器某个节点的值是它返回的值。
说到这儿,这就是为什么我说在理解Generator
的时候不要把它和迭代器混在一起看原因,就像一团线团,我们只要把它理顺了之后,这个问题就变得简单了。
事情到这儿,还没有完,最开始我们给出迭代器的TS类型定义的时候,可以看到next
方法是有参数的。现在,我要告诉大家,next
函数在执行的时候,我们传递的参数,可以作为yield
表达式的返回值。
大家有点儿懵,可以接着看下图:
然后,我们再来改写一下四季的函数:
js
function* atAllSeasons() {
const springMsg = yield '春';
console.log('进入到了夏天我接受到了来自春天的消息',springMsg);
const summerMsg = yield '夏';
console.log('进入到了秋天我接受到了来自夏天的消息',summerMsg);
const autumnMsg = yield '秋';
console.log('进入到了冬天我接受到了来自秋天的消息',autumnMsg);
yield '冬';
}
const it = atAllSeasons();
for(const seaon of it) {
console.log(season)
}
这段代码执行起来,结果是这样的: 全打印的是undefined,为什么呢?因为for-of
在执行代码的时候,它不知道需要向后面传递信息,所以打印的值就都是undefined,因此,我们想要实现向后面的迭代节点传递信息的效果,就只能回到最开始我们提到的用手动遍历的方式。
js
function visitIterable(it) {
let result = it.next();
while (!result.done) {
// 假设我们就把本次迭代节点的值传递给下一个节点
const val = result.value;
result = it.next(val);
}
}
const it = atAllSeasons();
visitIterable(it);
这段代码的执行效果如下:
这儿有个关键的知识点:
由于
next
方法的参数表示上一个yield
表达式的返回值,所以在第一次使用next
方法时,传递参数是无效的。V8 引擎直接忽略第一次使用next
方法时的参数,只有从第二次使用next
方法开始,参数才是有效的。从语义上讲,第一个next
方法用来启动遍历器对象,所以不用带有参数。
其实我们稍加思索也能明白这个问题,第一个yield
表达式之前肯定不会有yield
表达式的 ,那第一个next
方法调用时传递的参数给谁用呢,既然没有人需要它,那还传它干嘛。
yeild* 表达式
回到刚才我们聊到的四季的那个Generator
函数。根据上文的阐述,我让大家将其理解为一个返回链表的工厂函数,既然是链表,我们现在想在这个链表中插入一串节点。这种场景下,使用yield*
,就可以让引擎帮我们遍历内部的迭代器。
还是老规矩,先来个图: 得到一个新的迭代器:
js
const arr = [1,2,3,4,5];
function* atAllSeasons() {
const springMsg = yield '春';
yield* arr[Symbol.iterator]();
console.log('进入到了夏天我接受到了来自春天的消息',springMsg);
const summerMsg = yield '夏';
console.log('进入到了秋天我接受到了来自夏天的消息',summerMsg);
const autumnMsg = yield '秋';
console.log('进入到了冬天我接受到了来自秋天的消息',autumnMsg);
yield '冬';
}
const it = atAllSeasons();
for(const val of it) {
console.log(val)
}
看看代码的执行效果,是不是真的就像我们描述的那样,哈哈哈:
再看看,yield*
表达式在处理其它Generator
的return
表达式会不会有什么坑点之类的。
javascript
function* demo() {
yield 1;
yield 2;
yield 3;
yield 4;
yield 5;
return 6;
}
function* atAllSeasons() {
const springMsg = yield '春';
yield* demo();
console.log('进入到了夏天我接受到了来自春天的消息',springMsg);
const summerMsg = yield '夏';
console.log('进入到了秋天我接受到了来自夏天的消息',summerMsg);
const autumnMsg = yield '秋';
console.log('进入到了冬天我接受到了来自秋天的消息',autumnMsg);
yield '冬';
}
const it = atAllSeasons();
for(const val of it) {
console.log(val)
}
执行结果如下:
可见,yield*
并不会将内部Generator
函数的IterationResult
的done
为true
的那个节点加入到最终的迭代器的迭代节点中。
最后,再看看,yield*
是不是跟yield
性质一样,next
函数的入参能够作为它的返回值。
js
function* demo() {
yield 1;
yield 2;
yield 3;
yield 4;
yield 5;
return 6;
}
function* atAllSeasons() {
const springMsg = yield '春';
const inputParams = yield* demo();
console.log('上一个迭代器测试',inputParams);
console.log('进入到了夏天我接受到了来自春天的消息',springMsg);
const summerMsg = yield '夏';
console.log('进入到了秋天我接受到了来自夏天的消息',summerMsg);
const autumnMsg = yield '秋';
console.log('进入到了冬天我接受到了来自秋天的消息',autumnMsg);
yield '冬';
}
function visitIterable(it) {
let result = it.next();
while (!result.done) {
// 假设我们就把本次迭代节点的值传递给下一个节点
const val = result.value;
console.log(val);
result = it.next(val);
}
}
const it = atAllSeasons();
visitIterable(it);
竟然得到的是done
为true
的那个节点的值。 改写一下demo生成器函数,再看看是否输出的是是done
为true
的那个节点的值。
js
function* demo() {
yield 1;
yield 2;
yield 3;
yield 4;
yield 5;
}
确实是! 所以到此,我们又可以得出一个结论,yield*
的返回值是第一次IterationResult
的done
为true
的节点值。
Generator.prototype.return
这个方法是一个我们手动终结的方法,就比如有这样的场景,我因为某些上下文的关系,我想直接终止迭代器的遍历。
在这种场景下,我们就可以直接调用Generator
的return
方法,返回值是done
为true
的节点并且value
就是return
方法的入参。一般情况下,当我们再调用Generator
函数执行得到的那个迭代器对象,遍历就已经完成了,得到的值就是{ value: undefined, done: true }
比如:
js
function* gen() {
yield 1;
yield 2;
yield 3;
}
var g = gen();
g.next() // { value: 1, done: false }
g.return('foo') // { value: "foo", done: true }
g.next() // { value: undefined, done: true }
不过,有一个场景例外,比如,我们在Generator
内部署了try-cacth-finally
代码,当我们再调用Generator
函数执行得到的那个迭代器对象时,仍然可以继续向后面迭代。
比如:
js
function* numbers () {
yield 1;
try {
yield 2;
yield 3;
} finally {
yield 4;
yield 5;
}
yield 6;
}
var g = numbers();
g.next() // { value: 1, done: false }
g.next() // { value: 2, done: false }
g.return(7) // { value: 4, done: false }
g.next() // { value: 5, done: false }
g.next() // { value: 7, done: true }
理解起来,就是finally
强制改变了Generator
内部的状态流程。
Generator.prototype.throw
之前聊迭代器的时候就聊到了它,这个就只会在用于控制Generator
函数内部的状态的时候有用。
它的用途也是强制中断迭代器对象,它的入参是一个错误信息,最好是Error
对象的实例。
js
function *gen() {
yield 1;
yield 2;
yield 3;
yield 4;
yield 5;
}
const it = gen();
const res = it.next();
const err = it.throw(new Error('Bad Request'))
console.log(res, err)
当调用了throw
方法之后,当我们再调用Generator
函数执行得到的那个迭代器对象,遍历就已经完成了,得到的值就是{ value: undefined, done: true }
跟之前的Genarator.prototype.return
方法有些类似,如果我们在调用throw
方法的过程中,当时这个遍历器对象遍历的状态还处在内部包try-catch
包裹的状态时,可以继续向后面迭代。
比如:
js
function *gen() {
try {
yield 1;
yield 2;
} catch (exp){
console.log(exp);
yield 3;
yield 4;
}
}
上面的代码在不同的时机抛出错误,效果完全不一样。
js
const it = gen();
it.next();
it.throw(new Error('test error'));
it.next();
it.next();
it.next();
如果要真的探究上面的这段代码的迭代流程是怎么流转的,得看一下这个代码经过babel
编译之后的代码是什么样的:
js
var _marked = /*#__PURE__*/_regeneratorRuntime().mark(gen);
function gen() {
return _regeneratorRuntime().wrap(function gen$(_context) {
while (1) switch (_context.prev = _context.next) {
case 0:
_context.prev = 0;
_context.next = 3;
return 1;
case 3:
_context.next = 5;
return 2;
case 5:
_context.next = 14;
break;
case 7:
_context.prev = 7;
_context.t0 = _context["catch"](0);
console.log(_context.t0);
_context.next = 12;
return 3;
case 12:
_context.next = 14;
return 4;
case 14:
_context.next = 16;
return 5;
case 16:
case "end":
return _context.stop();
}
}, _marked, null, [[0, 7]]);
}
大家可以直接打开babel
的在线编译地址即可:Babel · The compiler for next generation JavaScript (babeljs.cn) 像这种调用,就不太符合我们之前所描述的: 这个具体就只能看regenerator
这个库内部的实现了,本文就不展开讲了,有兴趣的同学可以自己debug一下就明白里面的流程控制了。
到这个位置,我们其实就已经把Generator
的语法特点弄清楚的大差不差了。
最后,再给大家留一个思考题:
js
function *gen() {
let count = 1;
while(true) {
yield ++count;
}
}
上述代码会造成浏览器的死循环吗?请说明原因。
在上文中,我们并没有用一些比较晦涩难懂的词汇来描述Generator
的内部流程,而是采用接地气的方式阐述了Generator
的执行流程。
Generator的应用
相信大家在实际开发中应该都没有使用过Generator
吧,目前根据我的技术眼界,我所知就redux-saga
使用了Generator
,就是因为这个语法让很多同学都难受。
但是,你学习到此明白了Generator
的用法之后,假如再去看saga肯定会有新的理解了。
作为迭代器的语法糖
相信大家一定都看到过这个代码吧。
请添加某些代码使得以下代码正常运行:
js
const obj = { a: 1, b: 2 }
// 在此处添加你的代码
const [a, b] = obj;
如果Get不到这题的考点的同学那就没办法了,你还需要继续修炼,哈哈哈。如果知道可以给obj对象补迭代器的同学很容易就写出以下的代码:
js
obj[Symbol.iterator] = function () {
const keys = Object.keys(this);
let idx = 0;
const _this = this;
return {
next() {
const i = idx++;
return {
value: _this[keys[i]],
done: i >= keys.length,
};
},
};
}
但是,我要是说,这个代码可以写的更简洁,你一定要先"哇"为敬,哈哈哈。
如果使用Generator
实现:
js
// 定义一个Generator,读取目标对象的
function *objIterator(obj) {
for (const key in obj) {
if (obj.hasOwnProperty(key)) {
yield obj[key];
}
}
}
const obj = { a: 1, b: 2 };
obj[Symbol.iterator] = function() {
return objIterator(this)
}
// 在此处添加你的代码
const [a, b] = obj;
照顾到第一次学Generator
的同学,我objIterator这个函数所完成的功能阐述一下,因为Generator函数返回的是一个 返回内容是类似链表的工厂函数,这个函数的操作就是每次迭代都取一下目标对象的一个key,然后作为迭代节点的value返回,当解构赋值遍历迭代器的时候,就好比把这个对象上的每个属性值都消费了一下,从而完成了赋值。
状态模式的语法糖
在我之前的设计模式的章节中,就已经给大家聊过了设计模式在前端开发中的实际应用(一)------状态模式。
如果我们要自己去写状态模式的代码的话,会显得比较繁琐,相当于要自己去切换上下文,而有了Generator
我们就可以直接利用Generator
的能力为我们进行状态切换了。
以下就是一个红绿灯的例子:
js
function* func() {
while (true) {
yield console.log("红灯亮起");
yield console.log("绿灯亮起");
yield console.log("黄灯闪烁,红灯即将亮起");
}
}
const light = func();
function start(immediate) {
setTimeout(() => {
light.next();
start();
}, 1000);
immediate && light.next();
}
具体业务下使用状态模式肯定不会有这么简单,大家可以结合Generator.prototype.throw
方法来进行状态控制就可以满足相应的业务需求。
异步操作同步表达------作为async函数的polyfill
到这个位置,就已经可以聊到async
函数的polyfill实现了。
首先要说明一点,很多同学有一个错误的认知,就是觉得async
函数底层就是babel
编译的结果那样的实现,这种认知我觉得是站不住脚的,因为V8如果原生支持的话,有些操作比如可以直接用C++直接操作底层那效率肯定是要比regenenrator
库的实现要高的,但是为了老的浏览器能够用上这种能力,于是regenerator
提供了一个状态管理的能力,能够模拟出async
函数的那种效果,所以不要把两者混为一谈。
我们先理一下async
函数的特点。
async
函数内部可以有多个await
,每次遇到await
表达式,先执行后面的逻辑,到这个位置,还是在同步任务上执行代码。此刻JS就继续去执行别的同步代码了。过了一会儿,await
右边的Promise
的状态变成了fulfilled
之后,就可以把Promise
的返回值交给等号左边的变量了。如果后续再有await
表达式,重复刚才说的步骤。假设这个过程中,都没有异常发生,函数正常结束,就把最后return
的值作为最终这个Promise
的值返回;假设在其中任何一个过程发生未捕获的错误,那么就直接把错误的原因作为Promise
被rejected
的reason。
还有一个问题,就是上述的这个过程中,我假设了一个条件:每次迭代对象的value
值都是一个Promise
。
理清楚这个设计思路之后,剩下的编码就比较简单了。
这就是阮一峰老师给出的spawn
函数的实现,我改了一下变量名,可以帮助我们更好的理解:
js
// 外界传入一个Generator,这个Generator的迭代对象的每个节点的value都是一个Promise
function spawn(genF) {
// 对外返回一个Promise,Generator迭代过程中出错则返回错误的reason,否则一直向后迭代,
// 并且把Generator函数执行得到的迭代器对象最后的Promise的结果作为这个Promise的结果
return new Promise(function (resolve, reject) {
// 获取到迭代器对象
const iterator = genF();
function step(nextF) {
let itNode;
try {
itNode = nextF();
} catch (e) {
// 执行过程中有任何错误,直接结束,把错误信息作为Promise的错误reason
return reject(e);
}
// 迭代完成了,把最后一个值作为最终Promise的返回值
if (itNode.done) {
return resolve(itNode.value);
}
// 递归调用,实现遍历迭代器的效果
Promise.resolve(itNode.value).then(
(v) => {
step(function () {
// 把上一个迭代节点的返回值,即yield的返回值作为next函数的入参。
return iterator.next(v);
});
},
(e) => {
step(function () {
// 出错,直接调用Generator的throw方法终止迭代。
return iterator.throw(e);
});
}
);
}
// 开始迭代
step(() => {
// 因为开始迭代的过程中,第一个yield表达式不依赖next方法的入参,所以传一个undefined就好。
return iterator.next(undefined);
});
});
}
这儿我们可以这样来理解,快过年了,我们要放鞭炮,可是运气不好,去一个无良商家那儿买到了一串鞭炮,这串鞭炮的质量不好,每次都必须等上面一个鞭炮炸了以后,下面一个鞭炮才能炸。
以上就是async
函数的polyfill实现了。
总结
Generator
作为ES6中最难懂的语法,一旦完全掌握了,对我们编程能力有立竿见影的提升。不要把它想的很神秘,它一直都没有逃离我们学习的基础知识点。
从Generator
的语法中不难看出,用一些数据结构和算法的思维来理解迭代器可以起到相当有效的作用。并且,在这个过程中,我们也明确的看到了很多知识点不是单独存在的,他们是相辅相成的,只有掌握好了每个基础的知识点,才能更好的理解其它的知识点,当我们把所有的知识点都掌握之后,知识形成一个体系,看什么东西都觉得很快了。
有些同学一直在抱怨前端的知识更新迭代快,我个人觉得还好,怎么说呢,如果说我们要去记忆框架的API的话,那确实忙不过来,既然我们没办法让别人不造轮子,那我们就直接抓根本,从源头上拿捏,学好JS和计算机底层的一些知识点,面对新的东西可能真的就是熟悉一下API就可以信手拈来了,哈哈哈。
如果大家喜欢我的文章,可以多多点赞收藏加关注,你们的认可是我最好的更新动力,😁。