迭代的英文是 “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
属性)。
除了内置的可迭代对象,我们可以实现自己的可迭代对象。例如:
const myIterable = {
[Symbol.iterator]: function* () {
yield 1
yield 2
yield 3
// 生成器函数,返回一个可迭代对象
}
}
console.log([...myIterable]) // [1, 2, 3]
3. Iterator 接口
迭代器是一个对象,带有特殊的接口。一个具有 Iterator 接口的对象,分为两部分:可迭代协议和迭代器协议。
简单来说:
可迭代协议,是指对象实现了
@@iterator
方法(不管是本身属性,还是对象原型链上的属性都可以),并返回一个迭代器。可通过常量Symbol.iterator
访问该属性。迭代器协议,是指
@@iterator
方法返回的迭代器实现了next
方法。调用next
方法返回一个IteratorResult
对象,包括两个属性:{ done: true/false, value: '...' }
,其中 done 是一个布尔值,表示遍历是否结束;value 表示当前成员的值。
某个对象只有实现了以上两个协议,才算是一个完整、合格的迭代器。
二、生成器简述
说到生成器,你们一定知道 Generator 函数(即“生成器函数”),它是 ES6 中提供的一种异步编程的解决方案。
生成器的形式是一个函数,在函数名称前面加一个星号(*
),表示它是一个生成器。尽管语法上与普通函数相似,但语法行为却完全不同。
注意,箭头函数不能用来定义生成器函数。
如示例:
function* generatorFn() {} // generatorFn 表示一个生成器
const gen = generatorFn() // 调用生成器函数,会返回一个“生成器对象”
console.log(gen) // generatorFn {}
console.log(gen.next()) // {value: undefined, done: true}
// 生成器对象,一开始会处于暂停执行(suspended)的状态
// 调用 next() 方法会让生成器开始或恢复执行
// 由于生成器函数体为空,因此调用一次 next() 方法就会让生成器到达 done: true 的状态
本文不会详细介绍生成器函数相关语法,后面另起一文。本文提到它的缘由是:Generator 函数会产生一个“生成器对象”,该对象也实现了 Iterator 接口。
因此,生成器格外合适作为默认迭代器。在实现自定义可迭代对象时,也是最简单的一种实现。例如:
class Foo {
constructor(value) {
this.value = value
}
*[Symbol.iterator]() {
yield* this.value
}
}
// foo 实例对象是一个可迭代对象,因为它的原型上实现了 @@iterator 方法
const foo = new Foo([1, 2, 3])
// 访问迭代器
for (const x of foo) {
console.log(x)
}
// 依次打印:1、2、3
三、Iterator 详解
迭代器是按需创建的一次性对象。每个迭代器都会关联一个可迭代对象。
这句话怎么理解,看示例:
const num = 1
const obj = {}
const arr = [1, 2, 3]
// 通过访问 Symbol.iterator 属性可判断对象是否实现了可迭代协议,
// 返回值为函数,则表示实现了,否则未实现,
// 因此 num、obj 不可迭代,arr 是可迭代的。
console.log(num[Symbol.iterator]) // undefined
console.log(obj[Symbol.iterator]) // undefined
console.log(arr[Symbol.iterator]) // ƒ values() { [native code] }
// 调用 @@iterator 方法,会生成一个迭代器,
// iter1、iter2 表示两个不同的迭代器,
// 但它们相关联的可迭代对象都是 arr
const iter1 = arr[Symbol.iterator]()
const iter2 = arr[Symbol.iterator]()
console.log(iter1 === iter2) // false
for (const x of iter1) {
console.log(x)
}
// for...of 语句依次打印:1、2、3
在上面的实例中,结论与分析请看注释部分。
如何理解“一次性”对象,看示例:
// 示例一:
const gen = (function* () {
yield 1
yield 2
yield 3
})()
for (let x of gen) {
console.log(x)
break // 关闭生成器
}
// 生成器不应该重用,以下没有意义!
for (let x of gen) {
console.log(x)
}
// 由始至终,仅打印出 1
上面的示例一中,使用了一个生成器函数返回一个生成器(也是迭代器,它实现了 Iterator 接口)。当我们使用 for...of
循环遍历它,并使用了 break
关键字提前终止。在退出循环后,生成器关闭。若尝试再次迭代,不会产生任何进一步的结果。因此,我们不应该重用生成器。
再看以下示例:
// 示例二
const arr = [1, 2, 3, 4, 5]
const iter = arr[Symbol.iterator]()
// 第一次迭代
for (const x of iter) {
console.log(x)
if (x > 2) break
}
// 依次打印出:1、2、3
// 再次迭代
for (const x of iter) {
console.log(x)
}
// 依次打印出:4、5
观察示例二,数组的迭代器跟生成器函数返回的迭代器又稍有不同。尽管我们在第一次迭代的时候,提前跳出循环了,但是迭代器 iter
并没有关闭。因此,我们可以尝试继续从上一次离开的地方继续迭代,这个离开的地方由迭代器内部维护。因此,数组的迭代器是不能主动关闭的,当它迭代至最后一个成员才会关闭。
结合两个示例,同一个迭代器最好不要重复使用,因此才说是一次性对象。
迭代器的概念很容易混淆,所以要注意了。
例如,数组不是一个迭代器(Iterator),但它是一个可迭代对象(Iterable)。在调用数组实例的 @@iteator
方法,才会生成一个与数组实例相关联的迭代器。
还需要注意的是,由于迭代器维护着一个执行可迭代对象的引用,因此迭代器会阻止垃圾回收程序回收可迭代对象。
Iterator 遍历过程
过程如下:
(1)创建一个指针对象,指向当前数据结构的起始位置。也就是说,迭代器对象本质上,就是一个指针对象。
(2)第一次调用指针对象的 next()
方法,可以将指针对象指向数据结构的第一个成员。
(3)第二次调用指针对象的 next()
方法,可以将指针对象指向数据结构的第二个成员。
(4)不断调用指针对象的 next()
方法,直到它指向数据结构的结束位置。
每一次调用迭代器的 next()
方法,都会返回数据结构的当前成员的信息,即前面提到的 IteratorResult
对象。它有包含 done
和 value
两个属性,其中 done
是一个布尔值,表示遍历是否结束;value
表示当前成员的值。
我们基于数组的结构特点,模拟一个简单的 next()
方法(迭代器协议):
function createIterator(array) {
let nextIndex = 0
return {
next: function () {
return nextIndex < array.length
? { done: false, value: array[nextIndex++] }
: { done: true, value: undefined }
// 对迭代器对象来说,`done: false` 和 `value: undefined` 都是可以省略的,
// 因此可以简写成:
// return nextIndex < array.length
// ? { value: array[nextIndex++] }
// : { done: true }
}
}
}
const iter = createIterator([1, 2, 3])
上面示例中,定义了一个 createIterator
函数,作用是返回一个迭代器。当我们对数组 [1, 2, 3]
执行 createIterator()
,就会返回与该数组相关联的遍历器对象 iter
(即上文提到的指针对象)。
第一次调用指针对象 iter
的 next()
方法,指针指向数组的开始位置,并返回一个 IteratorResult
对象 {done: false, value: 1}
,它包含了当前数据成员的信息。若 done
为 false
表示遍历未结束,即可继续调用 next()
方法访问下一个成员信息......直到 done
为 true
遍历结束。
其实迭代器(Iterator)与可迭代对象(Iterable)是分开的,迭代器无需了解与其关联的可迭代对象,只需要知道如何取得连续的值。
四、默认迭代器
迭代器的目的就是提供一种统一的访问机制。可供给 JavaScript 所有接收可迭代对象的语句或方法使用。
目前支持以下这些:
for...of 语句
数组解构
扩展运算符
Array.from()
new Set()
new Map()
Promise.all()
Promise.race()
Promise.allSettled()
Promise.any()
yield* 操作符
前面提到,只要某种数据结构(对象)部署了 Iterator 接口,那么我们就称这种数据结构是“可迭代的”。
ES6 规定,默认的 Iterator 接口部署在对象的 Symbol.iterator
属性上。该属性本身就是一个函数,就是当前对象的迭代器生成函数。调用此方法就会返回一个迭代器。
插个话:
千万不要被
Symbol.iterator
属性吓到了,标准规定用它指向对象的默认遍历器方法而已,你完全可以在内心把它当成名称为iterator
的属性。那么arr[Symbol.iterator]()
,就相当于arr.iterator()
或arr['iterator']()
。它就是一个键名,仅此而已。我们知道使用 Symbol 类型去表示独一无二的值。同样的,当使用 Symbol 值作为对象的键名,就能保证不会出现同名属性。假设我们的默认迭代器部署在一个名称为
iterator
(或其他)的属性上,那么是不是很容易被我们改写或覆盖掉。因此指定标准的那帮家伙才会使用 Symbol 值作为键名,并规定它的特殊含义,这样开发者就不会随意覆盖它了。由于 Symbol 值是唯一的,因此不能使用点运算符(
.
)去访问,需通过方括号([]
)的形式才能访问。除了
Symbol.iterator
,还有很多内置的 Symbol 值。例如Symbol. hasInstance
、Symbol.toPrimitive
、Symbol.toStringTag
等等,它们都有特定的含义。有时候也会对应称作@@iterator
、@@toPrimitive
等。
在 JavaScript 中,有以下这些数据结构原生具备 Iterator 接口:
String、Array、TypedArray、Map、 Set、Arguments 和 NodeList。
因此我们不用任何处理,无需自己编写迭代器生成函数,就可以使用如 for...of
语句去遍历它们。for...of
内部会自动访问默认 Iterator 接口 Symbol.iterator
。
而普通对象并没有默认部署 Iterator 接口,因此我们不能使用 for...of 去迭代普通对象。但我们可以为它实现自定义迭代器接口。
实现迭代器接口,最简单的方法应该是使用 Generator 函数。
如何判断某种数据结构是否实现了 Iterator 接口,可以这样:
// Symbol.iterator 属性值为 undefined,表示未实现 Iterator 接口
console.log(1[Symbol.iterator]) // undefined
console.log({}[Symbol.iterator]) // undefined
console.log('abc'[Symbol.iterator]) // ƒ [Symbol.iterator]() { [native code] }
console.log([][Symbol.iterator]) // ƒ values() { [native code] }
五、自定义迭代器
普通对象,为什么不实现默认 Iterator 接口。主要是因为对象的属性是无序的,先遍历哪个属性,后遍历哪个属性是不确定的。若需要有序的对象结构,那不就 Map
对象嘛。因此,普通对象好像没必要默认部署迭代器接口了。
尽管原生未部署 Iterator 接口,我们可以自定义啊,标准又没有限制我们不能自定义对吧。
我们来定义一个 Counter
类,并实现 Iterator 接口。
1. 粗略版本
如下:
class Counter {
constructor([min = 0, max = 10]) {
this.min = min
this.max = max
}
// 实现迭代器协议
next() {
const isDone = this.min > this.max
return {
done: isDone,
value: isDone ? undefined : this.min++
}
// 迭代器的 next() 方法,需要返回一个对象,
// 对象包括 { done, value } 属性,
// 其中 done 为 false,或 value 为 undefined 时,返回值可省略该属性。
// 这里返回 this(即实例对象),它的原型上实现了 next() 方法
}
// 实现可迭代协议
[Symbol.iterator]() {
return this
// @@iterator 方法返回一个迭代器,它是一个对象。
// 不能返回类似 return 1 等无意义的值,否则进行迭代操作时会报错。
}
}
以上 Counter
类实现了可迭代协议和迭代器协议,因此其实例对象就具备了 Iterator 接口,可使用 for...of
进行迭代。
const counter = new Counter([0, 3]) // { min: 0, max: 3 }
for (const x of counter) {
console.log(x)
}
// 依次打印出:0、1、2、3
尽管实现了 Iterator 接口,但不理想。原因是每个实例只能被迭代一次,而且实例的 min
属性值会发生变化。
// 上一个代码块里面,counter 实例对象已迭代过一次
// 现在尝试再迭代一次,以下代码并不会打印出什么
for (const x of counter) {
console.log(x)
}
// 原因很简单:
// 当我们再次使用 for...of 之前,counter 实例已经变成:{ mix: 4, max: 3 }
// 然后执行到 for...of 的时候,内部做了以下这些步骤:
// 1) 首先创建一个迭代器,即访问实例的 @@iterator 方法,
// 返回的迭代器就是 this,值为 { mix: 4, max: 3 }。
// 2) 接着循环,其实就是不停调用迭代器的 next() 方法,即 this.next()
// 那么循环什么时候终止呢,那就是 done 为 true 时,表示遍历结束。
// 此时,第一次调用 next() 方法,返回值 done 就是为 true,即结束了。
// 因此,压根不会执行 for...of 的循环体,也就不会打印相关值了。
//
// 可通过以下示例去验证:
// for (const x of counter) {
// console.log(x)
// if (x === 1) break
// }
// 依次打印:0、1
// for (const x of counter) {
// console.log(x)
// }
// 依次打印:2、3
2. 改进版本
鉴于以上原因,我们还需要改进一下。
为了让一个实例对象能够创建多个迭代器,必须每创建一个迭代器就对应一个新计算器。因此,我们可以把计算器变量放到闭包里,然后通过闭包返回迭代器。
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
4. 更完善版本
假设我们在
for...of
循环使用break
、continue
、return
或throw
提前退出,或者使用解构操作未消费所有值时,我们希望就此“关闭”迭代器(即done
变为true
)。例如,生成器对象若提前退出,它的状态就会关闭。
怎么实现呢?
ECMAScript 标准为我们提供了一个“可选”的 return()
方法,它属于迭代器协议的一部分。
由于 return()
方法是可选的,因此并非所有数据结构的默认迭代器都是可关闭的。比如,数组的迭代器就是不可关闭的。而生成器对象就是可关闭的。
数组迭代器示例:
const arr = [1, 2, 3, 4, 5]
const iter = arr[Symbol.iterator]()
for (const x of iter) {
console.log(x)
if (x > 3) break
}
// 依次打印:1、2、3
for (const x of iter) {
console.log(x)
}
// 依次打印:4、5
// 需要注意的是:
// `for (const x of arr) {}` 与 `for (const x of iter) {}` 是有区别的,
// 前者每次调用,内部都会产生一个全新的迭代器,因此每次迭代,都会输出 0 ~ 5;
// 而后者则同一个迭代器,因此再次迭代时,会接着遍历下一个成员。
// 所以,不用误以为数组的迭代器是“可关闭的”。
生成器对象示例:
function* generatorFn() {
yield 0
yield 1
yield 2
yield 3
}
// 生成器对象,也是一个迭代器。
const gen = generatorFn() // generatorFn {}
for (const x of gen) {
console.log(x)
if (x === 1) break
}
console.log(gen) // generatorFn {}
// 迭代器“关闭”之后,再次去迭代的话就没意义了,它不会打印任何东西。
for (const x of gen) {
// do something...
}
其实 return()
的实现要求也很简单,它必须返回一个有效的 IteratorResult
对象。一般情况,可以只返回 { done: true }
。因为这个返回值只会用在生成器的上下文中。
我们基于前面的 Counter
类,修改一下:
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]() {
return this
},
next() {
const isDone = point > end
return {
done: isDone,
value: isDone ? undefined : point++
}
},
return() {
console.log('Exiting early')
return { done: true }
// 只会在“提前退出”才会触发此方法,
// 正常遍历结束,是不会执行此方法的。
}
}
}
}
上面实现了 return()
方法,以下“提前退出”的情况,可以看到都触发了此方法。
const counter = new Counter([0, 3])
for (const x of counter) {
console.log(x)
if ( x === 1 ) break // 或 throw 'xxx' 或 return 都行
}
// 依次打印出:0、1、"Exiting early"
const [a, b] = counter
// "Exiting early"
利用迭代器实例的 return
属性,就可以判断一个可迭代对象是否具备可关闭的 Iterator 接口。但注意,我们不能给一个不可关闭的迭代器仅增加 return()
方法,使其变成可关闭的。除非自定义 Iterator 接口去覆盖原生的 Symbol.iterator
方法。
5. 最终版本
既然又要提前关闭,为什么不直接利用 Generator 函数呢,简单又快捷。
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++
}
}
}
一般情况下,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.