跳转至

Map 与 Set — 字典的两种成熟形态,和弱引用这道防泄漏闸

这一章讲两件事: Map——比「拿对象当字典」好得多的字典:什么都能当键, 只装你放进去的东西,迭代有顺序;以及 Set——保证不重复、查询极快的集合, 和它们各自的「弱引用」版本(WeakMap/WeakSet)——垃圾回收的协作者。 读完你会明白:给一个对象关联元数据,该用 WeakMap 而不是往对象身上挂属性,为什么。

1. 先看现象:拿对象当字典,踩两个坑

JavaScript 的对象写起来天然像字典:obj[key] = value。第 9 章已经讲过对象远比键值对丰富, 这一章把「当字典用」的具体代价钉死。两个坑(书里列的):1

坑一:键被静默转成字符串。 任何写进 obj[key] 的键(除了 Symbol)都会被转成字符串。 array[1] 和 array["1"] 是同一个下标,就是这个规则的日常形态。后果:你拿数字 1 当键存的值, 和被字符串 "1" 当键存的值,撞在同一个格子里;更糟的是撞得无声无息。

坑二:分不清「自己的」还是「继承的」。 对象自带原型链(第 9 章): "toString" in obj 对任何普通对象都是 true——那不是你的数据,是继承来的后备。 你可以 Object.create(null) 造无原型的对象来缓解,但坑一无解。

Map(ES2015 引入)就是为这两个坑而生的:它只装你显式放进的键值对, 任何类型都能当键(对象、数组、NaN……都行)。2

主线例子:忍者档案室

全章的承重例子:给忍者对象挂「出生地元数据」。忍者来来去去(对象被建、被丢), 档案室(Map)怎么设计才不漏内存?三站走完:

  ① 建档案室:new Map,set/get/has/delete/size/clear 六件基本操作(§2)
  ② 查重与迭代:对象当键的引用相等语义、插入序遍历、WeakMap 的防泄漏改造(§3)
  ③ 花名册:Set 去重、交集找「既是忍者又是武士的人」、WeakSet 标记毕业(§4)

2. Map 的基本面

2.1 六件操作(书里的 Listing 10.11,带数走)

interface Ninja { name: string }
interface NinjaMetadata { homeIsland: string }

const ninja1 = { name: "Yoshi" };
const ninja2 = { name: "Hattori" };
const ninja3 = { name: "Kuma" };

const map = new Map<Ninja, NinjaMetadata>();
map.set(ninja1, { homeIsland: "Honshu" });      // 建档
map.set(ninja2, { homeIsland: "Hokkaido" });
map.get(ninja1)?.homeIsland === "Honshu";       // 查阅
map.get(ninja3) === undefined;                  // 没建档的:undefined
map.size === 2;                                 // 在册人数
map.delete(ninja1);                             // 除名
!map.has(ninja1) && map.size === 1;             // 除名成功
map.clear();                                    // 清空
map.size === 0;

API 与对象字典的对照一目了然:set/get/has/delete/size/clear 各司其职—— 注意没有 length,是 size;查无此键,get 给 undefined(不是报错)。

一个实用动机值得记住:为什么把元数据放 Map 而不是直接挂到忍者对象上? 书给了两条:你的函数可能收到「按契约不可修改」的对象;元数据可能只是临时的, 用 clear() 一倒,所有没被别人引用的值都标记给垃圾回收——比逐个删属性干净。3

2.2 对象当键:引用相等,而不是长得像

Map 的键比较规则一句话:key1 === key2 才算同一个键——对原始值是比值,对对象是比引用。4 三个推论,书里逐一给了断言:

// 推论 1:改了对象的内容,它还是那个键
ninja1.name = "Not Yoshi";
map.get(ninja1)?.homeIsland === "Honshu";   // 依然命中——键是对象本身,不是它的内容

// 推论 2:内容一模一样的克隆,不是同一个键
const ninja1Clone = { ...ninja1 };
map.has(ninja1Clone) === false;             // "A clone is no substitute for the real ninja1"

// 推论 3:同一对象的另一个变量名,当然是同一个键
const anotherNinja1 = ninja1;
map.has(anotherNinja1) === true;            // "The reference matters, not the variable"

这个语义和第 5 章「Set 里的函数按引用去重」是同一条规则:对象的身份在引用,不在内容。 (推论 2 也是深浅拷贝陷阱的又一注脚:展开克隆 { ...ninja1 } 是个新对象。)

2.3 迭代:顺序有保证,三视图可选

Map 是可迭代对象(第 8 章的协议):for...of 直接遍历它,每次吐出一个 [键, 值] 二元组(entry,条目)——数组解构一把拆开:5

const directory = new Map<string, string>();
directory.set("Yoshi", "+81 26 6462 9152");
directory.set("Kuma",  "+81 52 2378 6462");
directory.set("Hiro",  "+81 76 2771 4624");

for (const [key, value] of directory) { /* 键值对 */ }
for (const key of directory.keys())   { /* 只看键 */ }
for (const value of directory.values()) { /* 只看值 */ }

两个性质一起记:插入序保证——迭代顺序就是你 set 的顺序,与对象的 key 顺序 (先数字后字符串、按创建序的混合规则)不同,Map 的序是纯粹可预期的; keys()/values() 返回的是迭代器(第 8 章),不是数组——大 Map 遍历时不再额外占一份内存。6

entries 这个词还把两个静态方法带出来了:Object.entries(对象) 把对象变成 entry 数组, Object.fromEntries(iterable) 反向——于是 new Map(Object.entries(obj)) 把对象转 Map, Object.fromEntries(map) 把 Map 转回对象(键全部转字符串,可能撞车)。7

3. WeakMap:键被回收,条目自动消失

3.1 内存泄漏的构造

回到主线旅程的第 ② 站:档案室上了规模。应用各处都在建忍者、调 saveNinjaHomeIsland(ninja, ...) 建档,然后用完就丢(删掉自己这边的引用)。问题来了:Map 还死死抓着这些忍者—— 它们和它们的元数据都进不了垃圾回收。时间一长,Map 成了内存黑洞。8

机制根源:Map 对键持有的是强引用——只要 Map 活着,键对象就不可回收, 哪怕应用里已经没有任何地方再用它。

3.2 弱引用的语义

WeakMap 与 Map 几乎一样,但对键持有弱引用:

如果某个对象只剩下弱引用指向它,它就有资格被垃圾回收; 一旦键被回收,WeakMap 里那一整个条目自动消失。9

于是把档案室改成 new WeakMap() 后,忍者被应用丢弃 → 垃圾回收顺手清掉 → 条目自动蒸发。 「给别人的对象挂元数据」这个模式从此不漏内存——这也是 WeakMap 的标准定位。

代价清单(书里给了三条,前两条小,第三条大):10

  1. 键只能是对象,不能是原始值(弱引用的语义只对对象有意义);
  2. 没有 size 属性——大小随时在变(取决于回收何时发生),报了也不准;
  3. 不能遍历—— WeakMap 不是可迭代对象。只能拿具体键去 get/has/delete。

第三条是真金白银的限制:WeakMap 的内容对你是不可见的(这正是「弱」的代价: 引擎若允许你枚举,就得一直强引用着所有条目,弱引用就名存实亡)。 弱引用的元数据模式要配「拿到键才能查」的访问路径——书里的 hasNinjaGraduated(ninja) 就是这个形状。11

4. Set:只有键的 Map,和它的集合代数

4.1 去重的本性

场景的对偶:不再要「键→值」的映射,只要「这个东西在不在集合里」。 用 Map 模拟是 map.set(item, 真无关的值)——Set 是把这个模式扶正的内建类型: 可以想成「只有键、没有值的 Map」。12

const ninjas = new Set(["Kuma", "Hattori", "Yagyu", "Hattori"]);
// 构造时就地去重:四个输入,三个成员
ninjas.size === 3;
ninjas.has("Hattori");    // true
ninjas.add("Yoshi");      // 加新的
ninjas.add("Kuma");       // 加已有的——无效操作,大小不变
for (const n of ninjas) { /* 可迭代,插入序 */ }

API 与 Map 平行:add(对应 set)、has、delete、size、可迭代; 「重复添加是静默 no-op」对应 Map「重复 set 同一个键是覆盖」——语义不同但都无声。

书里的选型信号很直白:如果你在同一个数组上反复调 array.includes(item), 这是该换 Set 的强信号——set.has 是 O(1),array.includes 是 O(n): 元素越多,差距越大。13

4.2 集合运算:并、交、差、子集(ES2024)

Set 等了九年才补齐集合代数(书中列了四个):14

setA.union(setB)          // 并:A 或 B 有的全部
setA.intersection(setB)   // 交:A 和 B 都有的
setA.difference(setB)     // 差:在 A 不在 B 的
setA.isSubsetOf(setB)     // 判定:A 是否全在 B 里

主线例子的第 ③ 站用它收梢——找出「既是忍者又是武士」的人:

const ninjas  = new Set(["Kuma", "Hattori", "Yagyu"]);
const samurai = new Set(["Hattori", "Oda", "Tomoe"]);
const ninjaSamurais = ninjas.intersection(samurai);
ninjaSamurais.size === 1;
ninjaSamurais.has("Hattori");   // 只有 Hattori 一人两面

带数走:交集中每个元素必须同时在两边:Kuma(只在忍者册)出局,Oda/Tomoe(只在武士册)出局, Hattori 双籍入选。原书注明:这些方法够新(写作时刚落地,Node 22+ 可用), 野外代码里还少见——但目标环境支持就该用,可读性和效率双赢。15

4.3 WeakSet:只存对象的标记袋

对偶的最后一个:WeakSet。书里的场景——记录哪些忍者毕了业:

const trainedNinjas = new WeakSet<Ninja>();
const recordNinjaGraduation = (ninja: Ninja) => { trainedNinjas.add(ninja); };
const hasNinjaGraduated = (ninja: Ninja) => trainedNinjas.has(ninja);

与 WeakMap 同款的限制:只存对象、没有 size、不能遍历——换来同款的好处: 忍者对象被丢弃后,毕业记录随之消失,不挡回收。「给对象打不影响其生命周期的标记」, 就是 WeakSet 的甜区。16

5. 作者的判断与证据

书内可验的机制(全部带 assert):

  • Map 的引用相等键语义(三个推论的断言原文);
  • Set 构造时去重(4 个输入 → size 3);
  • intersection 的 1 人结果。17

作者立场(当立场读):

  • 「反复 includes 就该换 Set」是选型建议,O(1)/O(n) 的差距是真实的,但「反复」的阈值书上没给;
  • 「目标环境支持就用 ES2024 集合运算」是前瞻性建议,旧环境要用得配 polyfill 或自己实现;
  • WeakMap/WeakSet 的「不可遍历」被反复说成限制,从语义看它其实是弱引用的必然——作者把两者都点到了。

6. 边界与局限

  • Map/Set 的键比较是 SameValueZero:NaN 可以当键且 NaN === NaN 成立(唯一的例外情形); -0 与 +0 视作同一个键。这个细则书中未展开,机制解释与 === 规则足够接近日常。
  • WeakRef/FinalizationRegistry(更底层的弱引用机制)全书未提,WeakMap 之外它们属于进阶话题。
  • Map 的性能画像(哈希实现、大 Map 的遍历常数)书中没有数据,只有迭代器省内存的定性说法。
  • 集合运算只有四个方法被点名(symmetricDifference、isSupersetOf 在 ES2024 完整清单里,书中未列)。

7. 可带走的(合上这一章,你应该有这八条)

  1. 对象当字典两坑:键静默转字符串;in 分不清自有还是继承。Object.create(null) 只治后者。
  2. Map 只装显式放入的键值对,任何类型都能当键;查无此键给 undefined,成员数是 size。
  3. 键按 === 比较:改对象内容不影响命中;克隆体不是原对象;同对象换个变量名照命中。
  4. Map 迭代保插入序;keys()/values() 是迭代器不是数组;entry 是 [键, 值] 二元组。
  5. Object.entries/fromEntries 是对象与 Map 的双向桥(转回对象时键全变字符串)。
  6. WeakMap 的键是弱引用:键被回收条目自动消失——「给别人的对象挂元数据」的标准答案; 代价:只存对象、无 size、不可遍历。
  7. Set = 只有键的 Map:构造去重、add 重复静默无效、has 是 O(1);反复 includes 就该换 Set。
  8. ES2024 集合运算:union/intersection/difference/isSubsetOf 返回新 Set,旧环境先查支持。

8. 原文地图

主题 原书章 原文位置
对象字典两坑 10.2 Maps text/14-ch10-02-10-2-maps.txt:15(搜「pitfalls」)
Map 的动机 同上 :24(搜「Maps were introduced in ES2015」)
Listing 10.11 六操作 同上 :34(搜「Listing 10.11」)
元数据放 Map 的两条理由 同上 :90(搜「implicit API contract」)
引用相等三推论 同上 :102(搜「the object itself」) · :114(搜「clone is no substitute」) · :122(搜「The reference matters」)
key1 === key2 总则 同上 :128(搜「if (and only if)」)
迭代与插入序 同上 :132(搜「Iterating over maps」) · :140(搜「Listing 10.12」)
entries 与两静态方法 同上 :176(搜「ENTRIES, MAPS, AND OBJECTS」)
keys/values 是迭代器 同上 :188(搜「won't waste memory」)
内存泄漏构造 同上 :195(搜「Weakly holding references」) · :207(搜「come and go」)
弱引用语义 同上 :211(搜「only holds weak references」)
WeakMap 三条限制 text/15-ch10-03-10-3-sets.txt:3(搜「WeakMap can only use objects」)
WeakMap 不可见内容 同上 :7(搜「only way to access its contents」)
Set 的 Map 模拟与定义 同上 :15(搜「collections of distinct items」) · :22(搜「map with only keys」)
Listing 10.13 去重 同上 :30(搜「Listing 10.13」)
includes→Set 信号与 O(1) 同上 :81(搜「strong indicator」)
ES2024 集合运算清单 同上 :87(搜「ES2024 added」)
intersection 找双面人 同上 :99(搜「ninjaSamurais」)
集合运算的新度提醒 同上 :116(搜「little-used」)
WeakSet 毕业标记 同上 :122(搜「WeakSet」) · :128(搜「trainedNinjas」)

  1. 出处:「10.2 Maps」第 12–25 段(text/14-ch10-02-10-2-maps.txt:15,搜「pitfalls」)。对象字典两坑的原书陈述:原型继承干扰与键转字符串(array[1] 与 array["1"] 等价见第 20–21 段)。 ↩

  2. 出处:「10.2 Maps」第 24–26 段(text/14-ch10-02-10-2-maps.txt:24,搜「Maps were introduced in ES2015」)。Map 的两条保证:只装显式放入的、键可以是任何类型(含对象)。 ↩

  3. 出处:「10.2 Maps」第 90–97 段(text/14-ch10-02-10-2-maps.txt:94,搜「implicit API contract」)。元数据放 Map 的理由:不可修改的对象契约、临时数据 + clear() 的便利。 ↩

  4. 出处:「10.2 Maps」第 102–130 段(text/14-ch10-02-10-2-maps.txt:128,搜「if (and only if)」)。key1 === key2 的总规则与 1!=="1" 的补充例子。 ↩

  5. 出处:「10.2 Maps」第 132–175 段(text/14-ch10-02-10-2-maps.txt:140,搜「Listing 10.12」)。三种遍历循环与二元组解构。 ↩

  6. 出处:「10.2 Maps」第 135–137 段(text/14-ch10-02-10-2-maps.txt:136,搜「loops. You’re also guaranteed that these values will」);与第 188–191 段(搜「won't waste memory」)。插入序保证与迭代器省内存。 ↩

  7. 出处:「10.2 Maps」第 176–186 段(text/14-ch10-02-10-2-maps.txt:176,搜「ENTRIES, MAPS, AND OBJECTS」)。Object.entries/fromEntries 双向桥及键转字符串撞车的警告。 ↩

  8. 出处:「10.2 Maps」第 195–210 段(text/14-ch10-02-10-2-maps.txt:207,搜「come and go」)。泄漏构造:ninjas 被丢弃后仍被 Map 当键抓着。 ↩

  9. 出处:「10.2 Maps」第 211–214 段(text/14-ch10-02-10-2-maps.txt:211,搜「The solution to this problem: WeakMap. The WeakMap」)。弱引用的资格规则与条目自动移除。 ↩

  10. 出处:「10.3 Sets」第 1–9 段(text/15-ch10-03-10-3-sets.txt:3,搜「WeakMap can only use objects」)。三条限制清单。 ↩

  11. 出处:「10.3 Sets」第 7–9 段(text/15-ch10-03-10-3-sets.txt:8,搜「only way to access its contents」)。不可遍历是最大限制;不可见内容即弱语义的代价。 ↩

  12. 出处:「10.3 Sets」第 15–24 段(text/15-ch10-03-10-3-sets.txt:22,搜「Sets are a more convenient and efficient alternative」)。Set 的「只有键的 Map」定义与 Map 模拟代码。 ↩

  13. 出处:「10.3 Sets」第 81–83 段(text/15-ch10-03-10-3-sets.txt:81,搜「strong indicator」)。「反复 includes 就该用 Set」与 O(1) vs O(n) 的对照。 ↩

  14. 出处:「10.3 Sets」第 87–98 段(text/15-ch10-03-10-3-sets.txt:88,搜「ES2024 added」)。四个集合运算方法的原书清单。 ↩

  15. 出处:「10.3 Sets」第 99–120 段(text/15-ch10-03-10-3-sets.txt:105,搜「ninjaSamurais」)。交集示例与「more readable and more efficient」的新度提醒(第 116–118 段)。 ↩

  16. 出处:「10.3 Sets」第 122–139 段(text/15-ch10-03-10-3-sets.txt:128,搜「trainedNinjas」)。WeakSet 毕业记录示例与三条同款限制。 ↩

  17. 出处:「10.2 Maps」第 114–126 段(text/14-ch10-02-10-2-maps.txt:117,搜「clone is no substitute」);text/15-ch10-03-10-3-sets.txt:44(搜「size === 3」);:107(搜「size === 1」)。三组可复现断言。 ↩