本文目录
早餐店的取号机不需要在开门时打印当天所有号码。有人按一次,它给出一张;没人来,后面的号码就不产生。
这和数组有点不一样。数组已经把值装在下标里,取号机则更像一个过程:你每问一次“下一个呢”,它才计算下一项。JavaScript 用可迭代协议和迭代器协议描述这次对话。
取号机不需要提前打印一整卷号码
假设店里每天从 101 号开始,最多发到 103 号。我们当然能先建一个数组:
const numbers = [101, 102, 103]但如果序列很长、由规则计算,或者根本不知道会取多少项,提前创建全部值就很浪费。更自然的接口是:
machine.next() // { value: 101, done: false }
machine.next() // { value: 102, done: false }
machine.next() // { value: 103, done: false }
machine.next() // { value: undefined, done: true }调用者控制何时索取,生产者保存当前进度。这里没有事件循环,也不表示异步;next() 完全可以同步返回。
先猜结果:第二次循环为什么是空的
下面的对象把自己同时当成数据来源和唯一进度:
const tickets = {
current: 101,
next() {
if (this.current > 103) {
return { done: true }
}
return { value: this.current++, done: false }
},
[Symbol.iterator]() {
return this
},
}
console.log([...tickets]) // [101, 102, 103]
console.log([...tickets]) // []第二次展开为空,因为第一次已经把同一个 current 推到 104。对象符合协议,却是一次性的。协议不会自动替你重置状态。
如果我们希望每次循环都从头开始,[Symbol.iterator]() 应该创建一个拥有独立状态的新迭代器,而不是永远返回共享的 this。
iterable 和 iterator 是两份协议
可迭代对象,也就是 iterable,回答“怎样开始一次遍历”。它需要有一个以 Symbol.iterator 为键的方法,该方法返回 iterator:
const range = {
from: 1,
to: 3,
[Symbol.iterator]() {
let current = this.from
const end = this.to
return {
next() {
if (current <= end) {
return { value: current++, done: false }
}
return { value: undefined, done: true }
},
}
},
}迭代器协议回答“下一项是什么”。iterator 提供 next(),每次返回一个对象。done 为假表示仍有值,为真表示序列结束。结束结果里的 value 可以存在;省略时就是 undefined。
两份协议可以由同一个对象实现,也可以分开。数组是 iterable;调用 array[Symbol.iterator]() 才得到专门的 iterator。反过来,一个只有 next() 的对象是 iterator,却不一定能直接交给 for...of,因为后者先找 Symbol.iterator。
很多内置迭代器会让自己的 [Symbol.iterator]() 返回自身,因此既是 iterator,也是 iterable iterator:
const iterator = [10, 20][Symbol.iterator]()
console.log(iterator[Symbol.iterator]() === iterator) // true这是常见设计,不是“所有 iterator 必须如此”的规则。
for...of 每一步实际做了什么
对 range 使用 for...of:
for (const number of range) {
console.log(number)
}可以用一段近似伪代码理解:
const iterator = range[Symbol.iterator]()
while (true) {
const result = iterator.next()
if (result.done) break
const number = result.value
console.log(number)
}真实规范还会检查返回值是否为对象,并处理 abrupt completion 与迭代器关闭,不能把伪代码当完整实现。但核心对话很清楚:循环不需要知道数据存在哪里,只反复索取下一项。
这让数组、字符串、Set、Map 和自定义序列能使用统一语法。Map 默认迭代产生 [key, value];字符串按 Unicode code point 相关规则迭代,通常比按 UTF-16 下标拆字符更符合人的直觉。
展开语法和解构也在敲同一扇门
for...of 不是唯一消费者。数组展开会读取 iterable:
const values = [...range]数组解构也会索取需要的项:
const [first, second] = range函数调用的展开参数同样依赖可迭代协议:
Math.max(...range)这解释了为什么普通类数组对象不一定能展开。它有 0、1 和 length,只是外形像数组;没有 Symbol.iterator 就不属于 iterable。相反,一个没有 length 的对象也完全可以通过协议参与展开。
Array.from 能处理 iterable,也能处理一部分带长度的类数组对象,因此它的适用范围与展开语法并不完全相同。
for...in 问的是属性名,不是下一个值
for...in 和 for...of 只差两个字母,却在解决不同问题:
const basket = ['苹果', '梨']
for (const key in basket) {
console.log(key) // "0", "1"
}
for (const value of basket) {
console.log(value) // "苹果", "梨"
}for...in 枚举对象自身及原型链上符合条件的可枚举字符串属性键,不使用迭代器协议,也不适合拿来保证数组值的遍历语义。for...of 消费 iterable 提供的值。
给数组额外添加可枚举属性,for...in 还可能把它列出来;for...of 的数组迭代器不会因此把该属性当成一个数组元素。选择语法前先问:我要的是对象属性键,还是某种序列提供的值?
提前离场时,return() 为什么重要
循环可能因为 break、return 或异常提前结束。生产者也许持有文件句柄、数据库游标或其他资源,需要收到“消费者不再要了”的通知。迭代器可以提供 return()。
const source = {
[Symbol.iterator]() {
let current = 1
return {
next() {
return { value: current++, done: false }
},
return() {
console.log('关闭取号窗口')
return { done: true }
},
}
},
}
for (const number of source) {
console.log(number)
if (number === 2) break
}循环提前退出时会执行迭代器关闭流程,并调用可用的 return()。它必须返回对象,否则还会产生类型错误。正常收到 done: true 而结束时,通常不需要额外调用 return(),因为生产者已经自行宣布完成。
不是所有消费方式都提供相同的提前关闭保证。自己手写 while 调 next() 时,清理责任也由自己承担。
独立迭代器和共享状态的陷阱
把状态放在 [Symbol.iterator]() 内部,每次遍历就会获得独立进度:
const seats = {
total: 3,
[Symbol.iterator]() {
let seat = 1
return {
next: () => seat <= this.total
? { value: seat++, done: false }
: { value: undefined, done: true },
}
},
}
const a = seats[Symbol.iterator]()
const b = seats[Symbol.iterator]()
console.log(a.next().value) // 1
console.log(a.next().value) // 2
console.log(b.next().value) // 1若把 seat 放到 seats 自身,两次循环就会互相抢进度。有时这正是我们想要的一次性流,有时则是难查的 bug。协议不替你决定可重复性;API 应该在命名和文档里说明。
还有一个细节:上例在创建迭代器时没有复制 total,箭头函数会读取 seats.total 的当前值。遍历期间修改 total 会影响结束条件。若希望创建时就固定边界,应先写 const total = this.total。这也是“迭代器保存哪些状态”需要明确设计的地方。
协议出错时,循环不会替你猜
迭代协议对返回形状有要求。next() 必须返回对象,不能只返回一个数字:
const broken = {
[Symbol.iterator]() {
return {
next() {
return 1
},
}
},
}
console.log([...broken]) // TypeError引擎无法知道 1 是 value,还是表示还剩一项。显式的 { value, done } 消除了这种歧义。
若 next() 自己抛出异常,消费操作会立即失败。不要想当然地认为引擎一定会再调用同一个迭代器的 return():迭代器关闭发生在哪些 abrupt completion 路径上有明确规则,而从 next() 取值本身就失败时,资源生产者最好也能在内部保护资源。
真正持有文件或连接时,不能只把全部希望押在消费者正确 break 上。生产者自身的异常路径、消费者的关闭路径和资源 API 的幂等清理都要设计。
内置对象为什么表现不完全一样
数组迭代器并不是创建时复制一份数组。遍历期间修改数组,后续结果可能观察到变化:
const list = ['A', 'B']
for (const item of list) {
console.log(item)
if (item === 'A') list.push('C')
}
// A、B、C这不等于所有 iterable 都是“实时”的。Set、Map 和字符串各有自己的迭代语义,自定义 iterable 更可以选择快照或动态读取。调用者若在遍历中修改数据源,应先确认该类型的规定,而不是从一个数组例子外推。
Map 的默认 iterator 产生键值对,另外还提供 keys()、values() 和 entries():
const prices = new Map([
['水', 3],
['面包', 8],
])
for (const [name, price] of prices) {
console.log(name, price)
}这里数组解构又消费了每一项内部的二元数组。一次 for...of 里组合了两层迭代协议,这正是统一协议带来的复用。
惰性序列可以很长,甚至没有尽头
迭代器最大的价值之一,是不必提前把序列放进内存。无限奇数序列完全可以合法存在:
const oddNumbers = {
[Symbol.iterator]() {
let current = 1
return {
next() {
const value = current
current += 2
return { value, done: false }
},
}
},
}
for (const number of oddNumbers) {
console.log(number)
if (number >= 9) break
}它永远不会自行返回 done: true,所以消费者必须有停止条件。对这种 iterable 使用 [...oddNumbers] 会无休止地收集值,最终卡住页面或耗尽内存。
惰性不是异步。上面的值按需计算,却仍在每次 next() 中同步完成。如果一项计算本身很重,惰性只能避免计算没被请求的项,不能让已请求的重计算离开主线程。
可以再写一个 take(iterable, count) 适配器,只读取前 N 项。好的适配器还应在读够以后关闭上游 iterator,避免“我不再消费”却没有通知资源生产者。
字符串遍历为什么不等于按下标走
JavaScript 字符串的 length 和下标基于 UTF-16 code unit,而字符串 iterator 按 code point 相关边界产出值。对基本多文种平面之外的字符,两者结果可能不同:
const text = 'A😀B'
console.log(text.length) // 4
console.log([...text]) // ['A', '😀', 'B']表情 😀 由一对 surrogate code units 表示,所以占两个下标位置;iterator 把这对组合成一个值。它仍不等于用户眼中的“一个字形”:带肤色修饰、组合音标或家庭 emoji 可能由多个 code point 构成。若要按 grapheme cluster 切分,应考虑 Intl.Segmenter 等更合适的工具。
这个例子很能说明协议的价值。for...of 不必内置“数组怎样走、字符串怎样走”的分支,它只消费各类型提供的 iterator;具体类型负责定义下一项的语义。
自己调用 next 时要负责正常收尾
有时我们只想拿一项:
const iterator = source[Symbol.iterator]()
const first = iterator.next()若 source 持有资源,拿完以后直接丢掉 iterator 可能没有触发 return()。for...of 的提前退出会执行迭代器关闭,手工协议调用则必须自己用 try/finally 收尾:
const iterator = source[Symbol.iterator]()
try {
console.log(iterator.next().value)
} finally {
iterator.return?.()
}可选链只说明方法可能不存在,不保证清理不会失败。生产代码要决定如何处理清理异常,以及它与原始异常谁优先报告。
手工推进还容易漏掉 done。已经完成的结果里即使带 value,也通常是序列的完成值,不应再按普通元素处理。把协议封装在 for...of 中,价值不只是少写一个循环,还包括让语言统一执行检查和关闭步骤。只有需要精确控制每次推进时,才值得直接操作 iterator。
设计公开 API 时,也应写清它返回的是可重复 iterable 还是一次性 iterator。类型名字相似,但调用者能否保存后重放、能否同时开启两个循环,直接影响缓存与并发使用方式。
容易踩的坑:iterator 不一定也是 iterable
第一种误解是“有 next() 就能 for...of”。for...of 的入口是 Symbol.iterator,纯 iterator 没有它就不能直接消费。
第二种是“iterable 一定能重复遍历”。一次性 iterator 返回自身,也符合 iterable 协议;只是第一次消费后已经结束。
第三种是“done: true 后调用 next() 会抛错”。规范期望已完成迭代器保持完成,常见结果仍是 { done: true };自定义实现也应尽量保持这个稳定行为,而不是重新开始或返回随机结果。
最后,迭代协议本身是拉取模型:消费者主动调用 next()。事件监听那种生产者主动推送值的模式不是同一件事,虽然可以写适配层把两者连接起来。
理解这一点也能解释为什么惰性 iterator 的转换不只是照搬数组方法。数组方法面对已经存在的集合;iterator helper 需要保存上游状态、按需转换并传递关闭。较新的 JavaScript 环境正在提供迭代器辅助方法,但使用前仍要检查目标环境,不能在基础协议文章里假设所有浏览器已经支持。
试一试:给倒计时对象加上迭代能力
实现一个可重复遍历的倒计时:
const countdown = {
from: 3,
// 在这里实现 [Symbol.iterator]()
}
console.log([...countdown]) // [3, 2, 1]
console.log([...countdown]) // [3, 2, 1]要求每次调用 [Symbol.iterator]() 都创建独立的 current,结束后持续返回 done: true。再创建两个 iterator 交替调用,确认它们不会共享进度。
最后给 iterator 加上 return(),在 for...of 中第一项后 break,验证清理日志只在提前退出时出现。
下一步:能不能让函数自己记住号码发到哪了
手写 iterator 最大的麻烦,是自己维护 current、分支和返回对象。逻辑一复杂,next() 很快会变成一台难读的状态机。
如果普通函数可以在中间交出一个值、保存位置,下次调用再从那里继续,代码会自然很多。JavaScript 的生成器正是这台由语言帮助维护状态的机器。