开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第2天,点击查看活动详情
前言
本文主要内容:结合《JavaScript高级程序设计》一书,总结归纳了迭代器和生成器的来龙去脉和使用要点,结合自己的思考梳理了这一块的整体逻辑
上午读红宝书读到第七章,发现自己的知识已经有一点跟不上了,只是和前六章一样的摘抄需要注意的地方无法满足学习需求。所以这将会是第一篇关于JS基础的总结文章,用来帮自己梳理一些JS相关知识
什么是迭代
看一个例子
for (let i = 1; i <= 10; ++i) {
console.log(i);
}
像这样会按照一定的顺序,反复执行某一段程序,并且通常存在明确的终止条件的行为就是迭代
这种循环是迭代机制的基础,它可以指定迭代的次数,以及每次迭代要执行什么操作。
为什么要有迭代器
在介绍什么是迭代器之前,先来讲一下为什么要有迭代器,为什么不直接用循环结构完成迭代
看一下用循环来操作数组
let collection = ["foo", "bar", "baz"];
for (let index = 0; index < collection.length; ++index) {
console.log(collection[index]);
}
可以看到针对数组这种数据结构,我们需要通过 .length 获取循环的次数,通过 [index] 索引元素
然而如果是其他数据结构,可能并不会这么容易构建一段循环代码来处理其中的元素
比如 Set,我们就没有办法通过递增索引来访问数据。所以 ES6 新增了迭代器来解决这个问题
迭代器模式
迭代器模式是解决上述问题的 解决方案名,主要内容如下
将有些结构称为“可迭代对象”,这些对象实现了正式的 Iterable 接口,能够通过迭代器 Iterator 消费
也就是说可迭代对象拥有着后面两个特性来便于开发者使用,所以接下来要解释的就是这两个特性
可迭代协议
也就是实现了正式的 Iterable 接口,而这要求同时具备两个能力:
- 能够实现支持迭代的自我识别
- 能够创建实现 Iterator 接口的对象
这两句话着实有一些拗口,看一下具体的例子
以字符串为例,首先定义了一个字符串
let str = "heheer";
然后我们验证一下它需要具备的第一个能力,即能够自我识别自身是可迭代的
console.log(str[Symbol.iterator]);
输出结果如下
也就是 str 具有一个属性,这个属性的键为 Symbol.iterator,通过这个键访问这个属性会输出一个工厂函数如上,而这也就证明了 string 能够实现支持迭代的自我识别。
接下来验证一下它需要具备的第二个能力,即能够创建一个迭代器对象
console.log(str[Symbol.iterator]());
输出结果如下
也就是运行 str 的这个工厂函数,会创建一个相应的迭代器对象 StringIterator,所以这就证明了 string 是实现了需要的两个功能的,也即实现了 Iterable 接口,支持可迭代协议
同样的实现了Iterable接口的类型还有:
数组、 映射、 集合、 arguments 对象、 NodeList 等DOM集合类型
迭代器协议
也就是能够通过迭代器 Iterator 消费可迭代对象
具体的操作主要就是 next() 方法,直接看一下示例
let str = "he";
let iter = str[Symbol.iterator]();
console.log(iter.next());
console.log(iter.next());
console.log(iter.next());
输出结果如下
可以看到 iter 就是创建的迭代器,调用了三次 next() 方法,每次都会输出一个 IteratorResult 对象。
IteratorResult 对象包含两个属性:value 和 done,它们的值从示例也可以看出来,value的值就是通过 next() 遍历的值,而 done 则是如果没有遍历完返回 false,遍历完则返回 true
自定义迭代器
除了内置类型具有迭代器模式外,任何能够实现 Iterator 接口的对象都可以作为迭代器使用。比如
class Counter {
constructor(limit) {
this.limit = limit;
}
[Symbol.iterator]() {
let count = 1,
limit = this.limit;
return {
next() {
if (count <= limit) {
return { done: false, value: count++ };
} else {
return { done: true, value: undefined };
}
},
};
}
}
let counter = new Counter(2);
for (let i of counter) { console.log(i); }
let iter = counter[Symbol.iterator]();
console.log(iter.next());
console.log(iter.next());
console.log(iter.next());
虽然内置的 num 属性没有迭代器,但是我们可以自定义一个 Counter 类型,能够接收数字并且遍历其中的数字,输出效果如下
这样就是创建了一个实现了 Iterable 接口的迭代器,可以用在任何期待可迭代对象的地方
提前终止迭代器
迭代器还有一个 return() 方法,与 next() 同级,用于指定 在迭代器提前关闭时,要执行的逻辑
比如还是上面的代码,给它添加一个 return() 方法
class Counter {
constructor(limit) {
this.limit = limit;
}
[Symbol.iterator]() {
let count = 1,
limit = this.limit;
return {
next() {
if (count <= limit) {
return { done: false, value: count++ };
} else {
return { done: true, value: undefined };
}
},
// 添加的代码
return() {
console.log('Exiting early');
return { done: true }
}
// 添加的代码
};
}
}
let counter = new Counter(5);
for (let i of counter) {
if (i > 2) {
break;
}
console.log(i);
}
输出如下
也就是在迭代器终止时,还运行了 return() 方法中的内容
生成器
生成器基础
首先来认识一下生成器
生成器的形式是一个函数,函数名称前加一个星号(*)表示它是一个生成器
调用生成器函数会产生一个生成器对象,这个对象与迭代器相似,可以看下面的例子
function *generatorFn() {
return 'foo';
}
const g = generatorFn();
console.log(g);
console.log(g.next);
console.log(g.next());
输出如下
可以看到代码中通过调用生成器函数定义了一个生成器对象,这个对象有 next() 方法,并且执行 next() 方法会输出 return 的内容
然而这里就会有一个问题,既然是迭代器,那么 next() 往往调用不止一次,是如何返回多个值的呢?
就会用到下面的 yield 关键字
通过 yield 中断执行
直接来看几个例子
生成器对象作为可迭代对象
首先是 yield 最简单的运用
function *generatorFn() {
yield 'foo';
yield 'bar';
yield 'baz';
}
for (let i of generatorFn()) {
console.log(i);
}
输出如下
就是与可迭代对象类似,yield 每次输出一个,并且下一次输出时能在上一次输出的基础上向后执行输出
使用 yield 实现输入和输出
yield 还能够接收传入 next() 方法的参数,看个例子
function *generatorFn() {
console.log(yield);
console.log(yield);
console.log(yield);
}
let g = generatorFn();
g.next('bar');
g.next('baz');
g.next('qux');
输出结果如下
可以看到调用了三次 next() 方法,但是只输出了后两个传入的参数。
这是因为第一次调用 next() 传入的值不会被使用,这一次的调用是为了开始执行生成器函数;下一个 next() 传入的值会作为上一次 yield 的输入返回。
也就是说这里实际上只执行了前两个 yield,两个返回值分别是 'baz' 和 'qux', 而 'bar' 则没有 yield 接收
为了进一步加深对 yield 既能输入又能输出的理解,我们再看一个例子
function* generatorFn() {
return yield "foo";
}
let g = generatorFn();
console.log(g.next());
console.log(g.next("bar"));
输出结果是这样的
第一个 next() 方法执行时,yield 被冻结,输出后面的 foo,所以 value 是 'foo'
第二个 next() 方法执行时,yield 输入了 'bar',并同时完成了输出
使用 yield* 迭代可迭代对象
可以使用星号增强 yield 的行为,这样就能迭代一个可迭代对象
也就是说下面两段代码是等价的
// function* generatorFn() {
// for (const x of [1, 2, 3]) {
// yield x;
// }
// }
function* generatorFn() {
yield* [1, 2, 3];
}
使用 yield* 实现递归算法
yield* 最有用的地方是实现递归操作,此时生成器会产生自身。看下面的代码
function* nTimes(n) {
if (n > 0) {
yield* nTimes(n - 1);
yield n - 1;
}
}
for (const x of nTimes(3)) {
console.log(x);
}
输出结果如下
然而如果我们在递归输出是不用 yield*,会得到下面的输出结果
可以看到此时仅仅是直接输出了生成器函数,并没有进行递归的调用
生成器作为默认迭代器
在了解了生成器之后,我们知道生成器对象自身是实现了 Iterable 接口的,并且生成器函数执行后生成的就是一个迭代器,那么我们就可以很简洁地写出一个自定义迭代器
class Foo {
constructor() {
this.values = [1, 2, 3];
}
*[Symbol.iterator]() {
yield* this.values;
}
}
const f = new Foo();
for (const x of f) {
console.log(x);
}
可以看到此时的工厂函数加上了星号,变为了生成器函数。然后通过 yield* 迭代输出了 this.value 的数组。
提前终止生成器
与迭代器终止时调用 return 中的代码不同,生成器中存在 return() 和 throw() 方法能够强制生成器进入关闭状态
首先 return() 方法使用如下
function* generatorFn() {
for (const x of [1, 2, 3]) {
yield x;
}
}
const g = generatorFn();
console.log(g);
console.log(g.return(4));
console.log(g);
输出结果如下
可以看到通过给生成器对象添加 return() 方法,生成器被强制关闭了,并且终止时的值就是使用 return() 方法时传入的值
还有就是 throw() 方法
function* generatorFn() {
for (const x of [1, 2, 3]) {
yield x;
}
}
const g = generatorFn();
console.log(g);
try {
g.throw(4);
} catch (e) {
console.log(e);
}
console.log(g);
可以看到在 throw() 方法的测试没有被处理后,生成器被关闭了
但是当我们在生成器内部添加一段处理 throw() 方法的如下
function* generatorFn() {
for (const x of [1, 2, 3]) {
try {
yield x;
} catch (e) {}
}
}
const g = generatorFn();
console.log(g.next());
g.throw(4);
console.log(g.next());
此时 throw() 方法在生成器内部被处理了,不会终止
这里要注意一个点,要用 next() 让生成器对象开始执行后再进行测试。我一开始想着少改一点代码没有加 next() 方法的运行,调用 throw() 抛出的错误并不能被函数内部处理,所以就无法得到想要的结果。
小结
这一块的学习差不多持续了大半天,可以发现 JavaScript 的语言设计还是特别精巧的,自己的基础还是要补上才行