惊呆了!JavaScript 集合类型居然藏着这么多暗坑 ——《红宝书》×《你不知道的JS》深度解析

0 阅读12分钟

惊呆了!JavaScript 集合类型居然藏着这么多暗坑 ——《红宝书》×《你不知道的JS》深度解析

数组去重用 Set,键值对用 Map……这些“最佳实践”早已烂大街。但你有没有想过:为什么 new Array(3).map(parseInt) 会产生稀稀拉拉的诡异结果?为什么 WeakMap 既不能遍历又不能用基本类型做键,却被各大框架视为私有属性的救星?今天,我们以《JavaScript高级程序设计》(红宝书)第六章为骨架,注入《你不知道的JavaScript》里那些“反直觉”的真相,把集合类型这块硬骨头彻底嚼碎。


一、数组:你以为的简单其实暗藏玄机

红宝书用了大量篇幅梳理数组的 API:栈方法、队列方法、归并、搜索……可一旦离开“教科书式”的用例,很多隐藏行为就会咬你一口。《你不知道的JS》中有一句精准的吐槽:“数组在 JavaScript 中不过是一种特殊对象,length 只是那个对象上的一个可写属性。” 理解了这一点,所有诡异现象便豁然开朗。

1. 稀疏数组与“空位”陷阱

试着运行这段代码:

const arr = new Array(3);   // [ <3 empty items> ]
const mapped = arr.map((_, i) => i); 
console.log(mapped);        // [ <3 empty items> ] ,不是 [0, 1, 2]!

map 会跳过那些从未被赋值的“空位”(empty slots)。同样,forEachfilterevery 等也会忽略它们。与之形成对比的是 fillcopyWithin,它们把空位当作正常索引处理。红宝书中提到了 fill 会填充所有索引,但未深究空位与 undefined 的区别。《你不知道的JS》则明确指出:空位是真正的“无”,连 undefined 值都不是;你可以用 in 运算符证实——0 in new Array(3) 返回 false,而 0 in [undefined, undefined, undefined] 返回 true。这种不一致是语言早期设计留下的痼疾,ES6 的 Array.from() 则会将空位转为真正的 undefined,算是一种“拨乱反正”。

2. length —— 一把双刃剑

数组的 length 不是只读的,我们可以直接改写它:

const fruits = ['🍎', '🍌', '🍇'];
fruits.length = 2;     // 截断: ['🍎', '🍌']
fruits.length = 5;     // 扩张:产生 3 个空位

截断操作简单粗暴,常被用来快速清空数组(arr.length = 0)。而扩张操作则会凭空制造空位,再次引发上面提到的迭代陷阱。《你不知道的JS》将这个行为类比成“你告诉系统你要一个更长的列表,但没有给出里面应该放什么东西”。同时,因为数组本质是对象,你也可以给它添加字符串键的属性,它们并不计入 length

3. 类数组转数组:从 Hack 到优雅

红宝书介绍了 Array.from() 作为现代化的类数组转换方法。在此之前,前端工程师们依赖一个著名的黑魔法:

function foo() {
  var args = Array.prototype.slice.call(arguments);
}

这背后利用的是 slice 内部会读取 length 并迭代整数键的特性。《你不知道的JS》深入解释了“鸭子类型”在类数组场景中的应用,并提醒:arguments 在严格模式下已不再与形参绑定,性能更难优化。如今,有了 Array.from() 和扩展运算符 ...,我们终于能写出直觉化的代码:

const nodeList = document.querySelectorAll('div');
const arr = [...nodeList];               // 扩展运算符
const arr2 = Array.from(nodeList, n => n.textContent); // 一次转换并映射

4. sort 的默认排序:字典序的坑

如果你以为 [3, 15, 8].sort() 会返回 [3, 8, 15],那就掉坑里了——它会返回 [15, 3, 8]。因为默认的排序会将元素转换为字符串,按 UTF-16 码元比较。红宝书提醒要传入比较函数,而《你不知道的JS》进一步讨论了 V8 引擎中排序算法的稳定性以及 NaN 在比较中的表现。当你需要对复杂对象数组排序时,sort 还会直接修改原数组,这种“变异”行为经常引发意料之外的副作用,必要时记得先用 [...arr] 浅拷贝。


二、Map vs Object:谁才是键值对之王?

红宝书列出了 Map 的几大卖点:键可以是任意类型、顺序可预测、拥有 size 属性。然而,当我们把《你不知道的JS》中的原型链知识投射进去,真正的优劣才会浮现。

1. 原型链污染——Object 的阿喀琉斯之踵

创建普通对象时,它自动继承自 Object.prototype,这意味着一些键名可能已被占用:

const obj = {};
obj.hasOwnProperty = 'oops';  // 覆盖了原型方法
// 使用 for...in 时更是会把可枚举的原型属性也遍历出来

虽然可以通过 Object.create(null) 创建一个“纯净”对象,但代码里一旦混用多种创建方式,就会出现困惑。Map 从根源上斩断了这个问题:它没有原型键,任何键值对都干净独立。因此,当你需要存储用户动态输入的数据且键名不可预测时,Map 更安全。

2. 键的相等:SameValueZero 与隐式转换

Map 使用 SameValueZero 算法判断键是否重复,这意味着 NaN 被视为等于 NaN(这与 === 不同),而 +0-0 相等。但更值得注意的是:Map 的键不会发生类型转换

const m = new Map();
m.set('1', 'string');
m.set(1, 'number');
console.log(m.size); // 2,分得清清楚楚

const obj = {};
obj['1'] = 'string';
obj[1] = 'number';    // 键被转换为 "1",覆盖了前面的值

Object 的键必须是字符串或 Symbol,任何非字符串键都会通过 ToString 转换。所以 obj[1]obj['1'] 是同一个属性。这种隐式转换在复杂的业务逻辑里经常导致“键丢失”或“意外覆盖”。

3. 性能与迭代

红宝书提到:在频繁增删键值对时,Map 性能更优。背后的原因与引擎的“隐藏类”(hidden class)机制有关。Object 为了快速访问属性,会根据属性添加顺序生成隐藏类;频繁的动态增删会破坏这些优化,导致回退到字典模式(慢)。Map 从一开始就为动态增删设计,内部结构更适应这种场景。

此外,Map 的迭代直接通过 entries() 返回插入顺序的迭代器,而 Object 则需要 Object.entries() 手动处理,且行为在某种程度上依赖属性类型(整数键会先排序)。如果你需要确保键值对的遍历顺序,Map 是不二之选。


三、WeakMap / WeakSet:弱引用背后的垃圾回收魔法

红宝书将 WeakMap 和 WeakSet 描述为“不可迭代、键必须是对象”。许多人对它的印象停留在“只是更弱的 Map”。而《你不知道的JS》则把这层窗户纸捅破:它们存在的唯一目的,就是避免内存泄漏

1. 弱引用的真面目

常规的 Map 即使键不再被任何地方使用,因为 Map 本身持有它的引用,该对象无法被垃圾回收(GC)。WeakMap 则不同——它对键的引用是“弱”的,不计入垃圾回收的引用计数。当键对象在其他地方都被销毁后,WeakMap 中对应的条目便会被自动清除。

正因为这种“随时可能消失”的特性,WeakMap 无法提供迭代器,也不公开 size。你无法知道里面还剩多少项,引擎也不会告诉你什么时候回收。这并非缺陷,而是为了保证 GC 的安全。

2. 经典场景一:DOM 元数据

在 Web 开发中,我们经常需要给 DOM 节点关联一些状态或数据。如果用一个普通的对象或 Map 将 DOM 作为键,当该 DOM 从页面移除并消除引用后,这个映射关系仍然存在,阻止 DOM 被回收。WeakMap 天然适配此类需求:

const domData = new WeakMap();
const div = document.querySelector('#target');
domData.set(div, { clickCount: 0 });

div.addEventListener('click', () => {
  const data = domData.get(div);
  data.clickCount++;
});
// 当 #target 从 DOM 中移除,并且 div 变量被置为 null 后,GC 会回收它,
// WeakMap 中对应的条目也会消失,不会造成泄漏。

3. 经典场景二:真正私有的实例属性

在 ES6 的类私有字段(#)出现之前,WeakMap 曾是实现真正私有属性的最佳方式:

const _private = new WeakMap();

class Counter {
  constructor() {
    _private.set(this, { count: 0 });
  }
  increment() {
    const state = _private.get(this);
    state.count++;
    return state.count;
  }
}

外部代码完全无法访问 _private 中的数据,因为根本无法猜测出键(实例)对应的值。当实例被垃圾回收时,它的私有数据也随之消失。这种模式被 Babel 等工具广泛用于转译早期私有属性语法。

4. WeakSet 的打标记模式

WeakSet 适合存储“已被处理过”的对象集合。例如,某个对象的某个操作只应执行一次:

const visited = new WeakSet();

function doOnce(obj) {
  if (visited.has(obj)) return;
  visited.add(obj);
  // ...执行一次性任务
}

如果对象被销毁,WeakSet 中的记录自动消失,不会产生内存残余。这种模式比在对象上添加脏标记属性(如 obj._visited = true)更清洁、更内聚。


四、Set:去重利器也有脾气

Set 像是数组的“唯一化”版本,红宝书提到它常被用于去重 [...new Set(arr)]。但《你不知道的JS》会用更细致的 SameValueZero 对比告诉你:它并不是万能的。

Set 对值的判别同样使用 SameValueZero,所以:

const s = new Set([NaN, NaN]);
console.log(s.size); // 1,完美去重

但对于引用类型,它只比较引用:

const a = { id: 1 };
const b = { id: 1 };
console.log(new Set([a, b]).size); // 2,两个“长得一样”的对象是不同的引用

因此,去重对象数组时必须先序列化或指定唯一标识。另外,集合运算(并集、交集、差集)可以借助扩展运算符和 filter 轻松实现:

const union = new Set([...setA, ...setB]);
const intersect = new Set([...setA].filter(x => setB.has(x)));
const difference = new Set([...setA].filter(x => !setB.has(x)));

这些操作简洁又具有函数式的美感。


五、迭代器协议:集合的统一语言

红宝书在第六章末尾强调,所有集合类型(Array、TypedArray、Map、Set)都实现了可迭代协议。这意味着它们不仅仅是数据结构,更是“数据的生产者”。只要你理解了这个底层约定,就能写出高度解耦的代码。

1. 超越语法糖的扩展运算符

扩展运算符 ... 之所以能展开集合,正是因为它们具有 Symbol.iterator 方法。这意味着你可以将集合无缝传递给任何期望可迭代对象的 API:

const map = new Map([['a', 1], ['b', 2]]);
// 将 Map 的值转为数组
const values = [...map.values()]; 

function sum(...args) { return args.reduce((a,b) => a+b, 0); }
// 直接传入 Set
sum(...new Set([1, 2, 3])); // 6

2. 迭代器的手动操控

可迭代对象返回的迭代器都是懒加载的,你可以手动调用 next() 来按需获取数据,这对性能敏感的大数据集合尤其有用。《你不知道的JS》曾用生成器举例,其实集合的 values() 返回的也是同样接口:

const iterator = [10, 20, 30].values();
let result = iterator.next();
while (!result.done) {
  console.log(result.value); // 10, 20, 30
  result = iterator.next();
}

3. 与生成器协同

生成器函数返回的正是可迭代的迭代器对象,因此你可以在自定义逻辑中复用集合的迭代器:

function* combine(set, map) {
  yield* set;          // 依次产出 Set 的值
  yield* map.values(); // 依次产出 Map 的值
}
console.log([...combine(new Set([1,2]), new Map([['a',3]]))]); // [1,2,3]

这种高度的互操作性,让集合不再是一座孤岛。


六、定型数组:二进制世界的“硬核玩家”

红宝书第六章用了将近五分之一的篇幅介绍定型数组(Typed Arrays)。这源于现代 Web 中,大量 Canvas、WebGL、音频处理、文件读写都需要直接操作二进制数据。定型数组允许你在 JavaScript 中玩转像 C 语言般的高效内存。

你通过 ArrayBuffer 开辟一块固定大小的内存,然后以不同“视角”去解读它:

const buffer = new ArrayBuffer(8);      // 8 字节
const int32View = new Int32Array(buffer); // 每 4 字节为一个 32 位整数
int32View[0] = 42;
int32View[1] = 256;

const int16View = new Int16Array(buffer);
console.log(int16View[0], int16View[1]); // 42 的低 16 位与高 16 位

它们和普通数组一样支持 [] 读写、mapsubarray 等方法,但大小固定,无法使用 pushpop 等改变长度的操作。在图形处理等场景中,性能远超普通数组,因为数据直接映射到 GPU 可识别的缓冲区。如果你有过 Web Audio API 中处理音频帧的经历,或是需要优化大量数值计算,定型数组绝对是性能提升的利器。


结语:从“会用”到“懂它”

红宝书第六章给我们绘制了一张精确的集合类型地图,而《你不知道的JS》则像一位带你在夜间穿越森林的向导,用那些微妙、反直觉的细节让你看清每棵树的纹理。当你下次想用数组 sort 之前,请记得先传比较函数;当你选用 Map 而非 Object 时,心中清楚原型链和隐式转换的问题已被规避;当你用 WeakMap 管理私有数据时,知道这是 GC 友好的优雅解耦。

集合类型远不止 API 的堆砌。它们是 JavaScript 类型系统中的一面棱镜,折射出了原型继承、类型转换、内存管理以及迭代器协议之间深刻而有趣的相互作用。真正掌握它们,你便能在日常开发中游刃有余,写出既稳健又充满智慧的代码。

延伸思考:若你将一个保存 DOM 引用的 Set 直接清空(set.clear()),相关的 DOM 节点会被回收吗?如果不手动清空,还有什么因素会阻止 GC?答案正是本章所揭示的——强引用与弱引用的精妙平衡。正是这些细节,区分了普通开发者与“知其所以然”的匠人。


本文融合《JavaScript高级程序设计(第4版)》第六章与《你不知道的JavaScript(中/下卷)》核心内容,带你穿透表象,直击本质。