「ECMA-262 系列」理解迭代
引子
迭代,iteration,意思是「重复」或者「再来」。在计算机领域,我们可以简单理解为按照顺序反复的执行某一段代码。
for 与 for-of
比如看一个关于迭代最简单的例子:
const arr = [1, 2, 3, 4];
for(let i = 0; i < arr.length; i++) {
console.log(arr[i]);
}
上述代码通过 for 循环实现迭代,作用是遍历了一遍数组的并打印出数组中的值。
我们在使用 for 循环之前,我们提前知道了如下几个信息:
- 明确了被遍历对象的数据结构,这里的数据结构是数组
- 明确了循环中止的条件,这里是遍历到数组的最后一项
- 明确了如何获取遍历对象中的值,这里通过数组下标获得
for 循环的问题以及解决方案
基于以上几点信息,接着我们分析一下,使用 for 循环进行迭代的缺点。
- 无法兼容兼容不同的数据结构
在上面的例子中,我们使用 for 循环对一个数组进行迭代,那如果此时将数组换成 Map,或者Set 之类的数据结构呢?我们就要重新编写一套迭代逻辑。
- 过于关注如何迭代的逻辑
使用 for 循环进行迭代,我们必须要关心起始/终止条件,而如果没有特殊的需求,大部分的迭代所做的事情是按照一定的顺序遍历一遍可迭代对象中的成员。
那么,为了解决上述问题,JavaScript 是否提供了某种方法,让我们有更加简单且高效的方式进行迭代呢?
有的, JavaScript 提供的 for...of
语句可以帮助我们解决上面使用 for 循环带来的问题。
const arr = [1, 2, 3, 4];
const map = new Map([
["key1", 1],
["key2", 2],
["key3", 3],
]);
for (const item of arr) {
console.log(item);
}
// 结果如下
// 1
// 2
// 3
// 4
for (const item of map) {
console.log(item);
}
// 结果如下
// ['key1', 1]
// ['key2', 2]
// ['key3', 3]
通过 for...of
语句,我们不再关注如何迭代(设置迭代中止条件,兼容不同数据结构),只关注每次迭代的逻辑。
可迭代对象
从表现上来看,如果一个对象,能通过 for...of
语句进行迭代,那么这个对象就是一个可跌代对象。
JavaScript 官方给我们提供了一些可迭代对象:
- Array
- String
- Map
- Set
- TypedArray
用法几乎都是一样的,只不过不同的可迭代对象每次迭代返回的值(代码中的 item
)不太一样。
// map
for (const item of map) {
// item === ['key', value] 是一个键值对元祖
console.log(item);
}
// array
for (const item of array) {
// item === listValue 是数组中的每一项元素
console.log(item);
}
The Iterable interface
打开 Chrome,在控制台中输入 console.log(new Map())
,并展开其原型链,可以看到有一个属性叫 Symbol(Symbol.iterator)
。
举一反三,你可以自己在控制台试着打印一下数组,Set,这些可迭代对象。
无一例外,他们的原型链上,都存在着 Symbol(Symbol.iterator)
这个属性。
那么问题来了,Symbol(Symbol.iterator)
是什么东西?
ES 规范
下图是 ecma-262 规范中关于迭代器的部分截图:
这是一个关于 Iterable Interface 的定义。该接口要求实现一个属性 @@iterator
,并且该值是一个函数,返回一个迭代器对象(要求返回的对象必须符合 Iterator Interface)
Well-Known Symbols
上面这段话,最让人费解的就是 @@iterator
,这是什么意思?其实这是 ES 规范约定的一种表示方法,用于表述 Well-Known Symbol。
“well-known symbols” 是 ES 规范中明确引用的内建 Symbol 值。它们通常用作属性的键,这些属性的值作为规范算法的扩展点。
在 ES 规范中使用 @@name
来指代某个 well-known symbol。
所以说,@@iterator
代表的是一个 symbol,而该 Symbol 对应的值是一个方法,该方法返回一个对象作为默认的迭代器,并且会被 for of
语句调用。
此时,回过头来看 console.log(new Map())
所打印出来的值,是不是感觉清晰了许多。
深入迭代细节
我们接着从规范入手,区分一下 The Iterable Interface
和 The Iterator Interface
。
The Iterable Interface
下图是是一个关于 Iterable Interface 的定义。在上面我们已经看过了,该接口要求实现一个属性 @@iterator
,并且该值是一个函数,返回一个迭代器对象(要求返回的对象必须符合 Iterator Interface)
我们提炼一下这句话的几个核心点:
- 是一个对象
- 对象上有一个属性
- 该属性值为一个 Well-Known Symbol,并且为
@@Iterator
- 要求
@@Iterator
返回一个对象,必须符合The Iterator interface
The Iterator Interface
一个实现了 Iterator Interface
的对象必须包括 next
属性,以及可选包括 return
属性与 throw
关于 Table 74 和 Table 75, ES 规范还作了一些说明,你可以自己查阅。以及有很多细节不展开讲解了。
next 属性
next 属性对应的值是一个函数,并且该函数返回一个 IteratorResult Object
。而一个 IteratorResult Object
需要满足 IteratorResult Interface
。
其中存在两个属性 done
和 value
。
done
是一个布尔值,是Iterator
的next
属性调用的返回值。如果返回true
则代表已经完成迭代,如果是 false 则表示继续迭代,并且value
是有用的值。value
是一个 ES 规范中的值,如果done
是false
,那么这就是当前迭代元素的值。如果done
是true
,这就是迭代器的返回值(如果有的话)。如果迭代器没有返回值,那么"value"
就是未定义的。
return 属性
return 对应的值是一个函数,其返回一个符合 IteratorResult
接口的对象。并且调用该方法时,会通知 Iterator Object
,不再进行 next 方法的调用。
throw 属性
调用此方法会通知迭代器对象,调用者已经检测到一个错误条件。参数可以用来识别错误条件,通常会是一个异常对象。典型的响应是抛出作为参数传递的值。如果方法没有抛出,返回的 IteratorResult 对象通常会有一个 “done” 属性,其值为 true。
小结
实践
最后,我们可以通过以上所学,构建一个自己的数据结构,并为该数据结构实现迭代器。
例子:实现一个 KeyValuePair,使用 setPair(key, value)
方法设置键值对,并能通过 for of
语句实现迭代。
class KeyValuePair {
private keyList: string[] = [];
private valueList: string[] = [];
private length = 0;
setPair(key: string, value: string) {
this.keyList.push(key);
this.valueList.push(value);
this.length++;
}
[Symbol.iterator] = () => {
let index = 0;
return {
next: () => {
const pair = [this.keyList[index], this.valueList[index]];
const nextFlag = index >= this.length;
index++;
return {
value: pair,
done: nextFlag,
}
}
}
}
}
const kv = new KeyValuePair()
kv.setPair('a', 'a')
kv.setPair('b', 'b')
kv.setPair('c', 'c')
for (const item of kv) {
console.log(item);
}