首页 / 文章 / JavaScript的隐藏工具箱:符号、WeakMap、代理和生成器

JavaScript的隐藏工具箱:符号、WeakMap、代理和生成器

了解 Symbols、WeakMap/WeakSet、Proxy/Reflect、FinalizationRegistry 以及生成器在底层是如何运作,从而防止内存泄漏并实现框架级的元编程。

2046 词

解锁您最喜爱的框架背后的隐藏引擎,彻底解决内存泄漏问题

大多数情况下,JavaScript 开发依赖于一些常见的核心工具:constlet、箭头函数、map/filter、数组解构以及 async/await。这些常用的工具足以应对绝大多数开发需求。

不过 ECMAScript 规范中还包含另一组不太为人所知的工具:原始类型、专用数据容器以及低级元编程接口,这些在入门教程中几乎从未出现。框架和库的开发者经常使用它们来避免内存泄漏、解决命名冲突、利用引擎的内部机制,并保持庞大代码库的稳定性。一旦理解了这些工具,您就会在已使用的依赖项中随处发现它们的身影。

熟悉这些特性会改变你对对象生命周期、封装边界以及运行时性能的思考方式。下面将介绍一些你在实际代码中很可能已经受益的功能,只不过当时并未意识到它们的存在。

1. 符号:永不冲突的键

Symbol 是 ES6 中新增的一种原始类型,与 stringnumberbooleannullundefinedbigint 并列存在。

每次调用 Symbol() 都会生成一个全新的、独一无二的值。即使使用完全相同的描述创建的两个符号,它们也永远不会相等:

const id1 = Symbol('id');
const id2 = Symbol('id');

console.log(id1 === id2); // false

传递给 Symbol('id') 的文本仅作为调试输出的可选标签——它与符号的身份毫无关联。

为什么需要符号的存在?

在符号出现之前,对象的键必须是字符串。这带来了一个严重的问题:如果你编写了一个将内部元数据附加到他人拥有的对象上的库,就很容易破坏该对象中已存在的属性:

// Risky: Another script might already use `isProcessed`
user.isProcessed = true;

符号完全避免了这个问题,因为其他代码无法意外生成相同的符号:

const TRACKING_ID = Symbol('trackingId');

const user = {
  name: 'Sarah',
  role: 'Admin'
};

// Safe: Nothing can clash with this exact key
user[TRACKING_ID] = 'txn_89412';

“隐形斗篷”

以符号为键的属性会被常规的反射和序列化工具忽略:

  • for...in 循环会忽略它们。
  • Object.keys(user) 也会排除它们。
  • JSON.stringify(user) 则会完全删除它们。
console.log(Object.keys(user)); // ['name', 'role']
console.log(JSON.stringify(user)); // '{"name":"Sarah","role":"Admin"}'

尽管如此,符号键并非真正的机密——调用 Object.getOwnPropertySymbols(user) 仍然可以获取到它们。但对于常规的迭代和序列化操作而言,这些符号键仍然是隐藏的,因此非常适合用作内部标志、缓存值以及不应暴露在公共 API 中的插件级元数据。

已知符号:访问语言内部机制

该语言还提供了一组内置符号,即已知符号,它们作为 Symbol 对象的静态属性暴露出来。这些符号能让您直接访问引擎本身所依赖的机制。

通过 Symbol.iterator 进行自定义迭代

您可以通过自行实现该符号,让任何普通对象都能与 for...of 语句配合使用:

const inventory = {
  items: ['Keyboard', 'Monitor', 'Desk Pad'],
  [Symbol.iterator]() {
    let index = 0;
    return {
      next: () => {
        if (index < this.items.length) {
          return { value: this.items[index++], done: false };
        }
        return { done: true };
      }
    };
  }
};

for (const item of inventory) {
  console.log(item); // Prints: Keyboard, Monitor, Desk Pad
}

使用 Symbol.toPrimitive 实现自定义的强制转换行为

这让你能够精确控制对象被转换为字符串或数字时的处理方式:

const money = {
  amount: 250,
  currency: 'USD',
  [Symbol.toPrimitive](hint) {
    if (hint === 'number') return this.amount;
    if (hint === 'string') return `${this.amount} ${this.currency}`;
    return this.amount; // default
  }
};

console.log(+money + 50); // 300
console.log(`${money}`);  // "250 USD"

2. WeakMap 和 WeakSet:无需手动管理即可进行内存清理

要理解为何 WeakMap 很重要,首先需要了解普通 Map 是如何管理内存的。

普通的 Map 会对其存储的每个键和值保持强引用。这意味着只要 Map 本身仍然存在,其任何键都不会被垃圾回收——即便应用程序的其他部分已经不再引用它们了。

let cache = new Map();
let element = document.querySelector('#heavy-widget');

cache.set(element, { clicks: 0 });

// Later, the DOM node is removed:
element.remove();
element = null;

// Problem: The DOM node is still stuck in memory because `cache` holds it.

这是客户端出现内存泄漏的常见原因,尤其是在单页应用中,用户在不同视图之间切换时无需完整刷新页面来重置内存。

这时WeakMap就派上用场了。它仅保存对其键的弱引用,由此带来两个重要后果:

  1. 键必须是对象(或在现代引擎中为未注册的符号)——字符串或数字等原始类型不能作为键。
  2. 一旦程序中再也没有其他部分引用该键对象,垃圾回收器就可以回收它,相应的WeakMap条目也会随之消失。
const metadataStore = new WeakMap();

function setupWidget(domNode) {
  metadataStore.set(domNode, { initializedAt: Date.now() });
}

// When domNode is removed from the DOM and its variable goes out of scope,
// the WeakMap entry is garbage-collected automatically.

设计中固有的权衡

由于垃圾回收会在浏览器决定的、不可预测的时间执行,而非由代码控制,WeakMap刻意限制了对其的操作能力:

  • 它不可遍历:没有.forEach()、没有for...of,也没有.keys()
  • 没有.size属性,因此无法查看当前存在多少条记录。
  • 仅提供四种方法:.get().set().has().delete()

如果能够枚举键或读取大小值,所得结果将取决于垃圾回收是否刚刚执行——这会使程序的行为变得实际上不可预测。通过取消遍历功能,无论垃圾回收何时执行,该API的表现都能保持可预测性。

一种实用模式:真正私有的实例状态

在原生私有类字段(#field)得到广泛支持之前,WeakMap是让类实例拥有真正私有内部状态的标准手段:

const privateData = new WeakMap();

class BankAccount {
  constructor(initialBalance) {
    privateData.set(this, { balance: initialBalance });
  }

  deposit(amount) {
    const data = privateData.get(this);
    data.balance += amount;
  }

  getBalance() {
    return privateData.get(this).balance;
  }
}

const account = new BankAccount(100);
account.deposit(50);
console.log(account.getBalance()); // 150
console.log(account.balance);       // undefined (completely inaccessible)

由于以实例本身(this)作为键,一旦该实例不再被任何地方引用,其私有数据也会随之自动被清理。

3. Proxy与Reflect:当下即可使用的元编程工具

Proxy可以包裹一个对象,让你能够拦截其最基本的操作——读取属性、赋值、调用函数或删除键。

如果你曾经在Vue 3中更新过响应式状态,或见过现代响应式库自动追踪变化,那其实都是在与背后的Proxy进行交互。

const targetUser = { name: 'Alex', age: 28 };

const handler = {
  get(target, prop, receiver) {
    console.log(`Reading property "${prop}"`);
    return Reflect.get(target, prop, receiver);
  },
  set(target, prop, value, receiver) {
    if (prop === 'age' && typeof value !== 'number') {
      throw new TypeError('Age must be a valid number.');
    }
    console.log(`Setting "${prop}" to ${value}`);
    return Reflect.set(target, prop, value, receiver);
  }
};

const monitoredUser = new Proxy(targetUser, handler);

monitoredUser.age = 29; // Logs: Setting "age" to 29
console.log(monitoredUser.name); // Logs: Reading property "name" -> "Alex"
// monitoredUser.age = 'thirty'; // Throws TypeError

你可能已经注意到在那个 Proxy 处理程序中使用了 Reflect.get()Reflect.set()Reflect 是一个内置的全局对象,其中包含了能够模拟 Proxy 可以拦截的操作的方法。在 Proxy 处理程序中定义的每一个捕获机制,都在 Reflect 上有对应的方法,且这些方法接受的参数完全相同。与其在 set 捕获机制中手动编写类似 target[prop] = value 的代码——一旦涉及原型或访问器属性,这种方式就可能会以隐蔽的方式出错——不如直接调用对应的 Reflect 方法,这样既能安全地执行引擎的默认行为,还能返回一个布尔值告知该操作是否真正成功。

4. FinalizationRegistry 与 WeakRef:观察垃圾回收过程

很长一段时间里,JavaScript 中没有方法可以判断一个对象何时被引擎真正回收。ES2021 通过两个用于高级系统级编程的底层 API 填补了这一空白:WeakRefFinalizationRegistry

WeakRef 可以持有对对象的引用,同时不会阻止垃圾回收器回收该对象,但仍能让你在对象仍然存在时获取它:

let heavyAsset = { buffer: new ArrayBuffer(1024 * 1024 * 16) }; // 16MB buffer
const assetRef = new WeakRef(heavyAsset);

// Dereference to access
const asset = assetRef.deref();
if (asset) {
  // Asset still exists in memory
  console.log("Using cached asset");
} else {
  // Asset was collected by GC; reload it
  console.log("Asset was garbage collected");
}

FinalizationRegistry 则通过允许你注册一个在对象被回收后触发的回调函数来补充这一功能。它通常用于清理与该对象相关的外部资源,或进行诊断日志记录:

const registry = new FinalizationRegistry((heldValue) => {
  console.log(`Cleanup hook: Resource "${heldValue}" was garbage collected.`);
});

(() => {
  const temporaryWorker = { id: 'worker_404' };
  registry.register(temporaryWorker, temporaryWorker.id);
  // temporaryWorker leaves scope here
})();

使用这两个 API 时需格外谨慎。垃圾回收的时机因浏览器和 JavaScript 引擎而异,既无法预测也无法强制控制。绝不应将应用程序的核心逻辑——如状态持久化、事务确认,或是应用所依赖的任何功能——绑定到终结器回调中,因为无法保证该回调何时会执行,甚至无法确保它一定会及时执行。

5. 生成器函数:按需暂停与恢复

普通的 JavaScript 函数遵循“执行到完成”的规则:一旦被调用,就会持续执行直到遇到 return 语句或抛出异常,期间不会停止。

而使用 function* 语法定义的生成器函数则打破了这一规则。它们可以在执行过程中暂停,并通过 yield 关键字标记出暂停点,之后再继续执行。

function* idGenerator() {
  let id = 1;
  while (true) {
    yield `UID_${id++}`;
  }
}

const gen = idGenerator();
console.log(gen.next().value); // UID_1
console.log(gen.next().value); // UID_2
console.log(gen.next().value); // UID_3

仔细看看那个生成器中的 while (true) 循环。在普通函数中,这样的无限循环会立即导致线程卡住。但在生成器中,一旦执行到 yield,程序就会立即停止并把控制权交还给调用它的人——在再次调用 .next() 之前不会发生任何其他事情。

正因如此,生成器非常适合用于处理大量数据流,而无需将所有数据都存储在内存中。与其从文件中读取五十万行数据并放入一个大型数组,生成器可以按需逐条输出相关数据:

function* processLargeLogFile(lines) {
  for (const line of lines) {
    if (line.includes('ERROR')) {
      yield line.trim();
    }
  }
}

// Memory remains flat because lines are yielded one by one as consumed
for (const errorLine of processLargeLogFile(rawLines)) {
  sendToAlertService(errorLine);
}

这些特性都无需让你立即彻底改造现有的代码库。它们的真正价值在于帮助你判断何时该使用它们,而非采用可能产生脆弱代码或不必要的内存开销的传统方法。

下次在为动态增减的 DOM 节点添加元数据时,可以使用 WeakMap。在设计需要外部代码定义自身唯一键值的插件架构时,可选用 Symbol。而当需要一个能自动追踪变化的响应式数据层时,则应以 Proxy 为基础来构建。

熟练运用这些工具,才能将普通的应用程序代码开发与可扩展的工程级软件开发区分开来。

相关阅读

  • 通过逆向分析后台API调用查找隐藏价格 — 学习如何使用浏览器开发者工具来发现并复现那些用于加载价格而无需解析HTML的XHR、REST和GraphQL请求。