迭代器协议
JavaScript 的很多特性,比如 for...of、展开运算符(...)、解构、Array.from、Promise.all,都共用同一个底层机制:迭代器协议。一旦理解了它,你会发现这些特性都只是同一个思路的不同变体。
迭代器就是任何带有 next() 方法、并且该方法返回 { value, done } 的对象:
反复调用 next()。每次调用都会返回下一个值和一个 done 标志。当 done 为 true 时,序列就结束了。这就是整个协议:一个方法,外加一个布尔值。
可迭代对象与迭代器
还有第二个相关的概念。可迭代对象是任何知道如何产生迭代器的东西。它通过一个存放在特殊键下的方法来做到这一点,这个键就是 Symbol.iterator。
数组是可迭代对象。调用 numbers[Symbol.iterator]() 会返回一个全新的迭代器。字符串、Map、Set 和 arguments 也都是可迭代对象,这就是 for...of 能用于它们所有的原因。
这种区分很重要:可迭代对象是集合,迭代器是游标。你可以向一个可迭代对象要任意多个相互独立的游标。
为什么 for...of 能工作
for...of 只是迭代器协议的语法糖。在底层,它先调用 Symbol.iterator,然后不断调用 next(),直到 done 为 true:
展开运算符和解构做的是同样的事:它们沿着迭代器一直走到结束:
你构建的任何实现了 Symbol.iterator 的对象,都能免费享用所有这些特性。
编写自定义可迭代对象
我们来做一个 range 对象,它产出从 start 到 end 的数字:
有几点值得注意:
[Symbol.iterator]()用的是计算属性名形式的方法名。键就是这个 symbol 本身,而不是字符串"Symbol.iterator"。- 每次调用
[Symbol.iterator]()都会返回一个全新的迭代器,它有自己的current。正因如此,你可以对range遍历两次,而不会把它“用完”。 - 返回的迭代器只需要有
next(),仅此而已。
这样可行,但很啰嗦。有一种好得多的方法。
生成器登场
生成器函数用 function* 声明(注意那个星号)。它不会一口气运行到底,而是可以在 yield 表达式处暂停,之后再恢复。调用生成器函数并不会执行函数体,而是交回一个生成器对象,它既是迭代器,也是可迭代对象:
每次 next() 都会运行函数体,直到遇到一个 yield,然后暂停并返回 { value, done: false }。当函数执行完毕时,你会得到 { value: undefined, done: true }。
而且因为生成器是可迭代对象,上一节提到的所有特性它都能用:
用生成器重写 range
把上面那个啰嗦的版本和这个对比一下:
就这么简单。[Symbol.iterator] 前面的 * 让它成为一个生成器方法。yield i 取代了整个手写的迭代器对象。没有 next,没有 done,也没有差一错误的风险,只是一个用 yield 代替 push 的普通循环。
这就是生成器存在的原因。它们把“编写一个迭代器”变成了“编写一个会 yield 的函数”。
yield 与 return
yield 是暂停,return 是结束。你可以 yield 任意多次,生成器会从暂停的地方继续执行:
生成器中的 return 会在结束它的那次调用中表现为 { value: "done", done: true }。for...of 和展开运算符会忽略这个返回值,它们只消费 done 为 false 的元素。所以不要用 return value 把最后一个元素偷偷塞进循环里,它会被跳过。
惰性序列与无限序列
生成器按需产出值,一次一个。这意味着你可以表示那些无法用数组表示的序列:
循环写的就是 while (true),可程序仍然会结束,因为只有在有人请求下一个值时,生成器才会向前推进。你可以取前 N 个元素然后停下,其余部分永远不会运行:
take 本身也是一个生成器,它包装了另一个生成器。像这样组合生成器是它们的一大魅力:一个个小部件,各自只做一件事。
用 yield* 委托
如果一个生成器需要把另一个可迭代对象中的所有内容都 yield 出来,可以用 yield* 委托给它:
yield* 适用于任何可迭代对象,包括数组、集合和其他生成器,它会把每个元素逐个转发出去。它相当于迭代器版本的展开运算符。
异步生成器简介
用 async function* 声明的生成器可以 yield 需要花时间才能得到的值,适合用来流式读取 API 数据或分块读取文件。用 for await...of 来消费它:
async function* paginate(url) {
let next = url;
while (next) {
const res = await fetch(next);
const page = await res.json();
for (const item of page.items) yield item;
next = page.nextUrl;
}
}
for await (const item of paginate("/api/users")) {
console.log(item);
}
这段代码在这里无法运行(它需要一个真实的接口),但值得知道有这样一种形式。一旦理解了普通生成器,异步生成器就是同一个思路,只是加上了 await。
什么时候该用生成器
以下情况适合用生成器:
- 序列是无限的,或者可能是无限的:ID、时间戳、重试延迟。
- 生成所有值的代价很高,而使用方可能会提前停止。
- 你要在自定义对象上实现
Symbol.iterator。这几乎总是比手写{ next() }对象更短。 - 你想组合流式转换(
take、filter、map),又不想构建中间数组。
如果数据已经在内存中而且量不大,就用普通数组。生成器并不是没有代价的:暂停和恢复函数的机制有开销,而且经过生成器代码的堆栈跟踪可能更难阅读。
下一步:Symbol
Symbol.iterator 是大多数人遇到的第一个 symbol,但远不是唯一的一个。Symbol 是一种原始类型,正是为这类扩展点而设计的:它是独一无二的键,让语言本身和你的代码都能挂接到对象上,而不会与普通的属性名冲突。这就是下一页的内容。
常见问题
JavaScript 中可迭代对象和迭代器有什么区别?
可迭代对象是任何带有 Symbol.iterator 方法、并且该方法返回一个迭代器的对象。迭代器则是真正产出值的对象:它有一个 next() 方法,返回 { value, done }。数组、字符串、Map 和 Set 都是可迭代对象;调用它们的 Symbol.iterator 方法,就能得到一个可以逐步遍历的迭代器。
JavaScript 中的生成器函数是什么?
用 function* 声明、通过 yield 惰性产出值的函数。调用它并不会运行函数体,而是返回一个生成器对象,它既是迭代器,也是可迭代对象。每次调用 next(),函数会运行到下一个 yield,暂停,并返回 yield 出的值。
生成器中的 yield 和 return 有什么区别?
yield 暂停生成器并交回一个值,但下一次调用 next() 时,函数可以从暂停的地方继续执行。return 则彻底结束生成器:它会设置 done: true,之后不会再产出任何值。你可以 yield 很多次,但有意义的 return 只有一次。
什么时候应该用生成器而不是数组?
当序列是无限的、计算代价高,或者你只需要其中一部分值时。生成器按需一次产出一个元素,所以你可以表示无穷无尽的 ID 流或分页的 API 结果,而不必预先把所有内容都生成出来。如果你手上已经是一个固定的小数组,直接用数组就好。