Menu

JavaScript 迭代器与生成器:function*、yield、Symbol.iterator

JavaScript 的迭代器协议是怎样工作的,如何让自己的对象变成可迭代对象,以及生成器函数如何让这一切变得轻松。

本页包含可运行的编辑器。编辑、运行,立即查看输出。

迭代器协议

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 结果,而不必预先把所有内容都生成出来。如果你手上已经是一个固定的小数组,直接用数组就好。

Coddy 编程语言插图

和 Coddy 一起学编程

开始