系列文章目录

《JavaScript 基础与进阶笔记》(前期偏基础巩固与常见面试点,后续进入闭包、异步、工程化等进阶主题)



前言

第 30 篇讲了模块化是工程化的基石;本篇进入 语言机制层面 的两个重要概念:Symbol迭代协议

Symbol 不是简单的"第七种基本类型"——它是 JavaScript 在元编程方向上的关键一步。通过 Symbol.iterator,语言把"什么对象可以被 for...of 遍历"这个问题,从语法层面下放到了协议层面。而 Generator 函数,则是实现这个协议最自然的工具。

本篇不打算罗列 API,而是从设计动机出发,讲清楚:为什么需要 Symbol?迭代协议解决了什么问题?Generator 和普通函数的本质区别是什么?


一、Symbol:不只是"不会重复的键"

1.1 诞生的背景

ES5 时代,对象属性名只能是字符串。这意味着:

const obj = {};
obj["name"] = "Alice";
obj["name"] = "Bob"; // 直接覆盖,没有任何警告

在大型项目中,不同模块往同一个对象上挂属性(比如给 DOM 元素加元数据、给第三方库的实例加标记),命名冲突是真实存在的痛点。你没法保证你的 id 属性不会和别人的 id 冲突。

Symbol 就是为了解决这个问题:每个 Symbol 值都是唯一的,即使描述相同。

const s1 = Symbol("key");
const s2 = Symbol("key");
s1 === s2; // false

1.2 Symbol 的本质

Symbol 是 ES6 新增的第七种基本类型(primitive type),和 numberstring 同级。

关键特性:

  • 不可 newSymbol() 是函数调用,不是构造器。new Symbol() 会报错。
  • 隐式转换会报错Symbol("a") + "b" 抛 TypeError,因为 Symbol 不能转为数字或字符串参与运算。
  • 可显式转字符串String(Symbol("a"))"Symbol(a)"Symbol("a").toString()"Symbol(a)"
  • 可转布尔Boolean(Symbol())true,Symbol 不是 falsy 值。
const s = Symbol("id");

// 不能参与运算
// s + 1; // TypeError

// 可以显式转换
console.log(String(s)); // "Symbol(id)"
console.log(!!s);       // true

1.3 Symbol 作为属性键

Symbol 可以作为对象属性名,这是它最核心的用途:

const LOG_LEVEL = Symbol("log_level");
const config = {
  [LOG_LEVEL]: "debug",
  name: "my-app"
};

console.log(config[LOG_LEVEL]); // "debug"

遍历行为的差异——这是面试常考点:

const sym = Symbol("hidden");
const obj = { [sym]: "secret", visible: "hello" };

// 以下三个方法【不】会枚举 Symbol 属性
Object.keys(obj);           // ["visible"]
Object.getOwnPropertyNames(obj); // ["visible"]
for (const key in obj) {}   // 只遍历 "visible"

// 以下方法【会】包含 Symbol 属性
Object.getOwnPropertySymbols(obj); // [Symbol(hidden)]
Reflect.ownKeys(obj);              // ["visible", Symbol(hidden)]

为什么这样设计? 因为 Symbol 属性的一个重要用途就是"半私有"——你不想让它出现在普通的遍历中,避免被意外覆盖或序列化(JSON.stringify 也会忽略 Symbol 键)。

1.4 Symbol.for 与全局注册表

Symbol() 每次都创建全新值。但有些场景需要跨模块共享同一个 Symbol(比如多个文件都想用同一个 key 来标记某个协议)。

// a.js
const key = Symbol.for("shared_key");

// b.js
const key = Symbol.for("shared_key");

// a.js 的 key === b.js 的 key → true

Symbol.for() 会在一个全局注册表中查找,有则返回已有的,没有则创建并注册。这个注册表本质是一个 [[GlobalSymbolRegistry]] 内部槽,以字符串描述为 key。

对比

方式 作用域 适用场景
Symbol("desc") 当前调用 模块内部私有键
Symbol.for("key") 全局注册表 跨模块共享、协议约定
// 判断是否是全局注册的 Symbol
Symbol.keyFor(Symbol.for("test")); // "test"
Symbol.keyFor(Symbol("test"));     // undefined

二、Well-Known Symbols:语言的"后门"

JavaScript 预定义了一批 Well-Known Symbol,它们是挂在 Symbol 构造器上的特殊属性,用来定制对象的底层行为。你可以把它们理解为语言给开发者留的"钩子"。

最常用的几个:

Well-Known Symbol 控制什么
Symbol.iterator 对象是否可迭代、如何迭代
Symbol.asyncIterator 异步迭代协议
Symbol.toPrimitive 类型转换时的行为
Symbol.hasInstance instanceof 的判断逻辑
Symbol.toStringTag Object.prototype.toString() 的标签
Symbol.species 派生对象的构造器

自定义 instanceof

class EvenNumber {
  static [Symbol.hasInstance](num) {
    return typeof num === "number" && num % 2 === 0;
  }
}

4 instanceof EvenNumber;  // true
3 instanceof EvenNumber;  // false

自定义类型标签

class MyCollection {
  get [Symbol.toStringTag]() {
    return "MyCollection";
  }
}

Object.prototype.toString.call(new MyCollection()); // "[object MyCollection]"

这些机制体现了 JavaScript 的元编程能力:语言行为本身可以通过 Symbol 来定制,而不是只能被动接受。


三、迭代协议:从"怎么遍历"到"什么是可遍历的"

3.1 问题背景

ES5 遍历对象的方式很混乱:

  • 数组for 循环、forEachfor...in(会遍历原型链和非数字键)
  • 字符串for 循环按索引
  • 对象for...in + hasOwnProperty 过滤
  • DOM NodeListfor 循环,但不是数组

每种数据结构的遍历方式都不一样,无法写出统一的遍历代码

3.2 迭代协议的核心思想

ES6 引入了迭代协议(Iteration Protocol),核心就一句话:

任何实现了 Symbol.iterator 方法的对象,都是可迭代的(iterable)。

这个方法必须返回一个迭代器对象(iterator),迭代器对象必须有 next() 方法,next() 返回 { value, done }

可迭代对象 (iterable)
  └── [Symbol.iterator]() → 迭代器 (iterator)
                                └── next() → { value, done }
// 手动实现一个可迭代对象
const range = {
  from: 1,
  to: 5,
  [Symbol.iterator]() {
    let current = this.from;
    const last = this.to;
    return {
      next() {
        return current <= last
          ? { value: current++, done: false }
          : { done: true };
      }
    };
  }
};

for (const n of range) {
  console.log(n); // 1, 2, 3, 4, 5
}

关键洞察for...of 循环的本质就是调用 [Symbol.iterator]() 获取迭代器,然后反复调用 next() 直到 donetrue。它不是语法糖,而是协议驱动的。

3.3 原生可迭代对象

JavaScript 中内置的可迭代对象包括:

  • ArrayTypedArray
  • String
  • MapSet
  • arguments
  • NodeList
  • TypedArray
  • Generator 对象(后面会讲)
const arr = [10, 20, 30];
const iter = arr[Symbol.iterator]();

iter.next(); // { value: 10, done: false }
iter.next(); // { value: 20, done: false }
iter.next(); // { value: 30, done: false }
iter.next(); // { value: undefined, done: true }

注意:普通对象默认不是可迭代的!

const obj = { a: 1, b: 2 };
// for (const v of obj) {} // TypeError: obj is not iterable

这是有意为之的。对象的键有顺序争议(数字键会自动排序),ES6 委员会不想武断决定遍历顺序。如果你需要遍历对象,用 Object.keys/values/entriesObject.entries

3.4 for...of 的解构与展开

由于迭代协议的存在,所有可迭代对象都可以:

// 解构
const [a, b] = new Set([1, 2, 3]); // a=1, b=2

// 展开
const arr = [...new Set([1, 2, 2, 3])]; // [1, 2, 3]

// Array.from
const chars = Array.from("hello"); // ["h", "e", "l", "l", "o"]

// Promise.all、Map 构造器等都接受可迭代对象
const map = new Map([["a", 1], ["b", 2]]);

这就是协议的力量:一旦实现了 Symbol.iterator,就能无缝接入整个语言生态


四、Generator:迭代器的"语法糖"

手动写迭代器对象很繁琐——你需要维护闭包状态、返回 { value, done }。Generator 函数就是让这件事变得简单。

4.1 基本语法

function* countTo3() {
  yield 1;
  yield 2;
  yield 3;
}

const gen = countTo3();
gen.next(); // { value: 1, done: false }
gen.next(); // { value: 2, done: false }
gen.next(); // { value: 3, done: false }
gen.next(); // { value: undefined, done: true }

function* 声明一个 Generator 函数。调用它不会执行函数体,而是返回一个Generator 对象(同时也是迭代器)。每次调用 next(),函数体执行到下一个 yield 语句暂停。

本质理解:Generator 是一种可以暂停和恢复的函数yield 就是暂停点,next() 就是恢复执行。

4.2 Generator 与迭代协议的关系

Generator 对象自带 Symbol.iterator,且返回自身:

function* gen() { yield 1; }
const g = gen();

g[Symbol.iterator]() === g; // true

这意味着 Generator 对象天然就是可迭代的迭代器。所以用 Generator 实现 range 会简洁很多:

function* range(from, to) {
  for (let i = from; i <= to; i++) {
    yield i;
  }
}

for (const n of range(1, 5)) {
  console.log(n); // 1, 2, 3, 4, 5
}

4.3 yield* 委托

yield* 可以把迭代委托给另一个可迭代对象:

function* concat(iter1, iter2) {
  yield* iter1;
  yield* iter2;
}

[...concat([1, 2], [3, 4])]; // [1, 2, 3, 4]

递归 Generator + yield* 可以优雅地处理树形结构:

function* flatten(arr) {
  for (const item of arr) {
    if (Array.isArray(item)) {
      yield* flatten(item); // 递归委托
    } else {
      yield item;
    }
  }
}

[...flatten([1, [2, [3, 4]], 5])]; // [1, 2, 3, 4, 5]

4.4 双向通信:next(value)

Generator 不只是产出值,还可以接收值next() 的参数会成为上一个 yield 表达式的返回值:

function* accumulator() {
  let sum = 0;
  while (true) {
    const value = yield sum; // yield 产出 sum,同时接收 next 传入的值
    sum += value;
  }
}

const acc = accumulator();
acc.next();        // { value: 0, done: false }   — 初始 yield 产出 0
acc.next(10);      // { value: 10, done: false }  — yield 返回 10,sum=10,产出 10
acc.next(20);      // { value: 30, done: false }  — yield 返回 20,sum=30,产出 30
acc.next(5);       // { value: 35, done: false }  — yield 返回 5,sum=35,产出 35

这个特性是 async/await 实现的底层基础——Babel 把 async/await 转译成 Generator + Promise 就是利用了双向通信。

4.5 return()throw()

迭代器协议还支持两个额外的方法:

function* gen() {
  try {
    yield 1;
    yield 2;
    yield 3;
  } finally {
    console.log("cleanup");
  }
}

const g = gen();
g.next();       // { value: 1, done: false }
g.return("bye"); // "cleanup" 被打印,{ value: "bye", done: true }

return(value) 会强制结束 Generator,但会先执行 finally 块。这和 for...ofbreak 的行为一致——for...of 遇到 break 时会自动调用迭代器的 return()

throw(error) 则是在 Generator 暂停的地方抛出一个异常:

const g = gen();
g.next();           // { value: 1, done: false }
g.throw(new Error("oops")); // 在 yield 1 处抛出错误,进入 catch(如果有)

五、异步迭代:for await...of

5.1 异步迭代协议

有些数据源是异步到达的——比如逐行读取大文件、WebSocket 消息流、分页 API。这时候需要异步迭代器

// 异步迭代器:next() 返回 Promise
const asyncIterator = {
  i: 0,
  next() {
    if (this.i < 3) {
      return Promise.resolve({ value: this.i++, done: false });
    }
    return Promise.resolve({ done: true });
  },
  [Symbol.asyncIterator]() {
    return this;
  }
};

异步可迭代对象需要实现 Symbol.asyncIterator 方法(注意是 asyncIterator,不是 iterator)。

5.2 for await...of 语法

async function consume() {
  for await (const value of asyncIterator) {
    console.log(value); // 0, 1, 2
  }
}

for await...of 会等待每个 next() 返回的 Promise resolve 后再继续。

5.3 异步 Generator

和普通 Generator 类似,异步 Generator 可以在 yield 前加 await

async function* fetchPages(urls) {
  for (const url of urls) {
    const res = await fetch(url);
    const data = await res.json();
    yield data; // yield 的值会被包装成 Promise
  }
}

async function main() {
  const urls = ["/api/page1", "/api/page2", "/api/page3"];
  for await (const data of fetchPages(urls)) {
    console.log(data);
  }
}

异步 Generator 的返回值同时实现了 Symbol.asyncIterator,所以可以用 for await...of 消费。

5.4 实际应用场景

场景一:节流数据流

async function* throttle(asyncIter, delay) {
  for await (const value of asyncIter) {
    yield value;
    await new Promise(resolve => setTimeout(resolve, delay));
  }
}

场景二:取消迭代

async function* cancellable(asyncIter, signal) {
  for await (const value of asyncIter) {
    if (signal.aborted) return;
    yield value;
  }
}

场景三:批处理

async function* batch(asyncIter, size) {
  let batch = [];
  for await (const value of asyncIter) {
    batch.push(value);
    if (batch.length >= size) {
      yield batch;
      batch = [];
    }
  }
  if (batch.length > 0) yield batch;
}

六、Generator 的实际应用模式

Generator 不只是用来创建迭代器,在实际工程中有很多巧妙的用法。

6.1 状态机

Generator 天然适合表达状态机——每个 yield 就是一个状态:

function* trafficLight() {
  while (true) {
    yield "red";
    yield "green";
    yield "yellow";
  }
}

const light = trafficLight();
light.next().value; // "red"
light.next().value; // "green"
light.next().value; // "yellow"
light.next().value; // "red"(循环)

6.2 惰性求值

Generator 只在需要时才计算下一个值,适合处理大数据集:

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

// 只取前 10 个,不会无限计算
const first10 = [...fibonacci()].slice(0, 10);
// [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]

6.3 替代回调地狱

async/await 普及之前,Generator + Promise 是解决回调地狱的方案(co 库的原理):

// 这是 async/await 的底层原理示意
function runGenerator(genFn) {
  const gen = genFn();
  function step(value) {
    const result = gen.next(value);
    if (result.done) return result.value;
    return Promise.resolve(result.value).then(
      val => step(val),
      err => gen.throw(err)
    );
  }
  return step();
}

// 等价于 async/await
runGenerator(function* () {
  const user = yield fetchUser(1);
  const posts = yield fetchPosts(user.id);
  console.log(posts);
});

6.4 中间件模式(Koa 风格)

Koa 1.x 使用 Generator 实现洋葱模型中间件:

function* middleware1(next) {
  console.log("1 before");
  yield next;
  console.log("1 after");
}

function* middleware2(next) {
  console.log("2 before");
  yield next;
  console.log("2 after");
}

七、设计层面的思考

7.1 为什么需要协议而不是语法?

如果 for...of 只能用于数组,那它就是语法糖。但迭代协议让它可以用于任何对象——这就是鸭子类型(duck typing)的体现:不看你是什么类型,看你有什么行为。

这种设计的影响深远:

  • MapSet 不需要是数组,但可以被 for...of 遍历
  • 你可以让自定义类支持解构、展开
  • 第三方库可以实现自定义可迭代对象,无缝接入语言生态

7.2 Generator 的协程本质

Generator 本质上是一种协程(coroutine)——比线程更轻量的并发单元。yield 不是简单地"产出值",而是让出执行权

这和 async/await 的关系:

  • async/await 是 Generator + Promise 的语法糖
  • Babel 转译 async/await 时,底层就是用 Generator 实现的
  • awaityield,自动执行器 ≈ co
// async/await
async function fetchUser(id) {
  const res = await fetch(`/api/users/${id}`);
  return res.json();
}

// 等价的 Generator 版本
function* fetchUser(id) {
  const res = yield fetch(`/api/users/${id}`);
  return yield res.json();
}

7.3 为什么普通对象默认不可迭代?

这是一个有意的设计决策:

  1. 对象的键顺序有争议:数字键会被自动排序到前面,字符串键按插入顺序。这可能导致意外行为。
  2. 枚举语义不同for...in 会遍历原型链,而 for...of 不会。直接让对象支持 for...of 可能让人误以为不会遍历原型属性。
  3. 显式优于隐式:如果你需要遍历对象,明确使用 Object.keys()Object.entries() 等,意图更清晰。

八、易混淆点

  1. Symbol() vs Symbol.for():前者每次创建新值,后者全局注册表共享。
  2. Object.keys() 不包含 Symbol 键:需要 Reflect.ownKeys()Object.getOwnPropertySymbols()
  3. for...in vs for...offor...in 遍历键名(字符串,含原型链);for...of 遍历可迭代对象的值
  4. Generator 调用不执行函数体function* f() { console.log("hi"); } 调用 f() 不会打印,需要 f().next() 才执行。
  5. yield 是表达式,不是语句const x = yield value 是合法的,yield 表达式的值由下一次 next(arg) 的参数决定。
  6. for await...of 用于异步迭代:同步可迭代对象用 for...of,异步的用 for await...of,不要混用。
  7. yield* 委托 vs 直接 yieldyield* 会逐个产出被委托对象的所有值;直接 yield 只产出对象本身。
  8. Generator 的 return() 会触发 finally:和普通函数的 return 类似,但 Generator 是在暂停状态被终止的。

九、思考与练习

1. Symbol("foo") === Symbol("foo") 的结果是什么?为什么?

解析:falseSymbol() 每次创建的都是唯一值,即使描述相同。如果需要共享,用 Symbol.for("foo")

2. 以下代码输出什么?

const obj = { [Symbol()]: 1 };
const clone = { ...obj };
console.log(Object.getOwnPropertySymbols(clone).length);

解析:1... 展开会复制 Symbol 键属性(Object.assign 也会)。

3. 如何让一个普通对象可以被 for...of 遍历?

解析:给对象添加 [Symbol.iterator] 方法,返回一个有 next() 的迭代器对象。最简方式是用 Generator:

const obj = {
  a: 1, b: 2, c: 3,
  *[Symbol.iterator]() {
    for (const key of Object.keys(this)) {
      yield this[key];
    }
  }
};

for (const v of obj) console.log(v); // 1, 2, 3

4. Generator 函数中的 yieldreturn 有什么区别?

解析:

  • yield 暂停执行,产出值,下次 next() 可以继续
  • return 终止 Generator,done 变为 true
  • yield 可以多次使用,return 只能一次(之后的 next() 都返回 { done: true }

5. for await...ofPromise.all 的使用场景有什么区别?

解析:

  • for await...of串行处理:等上一个 Promise 完成再处理下一个,适合流式数据
  • Promise.all并行处理:同时发起所有请求,适合批量独立操作

6. 为什么 Generator 适合实现惰性求值?

解析:Generator 按需执行——只有调用 next() 时才计算下一个值。这意味着可以处理无限序列(如斐波那契数列),只取需要的部分,不会内存溢出。


总结

  • Symbol 解决了属性名冲突问题,Symbol.iterator 是迭代协议的入口。
  • 迭代协议统一了数据遍历方式:实现了 [Symbol.iterator] 的对象都可以用 for...of、解构、展开。
  • Generator 是实现迭代器的最简方式:function* + yield,自带 [Symbol.iterator]
  • 异步迭代通过 Symbol.asyncIterator + for await...of 处理异步数据流。
  • Generator 的本质是协程yield 是暂停点,next() 是恢复点——这也是 async/await 的底层原理。

下一篇讲 Proxy 与 Reflect:13 种拦截 trap、Reflectreceiver 的关系、以及 Vue 3 为什么选择 Proxy(系列第 32 篇,大纲 §32)。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐