Symbol 与 Iterator / Generator

系列文章目录

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


文章目录

  • 系列文章目录
  • 前言
  • 一、Symbol:不只是"不会重复的键"
    • [1.1 诞生的背景](#1.1 诞生的背景)
    • [1.2 Symbol 的本质](#1.2 Symbol 的本质)
    • [1.3 Symbol 作为属性键](#1.3 Symbol 作为属性键)
    • [1.4 Symbol.for 与全局注册表](#1.4 Symbol.for 与全局注册表)
  • [二、Well-Known Symbols:语言的"后门"](#二、Well-Known Symbols:语言的"后门")
  • 三、迭代协议:从"怎么遍历"到"什么是可遍历的"
    • [3.1 问题背景](#3.1 问题背景)
    • [3.2 迭代协议的核心思想](#3.2 迭代协议的核心思想)
    • [3.3 原生可迭代对象](#3.3 原生可迭代对象)
    • [3.4 `for...of` 的解构与展开](#3.4 for...of 的解构与展开)
  • 四、Generator:迭代器的"语法糖"
    • [4.1 基本语法](#4.1 基本语法)
    • [4.2 Generator 与迭代协议的关系](#4.2 Generator 与迭代协议的关系)
    • [4.3 `yield*` 委托](#4.3 yield* 委托)
    • [4.4 双向通信:`next(value)`](#4.4 双向通信:next(value))
    • [4.5 `return()` 与 `throw()`](#4.5 return()throw())
  • [五、异步迭代:`for await...of`](#五、异步迭代:for await...of)
  • [六、Generator 的实际应用模式](#六、Generator 的实际应用模式)
    • [6.1 状态机](#6.1 状态机)
    • [6.2 惰性求值](#6.2 惰性求值)
    • [6.3 替代回调地狱](#6.3 替代回调地狱)
    • [6.4 中间件模式(Koa 风格)](#6.4 中间件模式(Koa 风格))
  • 七、设计层面的思考
    • [7.1 为什么需要协议而不是语法?](#7.1 为什么需要协议而不是语法?)
    • [7.2 Generator 的协程本质](#7.2 Generator 的协程本质)
    • [7.3 为什么普通对象默认不可迭代?](#7.3 为什么普通对象默认不可迭代?)
  • 八、易混淆点
  • 九、思考与练习
  • 总结

前言

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

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

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


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

1.1 诞生的背景

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

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

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

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

javascript 复制代码
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 值。
javascript 复制代码
const s = Symbol("id");

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

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

1.3 Symbol 作为属性键

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

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

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

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

javascript 复制代码
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 来标记某个协议)。

javascript 复制代码
// 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") 全局注册表 跨模块共享、协议约定
javascript 复制代码
// 判断是否是全局注册的 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

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

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

自定义类型标签

javascript 复制代码
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 }
javascript 复制代码
// 手动实现一个可迭代对象
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 对象(后面会讲)
javascript 复制代码
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 }

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

javascript 复制代码
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 的解构与展开

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

javascript 复制代码
// 解构
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 基本语法

javascript 复制代码
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,且返回自身:

javascript 复制代码
function* gen() { yield 1; }
const g = gen();

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

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

javascript 复制代码
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* 可以把迭代委托给另一个可迭代对象:

javascript 复制代码
function* concat(iter1, iter2) {
  yield* iter1;
  yield* iter2;
}

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

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

javascript 复制代码
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 表达式的返回值:

javascript 复制代码
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()

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

javascript 复制代码
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 暂停的地方抛出一个异常:

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

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

5.1 异步迭代协议

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

javascript 复制代码
// 异步迭代器: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 语法

javascript 复制代码
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

javascript 复制代码
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 实际应用场景

场景一:节流数据流

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

场景二:取消迭代

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

场景三:批处理

javascript 复制代码
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 就是一个状态:

javascript 复制代码
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 只在需要时才计算下一个值,适合处理大数据集:

javascript 复制代码
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 库的原理):

javascript 复制代码
// 这是 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 实现洋葱模型中间件:

javascript 复制代码
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
javascript 复制代码
// 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. 以下代码输出什么?

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

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

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

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

javascript 复制代码
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)。

相关推荐
维双云1 小时前
小程序店铺装修模板怎么选?从首页布局、商品展示到下单路径这样看更实际
前端·小程序
YHL1 小时前
📖前端 HTTP 请求 & LLM 接口开发
前端·https
西部荒野子2 小时前
4.JS Bundle 执行流程
前端
假如让我当三天老蒯2 小时前
State和Props区别和左右(自学用)
前端·react.js
西部荒野子2 小时前
1. 建立源码地图
前端
西部荒野子2 小时前
3.RCTRootView 加载 Bundle 流程
前端
西部荒野子2 小时前
2.iOS 启动到 RCTRootView
前端
zzqssliu2 小时前
Taocarts库存锁定机制优化:彻底解决跨境代购商品超卖问题
java·linux·javascript·php
scan7242 小时前
SystemMessage,HumanMessage,AIMessage,ToolMessage
开发语言·前端·javascript