Map、Set 与弱集合:按数据关系选择容器
普通对象擅长表示固定字段记录,数组擅长有序序列。若问题变成“每个用户对应一个统计值”或“某项是否出现过”,Map 和 Set 更直接。容器的区别在于键的规则、重复判定、遍历顺序和生命周期。
Map 保留键的类型和身份
const counts = new Map();
counts.set(1, '数字键');
counts.set('1', '字符串键');
const reader = { id: 1 };
counts.set(reader, 3);
console.log(counts.get(1), counts.get('1')); // 数字键 字符串键
console.log(counts.get(reader), counts.get({ id: 1 })); // 3 undefined
console.log(counts.size); // 3数字 1 与字符串 '1' 是不同键,对象键比较身份,不比较字段内容。get 找不到返回 undefined,若值允许为 undefined,用 has 区分存在性。map[key] = value 只是给 Map 对象添加普通属性,完全没有加入 Map 的键值集合。
set 返回 Map 自身,方便链式调用;delete 返回是否删到了,clear 清空全部,size 是条目数。遍历默认产出 [key, value],也可以用 keys/values/entries 或 forEach((value, key, map) => ...)。
const minutes = new Map([['值', 10], ['函数', 20]]);
minutes.set('值', 15);
console.log([...minutes]); // [['值', 15], ['函数', 20]]
minutes.delete('值');
minutes.set('值', 30);
console.log([...minutes.keys()]); // ['函数', '值']更新已有键不改变插入位置,删除再加入会移到末尾。普通对象的数组索引键会重排,Map 更适合要求纯插入顺序的映射。
Set 表达唯一成员
const seen = new Set(['值', '函数', '值']);
seen.add('对象');
console.log(seen.size, seen.has('值')); // 3 true
console.log([...seen]); // ['值', '函数', '对象']
console.log(new Set([NaN, NaN, 0, -0]).size); // 2Set 与 Map 的键一样使用 SameValueZero:NaN 可与自身视为相同,正负零合并,数字与字符串不合并。new Set([{ id: 1 }, { id: 1 }]) 仍有两项,因为对象不同。按业务 ID 去重需要明确取 id 作为键,而不是期待 Set 深比较。
Set 支持 add/has/delete/clear、size 与遍历;keys 和 values 相同,entries 产出 [value, value],forEach 前两个参数也是同一个值,这是接口对齐而非存了两个副本。
按 id 去重,还要决定保留哪一次
const notes = [
{ id: 'a', title: '旧标题' },
{ id: 'b', title: '第二篇' },
{ id: 'a', title: '新标题' },
];
const firstById = new Map();
const lastById = new Map();
for (const note of notes) {
if (!firstById.has(note.id)) firstById.set(note.id, note);
lastById.set(note.id, note);
}
console.log(firstById.get('a').title); // 旧标题
console.log(lastById.get('a').title); // 新标题
console.log([...lastById.keys()]); // ['a', 'b']两种策略都能被称为“去重”,结果却不同。第一种只在键首次出现时保存,第二种每次覆盖。第二种数据取最后一次,但顺序仍按键首次插入,因为更新现有键不移动位置。若需求是按最后出现时间排序,就不能只靠覆盖Map,应另行安排删除再插入或显式排序。
去重至少有三项约定:按什么判等、重复时保留什么、结果按什么顺序。Set只帮你表达唯一成员;它不会读取对象id,也不会替你决定冲突策略。
缓存也要区分“不存在”和“值恰好为undefined”:只要undefined是合法缓存结果,if (cache.get(key) === undefined) 就可能重复计算,应先用has判断。这与对象属性存在性问题是同一类数据建模问题。
集合运算表达关系
const learned = new Set(['值', '函数']);
const needed = new Set(['函数', '数组']);
console.log([...learned.intersection(needed)]); // ['函数']
console.log([...learned.union(needed)]); // ['值', '函数', '数组']
console.log([...needed.difference(learned)]); // ['数组']这些现代方法需由目标运行时支持:intersection 交集,union 并集,difference 差集,symmetricDifference 对称差;isSubsetOf/isSupersetOf/isDisjointFrom 返回关系判断。Node.js 22 基线下仍建议在实际目标小版本验证。旧环境可用 new Set([...a].filter(x => b.has(x))) 表达交集;不要为了兼容性改变相等语义。
弱集合关心“是否阻止回收”
const metadata = new WeakMap();
let reader = { name: '小林' };
metadata.set(reader, { visited: 2 });
console.log(metadata.get(reader).visited); // 2
reader = null;普通 Map 会强引用键;WeakMap 不会仅因键在其中就阻止对象被回收。最后一行之后,若没有其他强引用,对象具备被回收的条件,但不能声称它“此刻已经被删除”。垃圾回收时间不由程序保证。
WeakMap 没有 size、键枚举或 clear,否则外界可能观察到不确定的回收时机。它支持 get/set/has/delete。WeakSet 相应支持 add/has/delete,适合只记录对象是否已处理。
现代弱集合也允许非注册 Symbol,旧教程“只能是对象”不再完整;Symbol.for 取得的注册符号不允许。若用该能力,检查运行环境。较新 Map/WeakMap.getOrInsert、getOrInsertComputed 不属于本书 Node 22 的统一可运行假设;核心模式仍可以通过 has 加 set 表达,特性采用前先检测。
弱集合不会替你切断其他强引用
假设一个Map以读者对象为键保存附加信息:
仍在使用的Map → 键对象
→ 附加信息即使原来的reader变量已经设为null,只要Map仍可达,它就能通过keys等方法重新交出对象。对象当然还不能消失。WeakMap取消的是“仅因登记为键就保留对象”的这条关系,并不是把程序中的所有引用都变弱。
const info = new WeakMap();
let reader = { name: '小林' };
const savedReader = reader;
info.set(reader, { visits: 1 });
reader = null;
console.log(info.get(savedReader).visits); // 1savedReader仍然强引用键,对象仍可正常使用。如果附加信息内部反向引用键,而这份信息只通过同一个WeakMap条目可达,结构化的弱关联不会仅因这条回边让对象永远保留;但如果你又把附加信息保存到外部数组,外部数组就可能经附加信息重新强引用键。
所以WeakMap适合“附着在对象生命周期上的元数据”,不适合“希望枚举并持久保存的业务记录”。这种关系只能解释回收资格,不能写一个setTimeout断言对象几毫秒后必定消失;没有size也正是为了避免把回收时间变成可观察的业务数据。
复制容器不复制内部对象
const source = new Map([['progress', { page: 1 }]]);
const copy = new Map(source);
copy.get('progress').page = 2;
console.log(source.get('progress').page); // 2Map 和 Set 构造器可以复制容器条目,但对象键和值仍共享身份。Object.fromEntries(map) 只适合键能按对象键语义转换的数据;数字 1 与字符串 '1' 会合并,对象键会转字符串,不能当无损桥梁。JSON 默认也不保留 Map/Set 内容,最后一篇会解释。
练习
对
['a', 'b', 'a']用 Map 统计次数,保留首次出现顺序。解释 Set 为什么不能按对象的 id 自动去重,以及 WeakMap 为什么没有 size。
按id去重时,每次Map.set覆盖会保留最后数据还是最后顺序?对象进入WeakMap后,外部数组仍保存它,能否因此被回收?