我们知道使用 Symbol 类型去表示独一无二的值。同样的,当使用 Symbol 值作为对象的键名,就能保证不会出现同名属性。假设我们的默认迭代器部署在一个名称为 iterator(或其他)的属性上,那么是不是很容易被我们改写或覆盖掉。因此指定标准的那帮家伙才会使用 Symbol 值作为键名,并规定它的特殊含义,这样开发者就不会随意覆盖它了。
由于 Symbol 值是唯一的,因此不能使用点运算符(.)去访问,需通过方括号([])的形式才能访问。
除了 Symbol.iterator,还有很多内置的 Symbol 值。例如 Symbol. hasInstance、Symbol.toPrimitive、Symbol.toStringTag 等等,它们都有特定的含义。有时候也会对应称作 @@iterator、@@toPrimitive 等。
class Counter {
constructor([min = 0, max = 10]) {
this.min = min
this.max = max
}
[Symbol.iterator]() {
let point = this.min
const end = this.max
return {
next: () => {
const isDone = point > end
return {
done: isDone,
value: isDone ? undefined : point++
}
}
}
}
}
const counter = new Counter([0, 3])
for (const x of counter) {
console.log(x)
}
// 依次打印出:0、1、2、3
for (const x of counter) {
console.log(x)
}
// 依次打印出:0、1、2、3
以上这种改进写法,与数组迭代的表现是相似的。
3. 完善版本
但目前跟数组,还存在一点区别。例如:
// ✅ 数组可以这样使用
const arr = [0, 1, 2, 3]
const iter = arr[Symbol.iterator]()
for (const x of iter) {
console.log(x)
}
// 依次打印:0、1、2、3
// ❌ 但是如果 Counter 这样去使用
const counter = new Counter([0, 3])
const iter = counter[Symbol.iterator]()
// 当代码执行到 for...of 这行的时候,发现就会报错:TypeError: iter is not iterable
for (const x of iter) {
console.log(x)
}
// 原因分析:
// 其实很简单,使用 for...of 的时候,内部会自动访问 iter[Symbol.iterator],
// 但 iter 是仅含有 next 属性(不计原型)的普通对象,并没有 Symbol.iterator 属性,
// 因此判断 iter 不是一个可迭代对象,所以报错:TypeError: iter is not iterable
解决方法也很简单,只要我们给 iter 添加一个 Symbol.iterator 属性,并返回其本身即可。
class Counter {
constructor([min = 0, max = 10]) {
this.min = min
this.max = max
}
[Symbol.iterator]() {
let point = this.min
const end = this.max
return {
[Symbol.iterator]() {
// 不能使用箭头函数,否则 this 指向就不对了
// 因此,我也把下面 next 方法,将原来的箭头函数改回普通函数
return this
},
next() {
const isDone = point > end
return {
done: isDone,
value: isDone ? undefined : point++
}
}
}
}
}
再次执行,就可以了。
const counter = new Counter([0, 3])
const iter = counter[Symbol.iterator]()
for (const x of iter) {
console.log(x)
}
// 依次打印出:0、1、2、3
class Counter {
constructor([min = 0, max = 10]) {
this.min = min
this.max = max
}
*[Symbol.iterator]() {
let point = this.min
const end = this.max
while (point <= end) {
yield point++
}
}
}
迭代的英文是 “iteration”,源自拉丁文 “itero”,是“重复”或“再来”的意思。在软件开发领域,“迭代”的意思是按照顺序反复多次执行一段程序,通常会有明确的终止条件。ES6 规范新增了两个高级特性:迭代器和生成器。使用这两个特性,能够更清晰、高效、方便地实现迭代。
一、迭代器简述
迭代器(Iterator)是一种机制,是一种接口。为各种不同的数据结构提供了统一的访问机制。
1. 为什么 ECMAScript 要引入“迭代器”?
在 ES6 之前,最常见的数据结构有数组和对象。
遍历这些数据结构通常使用
for
语句。例如数组,除了最原始的for
循环,还有Array.prototype.forEach()
等方法。而普通对象则使用for...in
语句。那么,我们决定使用哪一种方法之前,是不是要提前知道它们内部结构是怎样的,才能正确地写出迭代的程序,对吧。而在 ES6 标准中,新增了
Map
和Set
两种不同的数据结构。它们的内部结构与原本就存在的Array
和Object
又不一样。假设我们要去遍历它们,是不是要实现一个类似Array.prototype.forEach()
的方法,例如,遍历以上两种结构的方法叫Map.prototype.forEach()
和Set.prototype.forEach()
(当然这两个是我瞎编了)。那么这样是不是又多了两种迭代方式,无论对负责制定标准的 TC39 委员会,还是 JavaScript 开发者来说,都增加了工作量。委员会要负责实现,开发者要了解学习,都需要成本。看到这种苗头,TC39 委员会那些大佬就内心开始骂街了。每增加一种数据结构,就要实现一种全新的方法去遍历它,那劳资不干了,都不能好好摸鱼了。
于是它们就想出一个“偷懒”的方法:实现一种通用的迭代机制,并制定相关标准。它一定要支持原有的数据结构,而且未来新增的数据结构,也要满足这种模式(机制)才行。因此,在 ECMAScript 中就出现了“迭代器”(Iterator)的概念。
这样的话,对于我们开发者来说,无需知道某种对象的内部数据结构,就可以利用
for...of
等语句,方便、快速、安全地写出迭代程序。假设在未来的 ES2050 标准中,新增了一种名为Record
的数据结构,只要符合迭代器机制的,我们仍然可以使用for...of
去遍历它们。2. JavaScript 内置的可迭代对象
如果一个对象具有 Iterator 接口,我们将它称为“可迭代对象”(Iterable)。
在 JavaScript 中,目前内置的可迭代对象有:
String
、Array
、TypedArray
、Map
、Set
、Arguments
和NodeList
。它们的原型对象(prototype
)都实现了@@iterator
方法。(即对象含有Symbol.iterator
属性)。除了内置的可迭代对象,我们可以实现自己的可迭代对象。例如:
3. Iterator 接口
迭代器是一个对象,带有特殊的接口。一个具有 Iterator 接口的对象,分为两部分:可迭代协议和迭代器协议。
简单来说:
可迭代协议,是指对象实现了
@@iterator
方法(不管是本身属性,还是对象原型链上的属性都可以),并返回一个迭代器。可通过常量Symbol.iterator
访问该属性。迭代器协议,是指
@@iterator
方法返回的迭代器实现了next
方法。调用next
方法返回一个IteratorResult
对象,包括两个属性:{ done: true/false, value: '...' }
,其中done
是一个布尔值,表示遍历是否结束;value
表示当前成员的值。某个对象只有实现了以上两个协议,才算是一个完整、合格的迭代器。
二、生成器简述
说到生成器,你们一定知道 Generator 函数(即“生成器函数”),它是 ES6 中提供的一种异步编程的解决方案。
生成器的形式是一个函数,在函数名称前面加一个星号(
*
),表示它是一个生成器。尽管语法上与普通函数相似,但语法行为却完全不同。如示例:
本文不会详细介绍生成器函数相关语法,后面另起一文。本文提到它的缘由是:Generator 函数会产生一个“生成器对象”,该对象也实现了 Iterator 接口。
因此,生成器格外合适作为默认迭代器。在实现自定义可迭代对象时,也是最简单的一种实现。例如:
三、Iterator 详解
这句话怎么理解,看示例:
在上面的实例中,结论与分析请看注释部分。
如何理解“一次性”对象,看示例:
上面的示例一中,使用了一个生成器函数返回一个生成器(也是迭代器,它实现了 Iterator 接口)。当我们使用
for...of
循环遍历它,并使用了break
关键字提前终止。在退出循环后,生成器关闭。若尝试再次迭代,不会产生任何进一步的结果。因此,我们不应该重用生成器。再看以下示例:
观察示例二,数组的迭代器跟生成器函数返回的迭代器又稍有不同。尽管我们在第一次迭代的时候,提前跳出循环了,但是迭代器
iter
并没有关闭。因此,我们可以尝试继续从上一次离开的地方继续迭代,这个离开的地方由迭代器内部维护。因此,数组的迭代器是不能主动关闭的,当它迭代至最后一个成员才会关闭。迭代器的概念很容易混淆,所以要注意了。
例如,数组不是一个迭代器(Iterator),但它是一个可迭代对象(Iterable)。在调用数组实例的
@@iteator
方法,才会生成一个与数组实例相关联的迭代器。Iterator 遍历过程
过程如下:
(1)创建一个指针对象,指向当前数据结构的起始位置。也就是说,迭代器对象本质上,就是一个指针对象。 (2)第一次调用指针对象的
next()
方法,可以将指针对象指向数据结构的第一个成员。 (3)第二次调用指针对象的next()
方法,可以将指针对象指向数据结构的第二个成员。 (4)不断调用指针对象的next()
方法,直到它指向数据结构的结束位置。每一次调用迭代器的
next()
方法,都会返回数据结构的当前成员的信息,即前面提到的IteratorResult
对象。它有包含done
和value
两个属性,其中done
是一个布尔值,表示遍历是否结束;value
表示当前成员的值。我们基于数组的结构特点,模拟一个简单的
next()
方法(迭代器协议):上面示例中,定义了一个
createIterator
函数,作用是返回一个迭代器。当我们对数组[1, 2, 3]
执行createIterator()
,就会返回与该数组相关联的遍历器对象iter
(即上文提到的指针对象)。第一次调用指针对象
iter
的next()
方法,指针指向数组的开始位置,并返回一个IteratorResult
对象{done: false, value: 1}
,它包含了当前数据成员的信息。若done
为false
表示遍历未结束,即可继续调用next()
方法访问下一个成员信息......直到done
为true
遍历结束。其实迭代器(Iterator)与可迭代对象(Iterable)是分开的,迭代器无需了解与其关联的可迭代对象,只需要知道如何取得连续的值。
四、默认迭代器
迭代器的目的就是提供一种统一的访问机制。可供给 JavaScript 所有接收可迭代对象的语句或方法使用。
前面提到,只要某种数据结构(对象)部署了 Iterator 接口,那么我们就称这种数据结构是“可迭代的”。
ES6 规定,默认的 Iterator 接口部署在对象的
Symbol.iterator
属性上。该属性本身就是一个函数,就是当前对象的迭代器生成函数。调用此方法就会返回一个迭代器。在 JavaScript 中,有以下这些数据结构原生具备 Iterator 接口:
因此我们不用任何处理,无需自己编写迭代器生成函数,就可以使用如
for...of
语句去遍历它们。for...of
内部会自动访问默认 Iterator 接口Symbol.iterator
。而普通对象并没有默认部署 Iterator 接口,因此我们不能使用 for...of 去迭代普通对象。但我们可以为它实现自定义迭代器接口。
如何判断某种数据结构是否实现了 Iterator 接口,可以这样:
五、自定义迭代器
普通对象,为什么不实现默认 Iterator 接口。主要是因为对象的属性是无序的,先遍历哪个属性,后遍历哪个属性是不确定的。若需要有序的对象结构,那不就
Map
对象嘛。因此,普通对象好像没必要默认部署迭代器接口了。尽管原生未部署 Iterator 接口,我们可以自定义啊,标准又没有限制我们不能自定义对吧。
我们来定义一个
Counter
类,并实现 Iterator 接口。1. 粗略版本
如下:
以上
Counter
类实现了可迭代协议和迭代器协议,因此其实例对象就具备了 Iterator 接口,可使用for...of
进行迭代。尽管实现了 Iterator 接口,但不理想。原因是每个实例只能被迭代一次,而且实例的
min
属性值会发生变化。2. 改进版本
鉴于以上原因,我们还需要改进一下。
为了让一个实例对象能够创建多个迭代器,必须每创建一个迭代器就对应一个新计算器。因此,我们可以把计算器变量放到闭包里,然后通过闭包返回迭代器。
以上这种改进写法,与数组迭代的表现是相似的。
3. 完善版本
但目前跟数组,还存在一点区别。例如:
解决方法也很简单,只要我们给
iter
添加一个Symbol.iterator
属性,并返回其本身即可。再次执行,就可以了。
4. 更完善版本
怎么实现呢?
ECMAScript 标准为我们提供了一个“可选”的
return()
方法,它属于迭代器协议的一部分。由于
return()
方法是可选的,因此并非所有数据结构的默认迭代器都是可关闭的。比如,数组的迭代器就是不可关闭的。而生成器对象就是可关闭的。数组迭代器示例:
生成器对象示例:
其实
return()
的实现要求也很简单,它必须返回一个有效的IteratorResult
对象。一般情况,可以只返回{ done: true }
。因为这个返回值只会用在生成器的上下文中。我们基于前面的
Counter
类,修改一下:上面除了实现了
return()
方法,且在触发return()
方法时,迭代器也会“关闭”,只要将原先表示是否结束的isDone
变量提到外面一层,利用闭包即可实现。以下为“提前退出”的情况,可以看到都触发了此方法。
利用迭代器实例的
return
属性,就可以判断一个可迭代对象是否具备可关闭的 Iterator 接口。但注意,我们不能给一个不可关闭的迭代器仅增加return()
方法,使其变成可关闭的。除非自定义 Iterator 接口去覆盖原生的Symbol.iterator
方法。5. 最终版本
既然又要提前关闭,为什么不直接利用 Generator 函数呢,简单又快捷。
一般情况下,Generator 函数是实现自定义迭代器的一种选择。它非常强大,语法上更为简练。但如果要实现类似数组默认迭代器那样的话,就不能用它了。
六、Iterator 应用场景
无论是默认的迭代器接口,还是自定义迭代器接口,它们主要供以语法或方法消费。目前有以下这些方法:
for...of
循环Array.from()
new Set()
new Map()
Promise.all()
Promise.race()
Promise.allSettled()
Promise.any()
yield*
操作符这些语法(方法)内部都会去访问(可迭代)对象的
@@iterator
方法,或叫作Symbol.iterator
方法。The end.