K 的一隅

JavaScript JavaScript 核心机制

for...of 到底在向谁要下一个值:可迭代对象与迭代器协议

从早餐店取号机开始,拆开 iterable、iterator、next、for...of 与提前退出时的资源清理。

11 分钟阅读 更新于 2026-07-23
本文目录

早餐店的取号机不需要在开门时打印当天所有号码。有人按一次,它给出一张;没人来,后面的号码就不产生。

这和数组有点不一样。数组已经把值装在下标里,取号机则更像一个过程:你每问一次“下一个呢”,它才计算下一项。JavaScript 用可迭代协议和迭代器协议描述这次对话。

取号机不需要提前打印一整卷号码

假设店里每天从 101 号开始,最多发到 103 号。我们当然能先建一个数组:

js
const numbers = [101, 102, 103]

但如果序列很长、由规则计算,或者根本不知道会取多少项,提前创建全部值就很浪费。更自然的接口是:

js
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() 完全可以同步返回。

先猜结果:第二次循环为什么是空的

下面的对象把自己同时当成数据来源和唯一进度:

js
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:

js
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:

js
const iterator = [10, 20][Symbol.iterator]()

console.log(iterator[Symbol.iterator]() === iterator) // true

这是常见设计,不是“所有 iterator 必须如此”的规则。

for...of 每一步实际做了什么

range 使用 for...of

js
for (const number of range) {
  console.log(number)
}

可以用一段近似伪代码理解:

js
const iterator = range[Symbol.iterator]()

while (true) {
  const result = iterator.next()
  if (result.done) break

  const number = result.value
  console.log(number)
}

真实规范还会检查返回值是否为对象,并处理 abrupt completion 与迭代器关闭,不能把伪代码当完整实现。但核心对话很清楚:循环不需要知道数据存在哪里,只反复索取下一项。

这让数组、字符串、SetMap 和自定义序列能使用统一语法。Map 默认迭代产生 [key, value];字符串按 Unicode code point 相关规则迭代,通常比按 UTF-16 下标拆字符更符合人的直觉。

展开语法和解构也在敲同一扇门

for...of 不是唯一消费者。数组展开会读取 iterable:

js
const values = [...range]

数组解构也会索取需要的项:

js
const [first, second] = range

函数调用的展开参数同样依赖可迭代协议:

js
Math.max(...range)

这解释了为什么普通类数组对象不一定能展开。它有 01length,只是外形像数组;没有 Symbol.iterator 就不属于 iterable。相反,一个没有 length 的对象也完全可以通过协议参与展开。

Array.from 能处理 iterable,也能处理一部分带长度的类数组对象,因此它的适用范围与展开语法并不完全相同。

for...in 问的是属性名,不是下一个值

for...infor...of 只差两个字母,却在解决不同问题:

js
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() 为什么重要

循环可能因为 breakreturn 或异常提前结束。生产者也许持有文件句柄、数据库游标或其他资源,需要收到“消费者不再要了”的通知。迭代器可以提供 return()

js
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(),因为生产者已经自行宣布完成。

不是所有消费方式都提供相同的提前关闭保证。自己手写 whilenext() 时,清理责任也由自己承担。

独立迭代器和共享状态的陷阱

把状态放在 [Symbol.iterator]() 内部,每次遍历就会获得独立进度:

js
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() 必须返回对象,不能只返回一个数字:

js
const broken = {
  [Symbol.iterator]() {
    return {
      next() {
        return 1
      },
    }
  },
}

console.log([...broken]) // TypeError

引擎无法知道 1value,还是表示还剩一项。显式的 { value, done } 消除了这种歧义。

next() 自己抛出异常,消费操作会立即失败。不要想当然地认为引擎一定会再调用同一个迭代器的 return():迭代器关闭发生在哪些 abrupt completion 路径上有明确规则,而从 next() 取值本身就失败时,资源生产者最好也能在内部保护资源。

真正持有文件或连接时,不能只把全部希望押在消费者正确 break 上。生产者自身的异常路径、消费者的关闭路径和资源 API 的幂等清理都要设计。

内置对象为什么表现不完全一样

数组迭代器并不是创建时复制一份数组。遍历期间修改数组,后续结果可能观察到变化:

js
const list = ['A', 'B']

for (const item of list) {
  console.log(item)
  if (item === 'A') list.push('C')
}

// A、B、C

这不等于所有 iterable 都是“实时”的。SetMap 和字符串各有自己的迭代语义,自定义 iterable 更可以选择快照或动态读取。调用者若在遍历中修改数据源,应先确认该类型的规定,而不是从一个数组例子外推。

Map 的默认 iterator 产生键值对,另外还提供 keys()values()entries()

js
const prices = new Map([
  ['水', 3],
  ['面包', 8],
])

for (const [name, price] of prices) {
  console.log(name, price)
}

这里数组解构又消费了每一项内部的二元数组。一次 for...of 里组合了两层迭代协议,这正是统一协议带来的复用。

惰性序列可以很长,甚至没有尽头

迭代器最大的价值之一,是不必提前把序列放进内存。无限奇数序列完全可以合法存在:

js
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 相关边界产出值。对基本多文种平面之外的字符,两者结果可能不同:

js
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 时要负责正常收尾

有时我们只想拿一项:

js
const iterator = source[Symbol.iterator]()
const first = iterator.next()

source 持有资源,拿完以后直接丢掉 iterator 可能没有触发 return()for...of 的提前退出会执行迭代器关闭,手工协议调用则必须自己用 try/finally 收尾:

js
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 环境正在提供迭代器辅助方法,但使用前仍要检查目标环境,不能在基础协议文章里假设所有浏览器已经支持。

试一试:给倒计时对象加上迭代能力

实现一个可重复遍历的倒计时:

js
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 的生成器正是这台由语言帮助维护状态的机器。