持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第7天,点击查看活动详情
详解 JS 中的迭代器(iterator)
大家首先先弄清楚以下的三个概念:
-
什么是迭代?
- 在软件开发领域,迭代的意思是按照顺序反复多次执行一段程序,通常会有明确的终止条件。
-
迭代和遍历的区别?
- 迭代强调的是依次取数据,并不保证取多少,也不保证把所有的数据取完
- 遍历强调的是要把整个数据依次全部取出
-
迭代器
- 对迭代过程的封装,在不同的语言中有不同的表现形式,通常为对象
1.迭代器产生的原因
1.1 ES5 之前 循环遍历方法
在 ES5 之前,我们只能通过循环来遍历一个数组,使用方法如下:
let arr = [1, 2, 3, 4, 5];
for (let i = 0; i < arr.length; i++) {
console.log(arr[i]); // 1 2 3 4 5
}
循环遍历的不足:
因为数组有已知的长度,且数组每一项都可以通过索引获取,所以整个数组可以通过递增索引来遍历。 但通过这种循环来执行遍历并不理想,主要有以下两个原因:
- 迭代之前需要事先知道如何使用数据结构。 数组中的每一项都只能先通过引用取得数组对象,然后再通过[]操作符取得特定索引位置上的项。这种情况并不适用于所有数据结构。
- 遍历顺序并不是数据结构固有的。 通过递增索引来访问数据是特定于数组类型的方式,并不适用于其他具有隐式顺序的数据结构,如 ES6 新增的 Set 类型。
所以该方法只适用于带有索引的数据类型,并且还得通过数组对象来获取值,该方法得通用性不强,无法适用于 ES6 新增的 Set 类型。
1.2 ES5 forEach 遍历方法
ES5 新增了 Array.prototype.forEach 方法,使用方法如下:
let arr = [1, 2, 3, 4, 5];
arr.forEach((item) => console.log(item)); //1 2 3 4 5
forEach 遍历方法的不足:
- 虽然这个方法向通用迭代需求迈进了一步,解决了单独记录索引和通过数组对象取得值的问题,但没有办法标识迭代何时终止。
- 并且这个方法也只适用于数组,而且回调结构也比较笨拙,会消耗一定的性能。
如此可见在 ECMAScript 较早的版本中,执行迭代必须使用循环或其他辅助结构。但随着代码量增加,代码会变得越发混乱。
因此很多语言都通过原生语言结构解决了这个问题 —— 开发者无须事先知道如何迭代的情况下,就直接能实现迭代操作。
而这个问题的解决方案就是迭代器模式。Python、Java、C++等语言都对这个模式提供了完备的支持。因此 JavaScript 在 ECMAScript 6 也支持了迭代器模式。
2.迭代器模式
迭代器模式:一种设计模式,用于统一迭代过程。把拥有 Iterable 接口的数据结构称为可迭代对象(iterable),而且可以通过迭代器 Iterator 消费。
- 任何实现 Iterable 接口的数据结构 都可以 被实现 Iterator 接口的结构 消费(consume)。
- 迭代器(iterator)是按需创建的一次性对象。每个迭代器都会关联一个可迭代对象,而迭代器 会暴露 其关联的可迭代对象 中与迭代相关的 API。
- 迭代器无须了解与其关联的可迭代对象的结构,只需要知道如何取得连续的值。
这种概念上的分离正是 Iterable 和 Iterator 的强大之处。
2.1 可迭代协议
可迭代协议要求实现 Iterable 接口需要同时具备两种能力:
- 支持迭代的自我识别能力
- 创建实现 Iterator 接口的对象的能力
因此在 JS 中为满足 Iterable 接口的要求,使用特殊的Symbol.iterator作为属性名,属性值是默认迭代器,其引用一个迭代器工厂函数(iterator creator),调用这个工厂函数会返回一个新迭代器。
2.2 JS 中拥有 Iterable 接口的内置类型
JS 中有许多内置类型都实现了 Iterable 接口,也就特殊的Symbol.iterator属性,并且属性值是迭代器工厂函数,如:
- String
- Array
- Set
- Map
- 函数的 arguments 对象
- NodeList 等 DOM 集合类型
而 Number、Boolean、普通 Object 类型没有实现了 Iterable 接口。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>iterator</title>
<style></style>
</head>
<body>
<div></div>
<div></div>
<div></div>
<script>
let num = 1;
let obj = {};
// 这两种类型没有实现迭代器工厂函数
console.log(num[Symbol.iterator]); // undefined
console.log(obj[Symbol.iterator]); // undefined
let str = "abc";
let arr = ["a", "b", "c"];
let map = new Map().set("a", 1).set("b", 2).set("c", 3);
let set = new Set().add("a").add("b").add("c");
let els = document.querySelectorAll("div");
// 这些类型都实现了迭代器工厂函数
console.log(str[Symbol.iterator]); // ƒ [Symbol.iterator]() { [native code] }
console.log(arr[Symbol.iterator]); // f values() { [native code] }
console.log(map[Symbol.iterator]); // ƒ entries() { [native code] }
console.log(set[Symbol.iterator]); // f values() { [native code] }
console.log(els[Symbol.iterator]); // f values() { [native code] }
// 调用这个工厂函数会生成一个迭代器
console.log(str[Symbol.iterator]()); // StringIterator {}
console.log(arr[Symbol.iterator]()); // Array Iterator {}
console.log(map[Symbol.iterator]()); // MapIterator {'a' => 1, 'b' => 2, 'c' => 3}
console.log(set[Symbol.iterator]()); // SetIterator {'a', 'b', 'c'}
console.log(els[Symbol.iterator]()); // Array Iterator {}
</script>
</body>
</html>
2.3 JS 中会自动调用 Iterable 接口的原生语言结构
而我们在实际开发过程中,不需要显式调用这个工厂函数来生成迭代器。以下这些原生语言结构会在后台调用 Iterable 接口提供的可迭代对象的这个工厂函数,从而创建一个迭代器:
- for-of 循环
- 数组解构
- 扩展操作符
- Array.from()
- 创建集合
- 创建映射
- Promise.all()接收由期约组成的可迭代对象
- Promise.race()接收由期约组成的可迭代对象
- yield*操作符,在生成器中使用
let arr = ["foo", "bar", "baz"];
// for-of 循环
for (let el of arr) {
console.log(el); // foo bar baz
}
// 数组解构
let [a, b, c] = arr;
console.log(a, b, c); // foo, bar, baz
// 扩展操作符
let arr2 = [...arr];
console.log(arr2); // ['foo', 'bar', 'baz']
// Array.from()
let arr3 = Array.from(arr);
console.log(arr3); // ['foo', 'bar', 'baz']
// Set 构造函数
let set = new Set(arr);
console.log(set); // Set(3) {'foo', 'bar', 'baz'}
// Map 构造函数
let pairs = arr.map((item, index) => [item, index]);
console.log(pairs); // [['foo', 0], ['bar', 1], ['baz', 2]]
let map = new Map(pairs);
console.log(map); // Map(3) { 'foo'=>0, 'bar'=>1, 'baz'=>2 }
//如果对象原型链上的父类实现了 Iterable 接口,那这个对象也就实现了这个接口:
class FooArray extends Array {}
let fooArr = new FooArray("foo", "bar", "baz");
for (let el of fooArr) {
console.log(el); // foo bar baz
}
2.4 迭代器协议
迭代器是一种一次性使用的对象,用于迭代与其关联的可迭代对象。迭代器上拥有 next()方法,该方法用于得到下一个数据,调用该方法可在其关联的可迭代对象中实现遍历数据操作。
每次成功调用 next(),都会返回一个IteratorResult对象,该对象包含了 done 和 value 两个属性。
- done 是一个布尔值,表示是否还可以再次调用 next()取得下一个值;done: true 状态称为“耗尽”。
- value 包含可迭代对象的下一个值(done 为 false),当 done 为 true value 肯定为 undefined。
// IteratorResult 对象格式
{
value: '',//可迭代对象的数据
done: Boolean,//表示是否迭代完成
}
JS 中的迭代器:
- JS 规定,如果一个对象具有 next 方法,并且该方法返回一个
IteratorResult对象,则认为该对象是一个迭代器。
使用迭代器遍历其关联的可迭代对象时的注意点:
- 调用迭代器 next()方法按顺序迭代了数组,直至不再产生新值。这个过程中迭代器并不知道怎么从可迭代对象中取得下一个值,也不知道可迭代对象有多大。只要迭代器到达 done: true 状态,后续再调用 next()就一直返回
{ value: undefined, done: true }。 - 每个迭代器都表示对可迭代对象的一次性有序遍历。不同迭代器的实例相互之间没有联系,只会独立地遍历可迭代对象。
- 如果可迭代对象在迭代期间被修改了,那么迭代器也会做出相应的变化。
// 可迭代对象 用一个简单的数组来表示
let arr = ["foo", "bar"];
// 迭代器工厂函数
console.log(arr[Symbol.iterator]); // f values() { [native code] }
// 迭代器
let iter = arr[Symbol.iterator]();
console.log(iter); // ArrayIterator {}
// 执行迭代
console.log(iter.next()); // {value: 'foo', done: false}
arr.splice(1, 0, "baz"); // 在数组中间插入值
console.log(iter.next()); // { value: 'baz', done: false }
console.log(iter.next()); // { value: 'bar', done: false }
console.log(iter.next()); // { value: undefined, done: true }
console.log(iter.next()); // { value: undefined, done: true }
2.5 显式迭代器与原生迭代器的区别
“迭代器”的概念有时候容易模糊,因为它可以指通用的迭代,也可以指接口,还可以指正式的迭代器类型。下面的例子比较了一个显式的迭代器实现和一个原生的迭代器实现。
// 1.显式的迭代器实现
// 这个类实现了可迭代接口(Iterable)
// 调用默认的迭代器工厂函数会返回 一个实现迭代器接口(Iterator)的迭代器对象
class Foo {
[Symbol.iterator]() {
return {
next() {
return { done: false, value: "foo" };
},
};
}
}
let f = new Foo();
// 打印出实现了迭代器接口的对象
console.log(f[Symbol.iterator]()); // { next: [Function: next] }
// 2.原生的迭代器实现
// Array 类型内部已实现了可迭代接口(Iterable)
// 调用 Array 类型的默认迭代器工厂函数 会创建一个 ArrayIterator 的实例
let arr = new Array();
// 打印出 ArrayIterator 的实例
console.log(arr[Symbol.iterator]()); // Object [Array Iterator] {}
2.6 自定义迭代器
我们可以按照 Iterable 接口规则,自己在某个对象中实现 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(3);
for (let i of counter) {
console.log(i); // 1 2 3
}
for (let i of counter) {
console.log(i); // 1 2 3
}
每个以这种方式创建的迭代器也实现了 Iterable 接口。Symbol.iterator 属性引用的工厂函数会返回相同的迭代器:
let arr = ["foo", "bar", "baz"];
let iter1 = arr[Symbol.iterator]();
console.log(iter1[Symbol.iterator]); // ƒ [Symbol.iterator]() { [native code] }
let iter2 = iter1[Symbol.iterator]();
console.log(iter1 === iter2); // true
2.7 提前终止迭代器
可选的 return()方法用于指定迭代器提前关闭执行的逻辑。
可能的情况包括:
- for-of 循环通过
break、continue、return 或 throw提前退出; - 解构操作并未消费所有值。
return()方法必须返回一个有效的 IteratorResult 对象。简单情况下,可以只返回{ done: true }。
return()方法的使用场合是:
- 如果 for...of 循环提前退出(通常是因为出错,或者有 break 语句),就会调用 return()方法。
- 如果一个对象在完成遍历前,需要清理或释放资源,就可以部署 return()方法。
class Counter {
constructor(limit) {
this.limit = limit;
}
//自定义 迭代器工厂函数
[Symbol.iterator]() {
let count = 1,
limit = this.limit;
return {
//配置 next 方法
next() {
if (count <= limit) {
return { done: false, value: count++ };
} else {
return { done: true };
}
},
//配置 return 方法
return() {
console.log("Exiting early");
return { done: true };
},
};
}
}
// 1.break 提前退出 for-of 迭代
let counter1 = new Counter(5);
for (let i of counter1) {
if (i > 2) {
break;
}
console.log(i); // 1 2 Exiting early
}
// 2.continue 提前退出 for-of 迭代
let counter2 = new Counter(5);
for (let i of counter2) {
if (i > 2) {
continue;
}
console.log(i); // 1 2
}
// 3.throw 提前退出 for-of 迭代
let counter3 = new Counter(5);
try {
for (let i of counter3) {
if (i > 2) {
throw "err"; // 1 2 Exiting early
}
console.log(i);
}
} catch (e) {
console.log(e); // err
}
// 4.return 提前退出 for-of 迭代
let counter4 = new Counter(5);
for (let i of counter4) {
if (i > 2) {
return;
}
console.log(i); // 1 2 Exiting early
}
如果迭代器没有关闭,则还可以继续从上次离开的地方继续迭代。比如,数组的迭代器就是不能关 闭的:
let a = [1, 2, 3, 4, 5];
let iter = a[Symbol.iterator]();
for (let i of iter) {
console.log(i); // 1 2 3
if (i > 2) {
break;
}
}
//上次迭代器没有关闭 继续从上次离开的地方继续迭代
for (let i of iter) {
console.log(i); // 4 5
}
因为 return()方法是可选的,所以并非所有迭代器都是可关闭的。要知道某个迭代器是否可关闭,可以测试这个迭代器实例的 return 属性是不是函数对象。
不过,仅仅给一个不可关闭的迭代器增加这个方法并不能让它变成可关闭的。这是因为调用 return()不会强制迭代器进入关闭状态。但 return()方法还是会被调用。
let a = [1, 2, 3, 4, 5];
let iter = a[Symbol.iterator]();
//添加 return 方法
iter.return = function () {
console.log("Exiting early");
return {
done: true,
};
};
for (let i of iter) {
console.log(i); //1 2 3 Exiting early
if (i > 2) {
break;
}
}
for (let i of iter) {
console.log(i); // 4 5
}
重要概念总结
- 迭代器(iterator):一个实现了 Iterator 接口的结构,该结构具有 next() 方法,该方法返回一个
IteratorResult对象 - 迭代器工厂函数(iterator creator):一个返回迭代器的函数
- 可迭代对象(iterable):ES6 规定,如果一个实现了 Iterable 接口的结构,该结构具有一个特殊属性
Symbol.iterator,并且属性值是一个迭代器工厂函数
参考博客
- 《JavaScript 高级程序设计》(第 4 版)
- 阮一峰 - ES6 教程
结语
这是我目前所了解的知识面中最好的解答,当然也有可能存在一定的误区。
所以如果对本文存在疑惑,可以在评论区留言,我会及时回复的,欢迎大家指出文中的错误观点。
最后码字不易,觉得有帮助的朋友点赞、收藏、关注走一波。