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
- 键只能是对象,不能是原始值(弱引用的语义只对对象有意义);
- 没有
size属性——大小随时在变(取决于回收何时发生),报了也不准; - 不能遍历—— 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. 可带走的(合上这一章,你应该有这八条)¶
- 对象当字典两坑:键静默转字符串;
in分不清自有还是继承。Object.create(null)只治后者。 - Map 只装显式放入的键值对,任何类型都能当键;查无此键给 undefined,成员数是
size。 - 键按
===比较:改对象内容不影响命中;克隆体不是原对象;同对象换个变量名照命中。 - Map 迭代保插入序;
keys()/values()是迭代器不是数组;entry 是[键, 值]二元组。 - Object.entries/fromEntries 是对象与 Map 的双向桥(转回对象时键全变字符串)。
- WeakMap 的键是弱引用:键被回收条目自动消失——「给别人的对象挂元数据」的标准答案; 代价:只存对象、无 size、不可遍历。
- Set = 只有键的 Map:构造去重、add 重复静默无效、
has是 O(1);反复 includes 就该换 Set。 - 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」) |
-
出处:「10.2 Maps」第 12–25 段(
text/14-ch10-02-10-2-maps.txt:15,搜「pitfalls」)。对象字典两坑的原书陈述:原型继承干扰与键转字符串(array[1]与array["1"]等价见第 20–21 段)。 ↩ -
出处:「10.2 Maps」第 24–26 段(
text/14-ch10-02-10-2-maps.txt:24,搜「Maps were introduced in ES2015」)。Map 的两条保证:只装显式放入的、键可以是任何类型(含对象)。 ↩ -
出处:「10.2 Maps」第 90–97 段(
text/14-ch10-02-10-2-maps.txt:94,搜「implicit API contract」)。元数据放 Map 的理由:不可修改的对象契约、临时数据 + clear() 的便利。 ↩ -
出处:「10.2 Maps」第 102–130 段(
text/14-ch10-02-10-2-maps.txt:128,搜「if (and only if)」)。key1 === key2的总规则与 1!=="1" 的补充例子。 ↩ -
出处:「10.2 Maps」第 132–175 段(
text/14-ch10-02-10-2-maps.txt:140,搜「Listing 10.12」)。三种遍历循环与二元组解构。 ↩ -
出处:「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」)。插入序保证与迭代器省内存。 ↩ -
出处:「10.2 Maps」第 176–186 段(
text/14-ch10-02-10-2-maps.txt:176,搜「ENTRIES, MAPS, AND OBJECTS」)。Object.entries/fromEntries 双向桥及键转字符串撞车的警告。 ↩ -
出处:「10.2 Maps」第 195–210 段(
text/14-ch10-02-10-2-maps.txt:207,搜「come and go」)。泄漏构造:ninjas 被丢弃后仍被 Map 当键抓着。 ↩ -
出处:「10.2 Maps」第 211–214 段(
text/14-ch10-02-10-2-maps.txt:211,搜「The solution to this problem: WeakMap. The WeakMap」)。弱引用的资格规则与条目自动移除。 ↩ -
出处:「10.3 Sets」第 1–9 段(
text/15-ch10-03-10-3-sets.txt:3,搜「WeakMap can only use objects」)。三条限制清单。 ↩ -
出处:「10.3 Sets」第 7–9 段(
text/15-ch10-03-10-3-sets.txt:8,搜「only way to access its contents」)。不可遍历是最大限制;不可见内容即弱语义的代价。 ↩ -
出处:「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 模拟代码。 ↩ -
出处:「10.3 Sets」第 81–83 段(
text/15-ch10-03-10-3-sets.txt:81,搜「strong indicator」)。「反复 includes 就该用 Set」与 O(1) vs O(n) 的对照。 ↩ -
出处:「10.3 Sets」第 87–98 段(
text/15-ch10-03-10-3-sets.txt:88,搜「ES2024 added」)。四个集合运算方法的原书清单。 ↩ -
出处:「10.3 Sets」第 99–120 段(
text/15-ch10-03-10-3-sets.txt:105,搜「ninjaSamurais」)。交集示例与「more readable and more efficient」的新度提醒(第 116–118 段)。 ↩ -
出处:「10.3 Sets」第 122–139 段(
text/15-ch10-03-10-3-sets.txt:128,搜「trainedNinjas」)。WeakSet 毕业记录示例与三条同款限制。 ↩ -
出处:「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」)。三组可复现断言。 ↩