1. 这不是语法糖,是 JavaScript 的底层呼吸方式

你写过 for...of 循环,也用过 Array.from() [...arr] 展开运算符,甚至在 Map Set 上直接调用 .keys() .values() 方法——但有没有哪一刻,你盯着控制台里 arr[Symbol.iterator]() 返回的那个对象发过呆?它长着 { next() { ... } } 的样子,像一扇半开的门,门后却没人告诉你里面到底住着谁。这不是高级技巧,而是 JavaScript 运行时每天都在默默执行的底层协议: 可迭代(Iterable)与迭代器(Iterator) 。它不 flashy,不常出现在面试八股文前三页,但它决定了 for...of 为什么比 for 循环更安全、 yield* 为何能无缝委托、 async/await 如何与同步迭代逻辑共存,甚至影响着你写的每一段 Array.prototype.filter().map().reduce() 链式调用的内存行为。

核心关键词 JavaScript、Iterables、Iterators、for...of、Symbol.iterator ,它们不是孤立概念,而是一套协同工作的契约体系。 Symbol.iterator 是协议入口,一个不可枚举、不可配置的 symbol 属性; Iterable 是遵守该协议的对象(如数组、字符串、Map、Set、自定义类); Iterator 是执行该协议的“执行体”,一个拥有 next() 方法并返回 { value, done } 形状对象的状态机。这个设计让语言具备了统一的消费数据流的能力——无论数据来自内存数组、生成器函数、异步响应流,还是你手写的无限斐波那契序列,只要它“可迭代”,就能被同一套语法消费。它解决的根本问题,是 数据生产与数据消费的解耦 :生产者决定如何生成数据(按需、延迟、异步),消费者只关心“下一个是什么”,无需知道背后是数组索引、链表指针,还是网络请求回调。适合谁?前端工程师调试 React 列表渲染性能瓶颈时,需要理解 key 与迭代顺序的关系;Node.js 开发者处理大文件流时,得靠自定义迭代器避免内存爆炸;TypeScript 用户想为自定义集合添加类型推导支持,必须实现 Symbol.iterator 接口;甚至写脚本自动化处理 CSV 行数据的运维同学,用 for...of 配合 ReadableStream 迭代器,比 readline 模块更轻量可控。这不是“学了有用”,而是“不用就踩坑”——比如你写 for...of 遍历一个普通对象 {a:1,b:2} ,它会直接报错 TypeError: obj is not iterable ,因为对象默认不实现 Symbol.iterator ,而很多人第一反应是“是不是我少写了什么”,其实答案是“对象本来就不该被这样遍历”,这是协议设计的明确边界。

2. 协议拆解:从 for...of next() 的完整调用链

2.1 for...of 不是魔法,是编译器的自动展开

很多人以为 for...of 是个独立语法,其实它是 JavaScript 引擎对迭代协议的语法糖封装。当你写下:

const arr = [1, 2, 3];
for (const item of arr) {
  console.log(item);
}

引擎内部实际执行的是以下等价逻辑(简化版):

const iterator = arr[Symbol.iterator](); // 获取迭代器对象
let result = iterator.next(); // 第一次调用 next()
while (!result.done) {
  const item = result.value; // 提取当前值
  console.log(item);
  result = iterator.next(); // 获取下一个值
}

关键点在于: for...of 自动完成了三件事—— 获取迭代器、循环调用 next() 、检查 done 标志 。这解释了为什么 for...of 能安全遍历空数组( iterator.next().done === true 立即退出),而传统 for (let i=0; i<arr.length; i++) 需要额外判断长度。更深层的意义在于: for...of 的行为完全由 Symbol.iterator 方法返回的对象决定,而非数组本身。你可以覆盖数组的 Symbol.iterator ,让它每次返回 undefined ,那么 for...of 就会变成死循环( done 永远为 false ),这在调试第三方库的迭代行为时是重要线索。

2.2 Symbol.iterator :协议的唯一入口与可重写性

Symbol.iterator 是 ES6 引入的全局 symbol,其值是一个函数,调用后必须返回一个符合迭代器协议的对象。它的设计有三个硬性要求:

  1. 不可枚举性 Object.keys(arr) 不会包含 Symbol.iterator for...in 也不会遍历到它,保证协议属性不污染常规对象遍历。
  2. 不可配置性 Object.getOwnPropertyDescriptor(arr, Symbol.iterator).configurable === false ,防止被意外删除或重定义(除非用 Object.defineProperty 强制覆盖,但不推荐)。
  3. 可重写性 :虽然内置对象的 Symbol.iterator 默认不可写,但你可以为自定义对象或类显式定义它,从而赋予其迭代能力。

例如,为一个普通对象添加迭代能力:

const obj = { a: 1, b: 2, c: 3 };
obj[Symbol.iterator] = function* () {
  for (const key in this) {
    if (this.hasOwnProperty(key)) {
      yield [key, this[key]];
    }
  }
};
// 现在可以 for...of 遍历了
for (const [key, value] of obj) {
  console.log(`${key}: ${value}`); // a: 1, b: 2, c: 3
}

这里用了生成器函数 function* 作为 Symbol.iterator 的实现,因为它天然返回一个迭代器对象。但注意: Symbol.iterator 本身可以是任何函数,只要它返回的对象有 next() 方法。生成器只是最便捷的实现方式,不是强制要求。

2.3 迭代器对象:状态机的本质与 next() 的契约

迭代器对象的核心是 next() 方法,它必须返回一个严格格式的对象: { value: any, done: boolean } value 是当前产出的值, done 是布尔标志,表示迭代是否结束。这个设计看似简单,却蕴含两个关键约束:

  • done: true value 可选但非空 :当 done true value 可以存在(如 return 语句的返回值),也可以是 undefined 。但一旦 done true ,后续所有 next() 调用都必须返回 { value: undefined, done: true } ,这是协议强制要求,确保消费者能可靠终止循环。
  • 状态不可逆 :迭代器是单向、不可重置的状态机。调用 next() 后,内部状态(如数组索引、生成器暂停点)向前推进,无法“倒带”。如果需要重复遍历,必须重新调用 Symbol.iterator() 获取新迭代器。

实测验证这个状态特性:

const arr = [10, 20, 30];
const it = arr[Symbol.iterator]();
console.log(it.next()); // { value: 10, done: false }
console.log(it.next()); // { value: 20, done: false }
console.log(it.next()); // { value: 30, done: false }
console.log(it.next()); // { value: undefined, done: true }
console.log(it.next()); // { value: undefined, done: true } —— 永远如此

这个“单向性”直接影响实际开发:比如你写一个 getItems() 函数返回一个迭代器,调用者只能消费一次。若需多次使用,函数应返回一个可反复调用的 Symbol.iterator 方法,而非迭代器实例本身。

3. 实操落地:从内置对象到自定义迭代器的完整实现

3.1 内置可迭代对象的差异解析与陷阱排查

JavaScript 中并非所有类数组对象都是可迭代的。常见内置对象的迭代能力如下表所示:

对象类型 是否可迭代 Symbol.iterator 实现特点 常见陷阱
Array 返回数组索引迭代器, value 为元素值 Array.prototype.slice() 返回新数组,仍可迭代;但 Array.prototype.entries() 返回的是 [index, value] 数组,需解构使用
String 按 Unicode 码点迭代(非字节),支持 emoji 和代理对 "👨‍💻".length === 2 ,但 for...of 只迭代 1 次,因 👨‍💻 是单个码点(组合字符)
Map 默认迭代 [key, value] 对, keys() / values() / entries() 提供不同视图 直接 for...of map 等价于 map.entries() ,若只想遍历键,需 for...of map.keys()
Set 迭代元素本身,无序 Set 迭代顺序与插入顺序一致,但 WeakMap / WeakSet 不可迭代(无 Symbol.iterator
TypedArray (Uint8Array等) 按数值迭代, value 为数字 Array.from(typedArr) 会创建普通数组,失去类型优势;应直接 for...of typedArr
arguments 对象 ❌(ES5)✅(ES6+) ES6+ 中 arguments 是可迭代的类数组对象 在箭头函数中无 arguments ,需用剩余参数 ...args 替代
普通 Object 默认无 Symbol.iterator 属性 错误示例: for (const k of {a:1}) {} 报错;正确做法: for (const k of Object.keys(obj)) {}

一个典型陷阱是 NodeList (如 document.querySelectorAll() 返回值)。它在现代浏览器中是可迭代的,但旧版 IE 不支持。兼容写法需检测:

const nodes = document.querySelectorAll('div');
if (typeof nodes[Symbol.iterator] === 'function') {
  for (const node of nodes) {
    node.classList.add('processed');
  }
} else {
  // 降级:Array.from 或传统 for 循环
  Array.from(nodes).forEach(node => node.classList.add('processed'));
}

3.2 手写迭代器:从零构建一个可中断的范围迭代器

不依赖生成器,手动实现一个 RangeIterator 类,用于生成指定范围的整数序列。这能彻底理解迭代器状态管理:

class RangeIterator {
  constructor(start, end) {
    this.current = start;
    this.end = end;
  }

  next() {
    if (this.current < this.end) {
      return { value: this.current++, done: false };
    } else {
      return { value: undefined, done: true };
    }
  }
}

// 使 Range 类可迭代
class Range {
  constructor(start, end) {
    this.start = start;
    this.end = end;
  }

  [Symbol.iterator]() {
    return new RangeIterator(this.start, this.end);
  }
}

// 使用
const range = new Range(1, 5);
for (const num of range) {
  console.log(num); // 1, 2, 3, 4
}

关键细节解析:

  • RangeIterator next() 方法维护 this.current 状态,每次调用递增并检查边界。
  • Range 类的 [Symbol.iterator]() 方法每次返回 新实例 ,确保多次遍历互不干扰(如 for...of range 调用两次,会得到两个独立的 RangeIterator )。
  • 若将 next() 逻辑直接写在 Range 类中(如 Range.prototype.next = function(){...} ),则无法满足迭代器协议,因为 for...of 要求 Symbol.iterator 返回一个 对象 ,而非 this 本身。

3.3 生成器函数:最优雅的迭代器工厂

生成器函数 function* 是创建迭代器的语法糖,它自动处理状态暂停与恢复。对比手写迭代器,生成器代码更简洁、不易出错:

// 等价于上面的 Range 类,但更简洁
function* rangeGenerator(start, end) {
  for (let i = start; i < end; i++) {
    yield i; // 暂停并产出 i,下次 next() 从 yield 后继续
  }
}

// 使用
for (const num of rangeGenerator(1, 5)) {
  console.log(num); // 1, 2, 3, 4
}

生成器的威力在于 yield* 委托语法,可无缝组合多个迭代器:

function* fibonacci() {
  let [a, b] = [0, 1];
  while (true) {
    yield a;
    [a, b] = [b, a + b];
  }
}

function* limitedFibonacci(limit) {
  const fib = fibonacci();
  for (let i = 0; i < limit; i++) {
    yield* fib; // 委托 fib 迭代器,产出其所有值
  }
}

// 产出前 5 个斐波那契数
console.log([...limitedFibonacci(5)]); // [0, 1, 1, 2, 3]

提示: yield* 不是简单的 yield 多次,而是将控制权完全交给被委托的迭代器,直到其 done: true ,再回到当前生成器。这使得复杂数据流(如树的深度优先遍历)的实现变得极其清晰。

4. 高阶应用与避坑指南:真实项目中的经验总结

4.1 异步迭代器:处理 AsyncIterable 的正确姿势

ES2018 引入了异步迭代器,用于处理异步数据流(如 ReadableStream 、数据库游标)。它与同步迭代器的关键区别在于: Symbol.asyncIterator 返回一个 next() 返回 Promise 的对象,且需用 for await...of 消费。

常见错误是混淆 for...of for await...of

// 错误:用同步 for...of 遍历异步可迭代对象
// const stream = fetch('/data').then(r => r.body.getReader());
// for (const chunk of stream) { ... } // TypeError

// 正确:用 for await...of
async function readStream() {
  const response = await fetch('/data');
  const reader = response.body.getReader();
  while (true) {
    const { done, value } = await reader.read(); // 注意 await
    if (done) break;
    console.log(value);
  }
}

for await...of 的等价展开更复杂,涉及 await Promise 链,但核心原则不变: 异步迭代器的 next() 返回 Promise<{ value, done }> , 消费者必须 await 其结果 。在 Node.js 中处理大文件时, fs.createReadStream() 返回的流是 AsyncIterable ,用 for await...of 可逐块读取,内存占用恒定,远优于 fs.readFileSync() 一次性加载。

4.2 性能敏感场景:迭代器与内存的隐式关系

迭代器协议本身不分配额外内存,但具体实现会影响性能。一个经典案例是 Array.prototype.map() 的返回值:它总是创建一个新数组,即使你只需要第一个匹配项。而用迭代器可实现“短路”:

// 传统 map + find:创建完整新数组,再找第一个
const result1 = arr.map(x => x * 2).find(x => x > 10);

// 迭代器方式:按需计算,找到即停
function* doubleAndFilter(iterable) {
  for (const x of iterable) {
    const doubled = x * 2;
    if (doubled > 10) yield doubled;
  }
}
const result2 = doubleAndFilter(arr).next().value; // 只计算到第一个满足条件的元素

另一个场景是 Array.from() 。它接受一个可迭代对象,并将其转换为数组。但如果源迭代器是无限的(如 function* infinite() { while(true) yield Date.now(); } ), Array.from(infinite()) 会立即导致内存溢出。正确做法是先用 take(n) 限制数量:

function* take(iterable, n) {
  const it = iterable[Symbol.iterator]();
  for (let i = 0; i < n; i++) {
    const result = it.next();
    if (result.done) break;
    yield result.value;
  }
}
const safeArray = Array.from(take(infinite(), 100));

4.3 TypeScript 类型定义:让迭代器在 IDE 中“活”起来

在 TypeScript 中,为自定义迭代器添加类型提示能极大提升开发体验。关键接口是 Iterable<T> Iterator<T>

interface RangeOptions {
  start: number;
  end: number;
}

class Range implements Iterable<number> {
  constructor(private options: RangeOptions) {}

  *[Symbol.iterator](): Iterator<number> {
    for (let i = this.options.start; i < this.options.end; i++) {
      yield i;
    }
  }
}

// 现在在 VS Code 中,new Range(1,5) 的类型是 Iterable<number>
// for...of 时,item 的类型自动推导为 number
for (const num of new Range({ start: 1, end: 5 })) {
  num.toFixed(2); // IDE 知道 num 是 number,提供 .toFixed() 方法提示
}

若需更精确控制(如 next() 的返回类型),可实现 IteratorResult<T>

class CustomIterator implements Iterator<string> {
  private index = 0;
  private data = ['a', 'b', 'c'];

  next(): IteratorResult<string> {
    if (this.index < this.data.length) {
      return { value: this.data[this.index++], done: false };
    } else {
      return { value: undefined, done: true };
    }
  }
}

4.4 常见问题速查表与独家避坑技巧

问题现象 根本原因 解决方案 我的经验
TypeError: xxx is not iterable 对象未实现 Symbol.iterator 方法 检查对象类型(普通对象?DOM NodeList?),用 Array.from() Object.keys() 转换 我在调试 Vue 组件时遇到此错,发现是 props 传入了一个 plain object,误以为它像 Map 一样可迭代,实际应 Object.entries(props)
for...of 遍历 Map 得到 [key, value] 数组,但想单独遍历 key Map 默认迭代 entries() 视图 显式调用 map.keys() map.values() 新人常写 for (const [k,v] of map) {} 却忘了 [k,v] 是解构,若只需 k ,直接 for (const k of map.keys()) {} 更清晰
自定义迭代器在 for...of 中无限循环 next() 方法未正确设置 done: true next() 中添加边界检查,确保最终返回 { done: true } 我写斐波那契生成器时,忘了加 if (count > limit) break ,导致 yield* 无限委托,CPU 占用 100%
Array.from(iterator) 返回空数组 迭代器已被消费过( done: true 每次调用 Array.from 前,确保传入的是新迭代器实例(即重新调用 Symbol.iterator() 在测试中,我复用了一个 it = arr[Symbol.iterator]() ,第一次 Array.from(it) 正常,第二次为空,花了 20 分钟才意识到迭代器不可重用
for await...of 报错 is not async iterable 对象只有 Symbol.iterator ,没有 Symbol.asyncIterator 确认数据源是否为真正的异步可迭代对象(如 ReadableStream ),或手动包装 在 Node.js 读取文件时,我误将 fs.readFileSync() 的 Buffer 当作 AsyncIterable ,实际应 fs.createReadStream()

注意: Symbol.iterator Symbol.asyncIterator 是两个独立的 symbol,不能互相替代。一个对象可以同时实现两者(如某些数据库驱动),但大多数内置对象只实现其一。

5. 实战扩展:用迭代器重构一个真实的数据处理流程

假设你正在开发一个日志分析工具,需要从一个超大文本文件(GB 级别)中提取所有包含 ERROR 关键字的行,并统计每个错误类型的出现次数。传统做法是 fs.readFileSync() 加载整个文件到内存,再 split('\n') ,这在大文件下必然 OOM。用迭代器可优雅解决:

5.1 构建文件行迭代器

import { createReadStream } from 'fs';
import { createInterface } from 'readline';

// 创建一个可迭代的行读取器
function createLineIterator(filePath) {
  const fileStream = createReadStream(filePath);
  const rl = createInterface({
    input: fileStream,
    crlfDelay: Infinity
  });

  // 返回一个可迭代对象
  return {
    [Symbol.iterator]: function* () {
      // 注意:这里不能直接 yield* rl,因为 rl 不是同步迭代器
      // 需要手动管理异步流
      const lines = [];
      rl.on('line', line => lines.push(line));
      rl.on('close', () => {
        // 此处无法在生成器中等待 close 事件,需改用异步迭代器
      });
    }
  };
}

上述同步方式行不通,因为 readline 是异步事件驱动。正确做法是使用 for await...of 配合 ReadableStream

import { open } from 'fs/promises';

async function* lineIterator(filePath) {
  const file = await open(filePath, 'r');
  const reader = file.readableWebStream().getReader();
  
  try {
    while (true) {
      const { done, value } = await reader.read();
      if (done) break;
      // value 是 Uint8Array,需转为字符串
      const text = new TextDecoder().decode(value);
      // 按行分割(处理跨 chunk 边界的行)
      yield* text.split('\n');
    }
  } finally {
    reader.releaseLock();
    await file.close();
  }
}

// 使用:按需读取,内存恒定
async function analyzeErrors(filePath) {
  const errorCounts = new Map();
  
  for await (const line of lineIterator(filePath)) {
    if (line.includes('ERROR')) {
      const errorType = line.match(/ERROR:\s*(\w+)/)?.[1] || 'UNKNOWN';
      errorCounts.set(errorType, (errorCounts.get(errorType) || 0) + 1);
    }
  }
  
  return Object.fromEntries(errorCounts);
}

// 调用
analyzeErrors('./app.log').then(console.log);

5.2 迭代器链式处理:添加过滤与转换

将上述逻辑拆分为可复用的迭代器函数,实现 Unix 风格的管道操作:

// 过滤迭代器:只产出满足条件的项
async function* filterAsync(iterable, predicate) {
  for await (const item of iterable) {
    if (await predicate(item)) yield item;
  }
}

// 映射迭代器:转换每一项
async function* mapAsync(iterable, mapper) {
  for await (const item of iterable) {
    yield await mapper(item);
  }
}

// 组合使用
async function analyzeErrorsV2(filePath) {
  const lines = lineIterator(filePath);
  const errorLines = filterAsync(lines, line => line.includes('ERROR'));
  const errorTypes = mapAsync(errorLines, line => 
    line.match(/ERROR:\s*(\w+)/)?.[1] || 'UNKNOWN'
  );

  const counts = new Map();
  for await (const type of errorTypes) {
    counts.set(type, (counts.get(type) || 0) + 1);
  }
  return Object.fromEntries(counts);
}

这种写法的优势在于: 每个步骤都是独立、可测试、可复用的迭代器 。你可以单独测试 filterAsync 是否正确过滤,而不必启动整个文件读取流程。在前端,同样的模式可用于处理 fetch 流、WebSocket 消息流,或 Canvas 帧渲染循环。

6. 我的实际体会:迭代器是 JavaScript 的“呼吸节奏”

写完这个主题,我翻出自己三年前的一个线上 bug:一个后台管理系统的用户列表页,在 Chrome 中偶尔白屏,控制台报 JavaScript heap out of memory 。排查发现,是某个 computed 属性里写了 users.map(u => u.name).filter(n => n.startsWith('A')).slice(0,10) ,而 users 数组有 50 万条记录。 map() 创建了 50 万个新字符串, filter() 又创建了子数组,内存瞬间飙高。修复方案就是用迭代器重写:

function* userNames(users) {
  for (const u of users) yield u.name;
}

function* namesStartingWithA(names, prefix = 'A') {
  for (const n of names) {
    if (n.startsWith(prefix)) yield n;
  }
}

// 最终只生成前 10 个,内存占用几乎为常量
const top10 = [...namesStartingWithA(userNames(users), 'A')].slice(0, 10);

迭代器不是炫技,而是让代码回归“按需”的本质。它教会我一个问题: 当我在写 for...of 时,我真正想要的,是“下一个值”,而不是“整个数据集” 。这个认知转变,让我在设计 API 时更倾向于返回 Iterable 而非 Array ,在处理大数据时本能地寻找流式方案,甚至在写单元测试时,用 jest.mock() 模拟一个迭代器来隔离外部依赖。它不改变 JavaScript 的语法,却重塑了你思考数据流动的方式——就像呼吸,平时感觉不到,但一旦停止,一切都会窒息。

更多推荐