JavaScript学习笔记(5):迭代器与生成器

迭代器

可迭代对象

任何实现Iterable接口的数据结构都可以被实现Iterator接口的结构“消费”。迭代器(iterator)是按需创建的一次性对象,每个迭代器都会关联一个可迭代对象,而迭代器会暴露迭代其关联可迭代对象的API。迭代器无须了解与其关联的可迭代对象的结构,只需要知道如何取得连续的值。

Iterable接口

实现了Iterable接口的对象就是可迭代的对象,就可以被迭代,而这个接口是通过暴露一个属性(方法)作为“默认迭代器”实现的。这个方法必须使用符号Symbol.iterator作为键,并且这个方法应当是一个工厂函数,调用这个工厂函数必须返回一个新迭代器。

实现了Iterable接口的内置类型有:

  • 字符串
  • 数组
  • 映射
  • 集合
  • arguments对象
  • NodeList等DOM集合类型

那么调用它们的Symbol.iterator接口必定返回的是一个迭代器,而判断一个对象是否可迭代,也可以去测试它的Symbol.iterator接口:

1
2
3
4
let arr = [];
let obj = {};
console.log(arr[Symbol.iterator]); // ƒ values()
console.log(obj[Symbol.iterator]); // undefined

这样的arr就是一个可迭代的对象,而obj是不可迭代的,因为obj的Symbol.iterator是未定义的。

我们已经知道arr[Symbol.iterator]是一个工厂函数,调用它能够返回一个迭代器,那么我们拿到了这个迭代器是可以直接使用的,但我们暂时不直接使用它。

如果对象原型链上的父类实现了Iterable接口,那这个对象也会继承这个接口从而可迭代。

Iterator接口

实际写代码过程中,不需要显式使用对象的迭代器,实现Iterator接口的一些语言结构会自动地对可迭代对象的迭代器进行消费,依次取出它们的值并使用。接收可迭代对象的原生语言特性包括:

  • for-of循环
  • 数组解构
  • 扩展操作符
  • Array.from()
  • 创建集合
  • 创建映射
  • Promise.all()接收由期约组成的可迭代对象
  • Promise.race()接收由期约组成的可迭代对象
  • yield *操作符,在生成器中使用

初次接触迭代器的话依然很难搞明白它是什么,如果更通俗地解释它,就可以说迭代器是这么一个对象:

  • 它能够从某个对象中读取值,这个对象是迭代器对象的数据源。如:arr[Symbol.iterator]()返回的迭代器对象,会把arr数组作为它的数据源,这个迭代器能从数组arr中读取值。
  • 它一次只能从数据源中读取一个值,一般通过一个固定方法来读取,且迭代器是有状态的,它能够记录下读取到了哪里,下一次会从下个值继续读取。在JS中这个方法是next(),对上述迭代器初次调用next()方法会得到arr[0]的值,再次调用将得到arr[1]的值,以此类推直到得到了数组arr中的所有值。每次得到值的同时还会得到迭代器的状态。
  • 它读取值是有一定规则、有一定顺序的。如:从数组中读取的顺序就是数组的下标。

那么也就是说我们想要通过迭代器依次迭代数组arr需要不断调用迭代器的next()方法才行,但是JS提供了上述支持自动迭代可迭代对象的语言结构就使得迭代变得非常简单,最常用的就是for-of循环,对一个可迭代对象可以这样:

1
2
3
4
5
6
7
let nums = [1,2,3];
for (const num of nums) {
console.log(num);
}
// 1
// 2
// 3

for-of结构对可迭代对象nums做的事情就是:

  • 通过[Symbol.iterator]定义的方法取得nums的迭代器;
  • 每次循环从这个迭代器中取得一个值赋值给num;
  • 执行循环体。

这样我们就不需要自行操作迭代器对象,也不需要自己一次次调用next()方法,这些事情实现了Iterator接口的语言结构都能直接帮我们做了,我们只需要丢给它一个可迭代对象就可以了。

使用迭代器

上文说了使用迭代器无非就是:

  • 调用[Symbol.iterator]方法取得迭代器对象;
  • 不断对这个迭代器对象调用next()方法从数据源取得值。

每次调用next(),都会返回一个IteratorResult对象,其中包含了迭代器返回的一个值和迭代器的状态

迭代器的状态是封装的,若不调用next(),则无法知道迭代器的当前位置和状态。

IteratorResult对象包含两个属性:done和value。

  • done是一个布尔值,表示是否还可以再次调用next()取得下一个值;
  • value包含取回的可迭代对象的一个值,当done为true时迭代器迭代完了数据源,value值就是undefined。

直接使用迭代器的例子:

1
2
3
4
5
6
7
8
let arr = [1,2,3];
let iter = arr[Symbol.iterator](); // 取得arr的迭代器对象
console.log(iter); // Array Iterator
console.log(iter.next()); // {value: 1, done: false}
console.log(iter.next()); // {value: 2, done: false}
console.log(iter.next()); // {value: 3, done: false}
console.log(iter.next()); // {value: undefined, done: true}
console.log(iter.next()); // {value: undefined, done: true}

迭代器的迭代行为是有顺序的,且不可逆的,迭代器向下进行了就不可能再回头了。可以看到迭代器已经到了尽头再次调用next()方法将永远返回{value: undefined, done: true}

还有一点就是,如果手动操作迭代器,确保数据迭代完必定要多调用一次next(),如上述例子,调用了三次之后你还是不能确定数组中是不是只有这三个数,还需要调用第四次,迭代器去数组中取数,发现没了,才会返回done: true的结果,你才能知道数据迭代完了。这是因为迭代器只取一个值,并不会去查看后一个值,因此迭代器在取3的时候,并不会知道后面还有没有,因此第三次调用依然返回done: false,在第四次调用才确定了数据源中没有数据了。

自定义迭代器

任何实现了next()方法的对象都能被当作迭代器,有以下例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
class Counter {
constructor(end) {
this.count = 0;
this.end = end;
}
next() {
if(this.count < this.end) {
return {value: this.count++, done: false};
} else {
return {value: undefined, done: true};
}
}
[Symbol.iterator]() {
return this;
}
}
let counter = new Counter(3);
for (const c of counter) {
console.log(c);
}
// 0
// 1
// 2
for (const c of counter) {
console.log(c);
}
// 无输出

可以看到这个对象是可以被迭代的,但是它的迭代是一次性的,因为这个[Symbol.iterator]并不是一个合格的工厂函数,它每次返回的并不是全新的迭代器对象,第二次调用它返回的是个已经被用过的迭代器了。

可以通过闭包来改进,使得返回的总是一个全新的迭代器对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
class Counter {
constructor(end) {
this.count = 0;
this.end = end;
}
[Symbol.iterator]() {
let count = 0;
let end = this.end;
return {
next() {
if(count < end) {
return {value: count++, done: false};
} else {
return {value: undefined, done: true};
}
}
};
}
}
let counter = new Counter(3);
for (const c of counter) {
console.log(c);
}
// 0
// 1
// 2
for (const c of counter) {
console.log(c);
}
// 0
// 1
// 2

这样每次得到的就是一个从头迭代的全新的迭代器对象了。

提前终止迭代器

可以为迭代器对象指定return方法来决定迭代器提前关闭时执行的操作。迭代器提前关闭的情况有:

  • for-of循环通过break、continue、return或throw提前退出;
  • 解构操作并未消费所有值。

return()方法必须返回一个有效的IteratorResult对象。简单情况下,可以只返回{ done: true }。因为这个返回值只会用在生成器的上下文中,在迭代器中并没有什么用。

内置的可以自动迭代对象的语言结构在发现还有更多值可以迭代,但不会消费这些值时,会自动调用return()方法。如我们对之前的例子加入return方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
class Counter {
constructor(end) {
this.count = 0;
this.end = end;
}
[Symbol.iterator]() {
let count = 0;
let end = this.end;
return {
next() {
if(count < end) {
return {value: count++, done: false};
} else {
return {value: undefined, done: true};
}
},
return() {
console.log("迭代器提前关闭啦!");
return { done: true };
}
};
}
}
let counter = new Counter(3);
for (const c of counter) {
console.log(c);
if(c>0) break;
}
// 0
// 1
// 迭代器提前关闭啦!

有趣的是,即便迭代了所有的值,只要for中使用了break,迭代器就会被视为提前关闭,比如:

1
2
3
4
5
6
7
8
for (const c of counter) {
console.log(c);
if(c>1) break;
}
// 0
// 1
// 2
// 迭代器提前关闭啦!

迭代器明明就只有三个值,却还是说迭代器被提前关闭,为什么呢?其实是之前说过的,迭代器想知道值被迭代完了,一定会多执行一次next方法,这里提前终止了循环,我们知道一共有三个值,但是迭代器不知道,迭代器还需要第四次next才能知道,但它的第四次next被我们打断了。

注意:return方法只是在迭代器被提前关闭时会被调用,但是有的迭代器天生是无法关闭的,提前跳出循环也不能关闭它,因此不意味着给它加了return方法就能使一个不可关闭的迭代器变成可关闭的。比如数组的迭代器就是不可关闭的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
let arr = [1,2,3,4,5,6];
let iter = arr[Symbol.iterator]();
for (const i of iter) {
console.log(i);
if(i>2) break;
}
console.log("继续迭代");
for (const i of iter) {
console.log(i);
}
// 1
// 2
// 3
// 继续迭代
// 4
// 5
// 6

可以看到数组的迭代器不会因为break而关闭,之所以之前我们使用for-of循环不会出现继续迭代的情况,是因为我们没有使用显式的迭代器,而是直接给for-of循环了一个可迭代对象,每次循环实际上都是创建了一个全新的迭代器。

扩展操作符

可迭代对象可以使用扩展操作符将迭代的结果展开,如:

1
2
3
4
5
6
let arr1 = [1, 2, 3];
let arr2 = [...arr1];
let arr3 = [0, ...arr2, 4, 5, 6];
console.log(arr1); // [1, 2, 3]
console.log(arr2); // [1, 2, 3]
console.log(arr3); // [0, 1, 2, 3, 4, 5, 6]

展开的结果视为浅复制。

而不可迭代对象只有一种情况下能被解构,即使用字面量浅复制对象时:

1
2
let o = {a: 1, b: 2};
let o2 = {...o}

生成器

定义生成器

生成器的形式是一个函数,函数名称前面加一个星号(*)表示它是一个生成器。只要是可以定义函数的地方,就可以定义生成器,可以把它看成一个特殊的函数,但注意箭头函数不能用来定义生成器函数。标识生成器函数的星号不受两侧空格的影响。

与迭代器相似,生成器对象也实现了Iterator接口,因此具有next()方法,调用这个方法会让生成器开始或恢复执行。next()方法的返回值类似于迭代器,有一个done属性和一个value属性。函数体为空的生成器函数中间不会停留,调用一次next()就会让生成器到达done: true状态。

yield关键字可以让生成器停止和开始执行,生成器函数在遇到yield关键字之前会正常执行,遇到这个关键字后,执行会停止并跳出函数执行,但是函数的内部状态会被保存,下次调用next会自动接着上次的状态继续执行。

使用生成器

生成器状态

在生成器函数初次被调用时,只是获得了一个生成器,但它不会立刻开始执行,需要调用它的next方法才可以让它开始执行,遇到的第一个yield关键字暂停执行返回,通过yield返回的值会通过next方法的返回值获得,此时的状态为done: false,而return将彻底结束生成器,返回的值也将通过next方法返回,此时状态为done: true

有以下例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function * generator() {
console.log("生成器开始执行了!");
yield "第1次返回值";
console.log("又开始执行了!");
yield "第2次返回值";
console.log("又又开始执行了!");
yield "第3次返回值";
console.log("要结束了!");
return "Bye!"
}
let g = generator(); // 未开始执行
let r = g.next(); // 生成器开始执行了!
console.log(r); // {value: '第1次返回值', done: false}
r = g.next(); // 又开始执行了!
console.log(r); // {value: '第2次返回值', done: false}
r = g.next(); // 又又开始执行了!
console.log(r); // {value: '第3次返回值', done: false}
r = g.next(); // 要结束了!
console.log(r); // {value: 'Bye!', done: true}
r = g.next();
console.log(r); // {value: undefined, done: true}

yield关键字的行为本身就和return很类似,因此使用yield关键字也可以使用生成器实现递归。

使用循环迭代生成器

生成器实现了默认的可迭代接口,默认迭代器就是生成器本身。
有以下例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function * generator() {
yield "val1";
yield "val2";
yield "val3";
return "Bye"
}
let g = generator();
for (const i of g) {
console.log(i);
}
// val1
// val2
// val3
for (const i of g) {
console.log(i);
}
// 没有输出

可以看到我们创建了一个生成器对象,它只是一次性的,如果迭代完了所有的值,就没法再迭代第二次了。

因此我们可以每次迭代都使用一个新的生成器实例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function * generator() {
yield "val1";
yield "val2";
yield "val3";
return "Bye"
}
for (const i of generator()) {
console.log(i);
}
// val1
// val2
// val3
console.log("再来一次"); // 再来一次
for (const i of generator()) {
console.log(i);
}
// val1
// val2
// val3

yield关键字用于输入输出

之前的例子其实是yield关键字向外部输出值,但它还可以从外部读取输入的值,同样是通过next方法来实现的,next的参数会成为yield表达式的值,并出现在生成器函数的相应位置:

1
2
3
4
5
6
7
8
9
10
11
12
function * generator() {
let a = 1;
a *= yield a; // (1)
a *= yield a; // (2)
a *= yield a; // (3)
return a;
}
let g = generator(); // 创建生成器
console.log(g.next()); // 第一次next仅仅是为了启动生成器,获得了(1)中yield的值,但不会向yield传值
console.log(g.next(2)); // 向(1)中yield传入2,生成器使a乘以2,同时获得了(2)中yield的值
console.log(g.next(3)); // 向(2)中yield传入3,生成器使a乘以3,同时获得了(3)中yield的值
console.log(g.next(4)); // 向(3)中yield传入4,生成器使a乘以4,同时获得了返回值

生成器可以实现无穷计数的,也就是允许生成器中的循环永远不会跳出,前提是循环中使用yield,这样每轮循环都会中断一次。

yield解构可迭代对象

使用yield *结构可以将一个可迭代对象解构,并将其每个元素依次返回,如:

1
2
3
4
5
6
7
8
function * generator() {
yield * [1,2,3];
}
let g = generator();
console.log(g.next()); // {value: 1, done: false}
console.log(g.next()); // {value: 2, done: false}
console.log(g.next()); // {value: 3, done: false}
console.log(g.next()); // {value: undefined, done: true}

替代默认迭代器

生成器对象实现了可迭代接口,调用生成器函数又会返回一个生成器对象,因此可将生成器函数作为对象的默认迭代器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
let obj = new class {
constructor() {
this.values = [1,2,3];
}
* [Symbol.iterator]() {
yield * this.values;
}
}();
for (const i of obj) {
console.log(i);
}
// 1
// 2
// 3

关闭生成器

生成器提前终止的方法有return()方法和throw()方法,我们也可以重写这两个方法,但它们都会强制生成器关闭。

return方法

有以下例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function * generator() {
yield * [1,2,3,4,5,6];
}
let g = generator();
for (const i of g) {
console.log(i);
if(i>2) break;
}
// 1
// 2
// 3
console.log(g.next()); // {value: undefined, done: true}
for (const i of g) {
console.log(i);
}

可以看到生成器在被迭代时break了,会导致return方法被隐式调用,生成器直接进入关闭状态不可恢复。当然我们也能显式调用它的return方法来关闭。

throw方法

throw方法会在暂停时将一个错误注入到生成器对象中,如果它如果错误未被处理则生成器关闭,如果在生成器内部处理了这个错误,生成器不会关闭

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function * generator() {
for (const i of [1,2,3,4]) {
try {
yield i;
} catch (e) {
console.log(e);
}
}
}
let g = generator();
console.log(g.next()); // {value: 1, done: false}
g.throw("啊呀呀!出错了呢!");
let r = g.next(); // 啊呀呀!出错了呢!
console.log(r); // {value: 3, done: false}
console.log(g.next()); // {value: 4, done: false}
console.log(g.next()); // {value: undefined, done: true}

可以看到在注入了错误之后,下一次调用next会进入错误处理,并跳过一个值,直接取得下一个值。

此外还要注意:生成器对象刚创建时处于未执行的状态,这时调用throw方法将不会被生成器捕获,相当于在函数外部抛出了错误!




* 你好,我是大森。如果文章内容帮到了你,你可通过下方付款二维码支持作者 *