第3章 3.4 Set/Map/WeakMap 集合

上一章我们学会了用 ES6 Class 给数据「打包封装」,就像把散装水果装进包装盒。但装完之后,问题来了:盒子多了,怎么快速找到其中一个?怎么判断两颗钻石是不是同一颗?怎么确保某个私密属性只有我自己能访问?

这一章,我们来认识三个「高级盒子」—— Set、Map、WeakMap。它们是 JavaScript 的三种特殊集合,专门解决「找东西」「存关系」「藏秘密」的问题。学会了它们,你的数据操作直接从「杂货铺」升级到「精品店」。


🎯 开场 3 分钟:为什么要学这个?

场景带入

想象你开了一家水果店:

  • 痛点 1:顾客说「我要买苹果」,你怎么快速知道「有没有苹果」和「还剩几个」?总不能一个个数吧?
  • 痛点 2:老客户的信息怎么存?「张三,买了 3 次苹果,欠款 50 块」—— 用普通数组怎么优雅地表示这种「对应关系」?
  • 痛点 3:有个员工要存一个「只属于他」的秘密属性,不想让别人看到,怎么办?

这些问题,普通数组和对象解决起来很\n\nSimple tech illustration expla\n\nAI comic creation scene, creat\n\n别扭,但 Set、Map、WeakMap 只需要几句话就能搞定。

学会之后你能

  • Set 给数组去重、判断是否存在某元素
  • Map 存储「键值对」关系,并快速查询
  • WeakMap 实现真正的「私有属性」
  • 理解 JavaScript 的垃圾回收机制,不再内存泄漏

🧱 基础 25 分钟:核心概念

3.4.1 Set:去重神器

是什么?

Set 就是一个「不重复的数组」。就像你去超市存包,每个包都有一个专属号码牌,同一个号码只能出现一次

为什么要用?

想象你有一个「已购用户」列表,同一个用户可能重复购买多次,但你想知道「到底有多少独立用户」—— 用 Set 自动去重,10 年老粉只算一次。

怎么用?

// 创建一个 Set
const fruits = new Set();

// 添加元素(add)
fruits.add('苹果');
fruits.add('香蕉');
fruits.add('苹果');  // 重复添加,实际只存一个

console.log(fruits);        // Set(2) {'苹果', '香蕉'}
console.log(fruits.size);   // 2,有多少种水果
// 从数组创建(自动去重)
const orders = ['张三', '李四', '王五', '张三', '李四'];
const uniqueCustomers = new Set(orders);

console.log(uniqueCustomers);  // Set(3) {'张三', '李四', '王五'}

代码解释
- 第 1 行:创建空 Set
- 第 2-4 行:用 add() 添加水果,'苹果' 加了两次但只保留一个
- 第 7 行:直接从数组构建,'张三''李四' 被自动去除

Set 的常用方法

const fruits = new Set(['苹果', '香蕉', '橙子']);

// 判断是否存在(has)
console.log(fruits.has('苹果'));  // true
console.log(fruits.has('葡萄'));  // false

// 删除元素(delete)
fruits.delete('香蕉');
console.log(fruits);  // Set(2) {'苹果', '橙子'}

// 遍历(forEach 或 for...of)
fruits.forEach(fruit => {
console.log('我有:' + fruit);
});

for (const fruit of fruits) {
console.log('库存:' + fruit);
}

代码解释
- has() 就像问「有这个水果吗?」,返回 true/false
- delete() 删除特定元素
- 遍历方式和数组一样,但元素不重复


3.4.2 Map:键值对字典

是什么?

Map 就是「可以存任意类型键」的字典。普通对象的键只能是字符串,但 Map 可以用数字、对象、函数做键。

类比:就像一个高级名片夹,每个人名(键)对应一张名片(值),名片夹知道每个人的所有信息。

为什么要用?

想象你要存「员工 ID → 员工信息」:

  • 用普通对象:{'001': {name: '张三', age: 25}} —— 可以,但键会被转成字符串
  • 用 Map:Map {[1] => {name: '张三'}, [2] => {name: '李四'}} —— 更直观,且支持任意类型键

怎么用?

// 创建 Map
const userMap = new Map();

// 设置键值对(set)
userMap.set('张三', {年龄: 25, 职业: '老师'});
userMap.set('李四', {年龄: 30, 职业: '医生'});
userMap.set(10086, {年龄: 28, 职业: '工程师'});  // 数字做键,完全 OK

console.log(userMap);
console.log(userMap.size);  // 3
// 获取值(get)
const info = userMap.get('张三');
console.log(info);  // {年龄: 25, 职业: '老师'}

// 判断键是否存在(has)
console.log(userMap.has('李四'));  // true
console.log(userMap.has('王五'));  // false

// 删除(delete)
userMap.delete(10086);
console.log(userMap.size);  // 2

代码解释
- set(key, value) 存数据,get(key) 取数据
- 键可以是字符串、数字、对象——普通对象做不到
- size 属性直接告诉你有多少条记录

Map 的遍历

const userMap = new Map([
['张三', {年龄: 25, 职业: '老师'}],
['李四', {年龄: 30, 职业: '医生'}],
]);

// 遍历所有键值对
for (const [name, info] of userMap) {
console.log(name + '是' + info.职业);
}

// 用 forEach
userMap.forEach((info, name) => {
console.log(`${name}: ${info.年龄}岁`);
});

3.4.3 WeakMap:私密保险箱

是什么?

WeakMap 是「键是对象且没有强引用」的 Map。听起来很拗口,听我讲个故事:

想象你在健身房存包:
- 普通 Map:你把包存进去,但健身房保留了「你存过包」的记录(强引用),即使你拿走包,健身房还记得
- WeakMap:你把包存进去,健身房不保留记录,只有你自己能打开(弱引用)。包一旦被你拿走,健身房完全不记得这回事

「弱引用」的意思是:如果一个对象只被 WeakMap 引用,没有任何其他地方引用它,垃圾回收器会直接把它清理掉

为什么要用?

WeakMap 最大用途是:存对象的「私有属性」

// 不用 WeakMap,给对象加个「隐藏」属性
const user = { name: '张三' };
user._private = '这是我不想让别人看到的秘密';  // 任何人都能访问到

console.log(user._private);  // 能访问!不安全
// 用 WeakMap 实现真正的私有
const privateData = new WeakMap();

const user = { name: '张三' };
privateData.set(user, '这是我只有自己能访问的秘密');

console.log(privateData.get(user));  // 只有我有 WeakMap,才能拿到
console.log(user._private);          // undefined,根本不知道有秘密

代码解释
- WeakMap 的键只能是对象(不能是字符串、数字)
- privateData 存在外部,只有知道这个 WeakMap 的人才能访问对应的值
- user 对象本身完全干净,不知道自己有任何秘密

WeakMap 的限制

const weak = new WeakMap();

// 可以 set
const obj = { id: 1 };
weak.set(obj, '私有数据');

// 可以 get、has、delete
console.log(weak.get(obj));   // '私有数据'
console.log(weak.has(obj));   // true
weak.delete(obj);             // 删除这条记录

// ❌ 不能遍历!没有 forEach、for...of、keys()、values()
weak.forEach(...)  // 报错!TypeError

// ❌ 不能用 size(因为不确定里面有多少)
console.log(weak.size)  // undefined

为什么这样设计? 因为 WeakMap 是为了「私有」设计的,不应该被遍历。如果你需要遍历,用普通 Map。


3.4.4 三者对比

特性 Set Map WeakMap
用途 存「不重复的值」 存「键值对」 存「对象的私有数据」
键的要求 值不重复 键可以是任意类型 键只能是对象
重复处理 自动去重 键重复会覆盖 键重复会覆盖
遍历 可以 可以 不可以
内存管理 手动删除 手动删除 自动回收(垃圾回收)
典型场景 去重、存在判断 字典查询、配置存储 私有属性、缓存

🔥 实战 35 分钟:3 个递进的小项目

项目 1(5 分钟):统计「不重复用户」

场景:你有一个用户行为日志,要统计有多少独立用户访问过。

// 用户访问日志(模拟数据)
const visitLog = [
'用户A',
'用户B',
'用户A',  // 重复访问
'用户C',
'用户B',  // 重复访问
'用户D',
'用户A'   // 第三次访问
];

// 用 Set 去重
const uniqueUsers = new Set(visitLog);

console.log('独立用户数:' + uniqueUsers.size);  // 4
console.log('用户列表:');
uniqueUsers.forEach(user => {
console.log(' - ' + user);
});

预期输出

独立用户数:4
用户列表:
- 用户A
- 用户B
- 用户C
- 用户D

一句话解释:Set 自动过滤掉重复的 '用户A''用户B'size 直接告诉你「去重后还剩几个」。


项目 2(15 分钟):学生成绩查询系统

场景:从 JSON 数据加载学生成绩,用 Map 实现「输入学号 → 秒查成绩」。

// 模拟从数据库加载的学生数据
const studentData = [
{ id: '2024001', name: '张小明', scores: { 数学: 92, 语文: 88, 英语: 95 } },
{ id: '2024002', name: '李小红', scores: { 数学: 78, 语文: 85, 英语: 80 } },
{ id: '2024003', name: '王大力', scores: { 数学: 95, 语文: 91, 英语: 88 } },
];

// 构建 Map:学号 → 学生信息
const studentMap = new Map();

studentData.forEach(student => {
studentMap.set(student.id, student);
});

console.log('学生人数:' + studentMap.size);  // 3

// 快速查询
function 查询成绩(学号) {
const student = studentMap.get(学号);
if (!student) {
console.log('查无此人');
return;
}

console.log(`【${student.name}】的成绩:`);
const { 数学, 语文, 英语 } = student.scores;
console.log(`  数学:${数学}分`);
console.log(`  语文:${语文}分`);
console.log(`  英语:${英语}分`);

const avg = (数学 + 语文 + 英语) / 3;
console.log(`  平均:${avg.toFixed(1)}分`);
}

// 测试查询
查询成绩('2024001');
console.log('---');
查询成绩('2024005');  // 不存在的学号

预期输出

学生人数:3
【张小明】的成绩:
数学:92分
语文:88分
英语:95分
平均:91.7分
---
查无此人

一句话解释:把数组转成 Map 后,查询从 O(n) 变成 O(1),无论查 100 个还是 10000 个学生,秒回。


项目 3(15 分钟):带缓存的「敏感信息管理系统」

场景:模拟一个用户系统,有些敏感操作需要「验证后才能查看」,用 WeakMap 存私有验证状态。

// 用 WeakMap 存储每个用户的「验证状态」和「私有令牌」
const privateStore = new WeakMap();
const userCache = new WeakMap();

// 创建用户(模拟注册)
function 创建用户(name, isVIP) {
const user = { name, isVIP };
// 初始状态:未验证
privateStore.set(user, {
verified: false,
token: Math.random().toString(36).substring(2),  // 随机令牌
lastLogin: new Date().toLocaleString()
});
return user;
}

// 验证用户(只有验证过才能查看敏感信息)
function 验证用户(user) {
const privateData = privateStore.get(user);
if (!privateData) {
console.log('用户不存在');
return false;
}
privateData.verified = true;
console.log(`✅ ${user.name} 验证成功`);
return true;
}

// 查看用户敏感信息(需要先验证)
function 查看敏感信息(user) {
const privateData = privateStore.get(user);

if (!privateData) {
console.log('用户不存在');
return;
}

if (!privateData.verified) {
console.log('❌ 请先验证身份');
return;
}

console.log(`📋 ${user.name} 的敏感信息:`);
console.log(`   令牌:${privateData.token}`);
console.log(`   上次登录:${privateData.lastLogin}`);
console.log(`   VIP身份:${user.isVIP ? '是' : '否'}`);
}

// 测试流程
const user1 = 创建用户('张三', true);
const user2 = 创建用户('李四', false);

console.log('=== 直接查看(未验证)===');
查看敏感信息(user1);
查看敏感信息(user2);

console.log('\n=== 验证后查看 ===');
验证用户(user1);
验证用户(user2);
查看敏感信息(user1);
查看敏感信息(user2);

预期输出

=== 直接查看(未验证)===
❌ 请先验证身份
❌ 请先验证身份

=== 验证后查看 ===
✅ 张三 验证成功
✅ 李四 验证成功
📋 张三 的敏感信息:
牌:a3f8b2c1...
次登录:2024/1/15 14:30:00
IP身份:是
📋 李四 的敏感信息:
牌:b7c4d9e2...
次登录:2024/1/15 14:30:00
IP身份:否

一句话解释:WeakMap 保证了「令牌」「验证状态」这些私密数据只有持有 user 对象的人才能访问,而且 user 被垃圾回收后,对应的私密数据也会自动消失(不会内存泄漏)。


💪 进阶 20 分钟:常见坑 + 性能小贴士

坑 1:Set/Map 的键是「严格相等」

// ❌ 错误:以为 1 和 '1' 是同一个键
const map1 = new Map();
map1.set('1', '值1');
map1.set(1, '值2');
console.log(map1.get('1'));  // '值1',不是 '值2'!
console.log(map1.size);      // 2,两个不同的键

// ✅ 正确:同类型才被认为是同一个键
const map2 = new Map();
map2.set('name', '张三');
map2.set('name', '李四');  // 覆盖
console.log(map2.get('name'));  // '李四'

坑 2:遍历 Map 时修改 Map 本身

// ❌ 错误:边遍历边添加,可能死循环
const map = new Map([['a', 1], ['b', 2]]);
for (const [key, value] of map) {
map.set(key + key, value * 10);  // 无限添加!
}

// ✅ 正确:先收集要处理的键
const map = new Map([['a', 1], ['b', 2]]);
const keys = [...map.keys()];
for (const key of keys) {
map.set(key + key, map.get(key) * 10);
}
console.log(map);

坑 3:WeakMap 的键必须是对象

// ❌ 错误:字符串不能做 WeakMap 的键
const weak = new WeakMap();
weak.set('name', '张三');  // 报错!TypeError

// ✅ 正确:只能用对象做键
const obj = { id: 1 };
const weak = new WeakMap();
weak.set(obj, '私有数据');

坑 4:Set 不是数组

// ❌ 错误:以为 Set 有数组的方法
const set = new Set([1, 2, 3]);
console.log(set.push(4));  // 报错!Set 没有 push

// ✅ 正确:Set 用 add 添加,用 [...set] 转成数组
const set = new Set([1, 2, 3]);
set.add(4);
console.log([...set]);  // [1, 2, 3, 4]

坑 5:忘记 Map 的 size 是属性不是方法

// ❌ 错误:把 size 当方法调用
const map = new Map([['a', 1]]);
console.log(map.size());  // undefined

// ✅ 正确:size 是属性
console.log(map.size);  // 1

性能小贴士:大量数据查询用 Map 代替数组

// ❌ 低效:每次查询都遍历数组
const users = [{id: '001', name: '张三'}, {id: '002', name: '李四'}, /* 1000个... */];
function findUser(id) {
return users.find(u => u.id === id);  // O(n),1000个就慢
}

// ✅ 高效:一次建索引,永久 O(1) 查询
const userMap = new Map();
users.forEach(u => userMap.set(u.id, u));
function findUser(id) {
return userMap.get(id);  // O(1),无论多少都飞快
}

调试技巧:用 console.table() 查看 Map/Set

const map = new Map([
['数学', 95],
['语文', 88],
['英语', 92]
]);

const set = new Set(['苹果', '香蕉', '橙子']);

// 普通 console
console.log(map);   // Map(3) {'数学' => 95, ...}
console.log(set);    // Set(3) {'苹果', '香蕉', '橙子'}

// 用 table 更清晰
console.table([...map.entries()]);  // 表格形式显示键值对
console.table([...set]);             // 表格形式显示集合内容

✏️ 练习题 + 作业题

练习题(5 道,10 分钟内完成)

练习 1(2 分钟):Set 去重
- 输入:['红', '蓝', '红', '绿', '蓝', '红']
- 预期输出:独立颜色数量 3
- 提示:用 new Set().size

练习 2(2 分钟):Map 键值查询
- 输入:Map {'name': '小明', 'age': 18}
- 预期输出:判断 'name' 是否存在 → true
- 提示:记得用 .has() 方法

练习 3(2 分钟):判断 Set 成员
- 输入:Set {1, 2, 3},判断是否存在 4
- 预期输出:false
- 提示:has() 方法返回布尔值

练习 4(3 分钟):给 Map 加新键
- 输入:已有的 Map {'a': 1, 'b': 2},添加 'c': 3
- 预期输出:Map 大小变成 3
- 提示:用 set() 添加新键值对不会覆盖原有的

练习 5(3 分钟):找错
- 题目:下面代码想统计不重复单词,但报错

const words = new Set('apple banana apple cherry banana');
console.log(words.size);  // 报错!
  • 预期输出:报错说 words.size is undefined
  • 提示:Set 构造函数接受数组,不接受字符串。字符串会被当成可迭代对象逐字符拆分

作业题(30 分钟 - 2 小时)

作业:做一个「投票统计系统」

需求描述:模拟一次投票活动,统计每个候选人的票数,并输出排名。

功能点
1. 用 Map 存储候选人 → 票数的映射
2. 投票记录用 Set 去重(同一选民不能重复投)
3. 用 WeakMap 记录每个候选人的「私密备注」(只有创建者能看)
4. 输出每个候选人的得票数和排名

加分项
1. 支持「查看某个候选人的私密备注」(需要验证)
2. 用 console.table() 展示结果

验收标准
- 能跑起来,输入测试数据后输出正确排名
- 代码有适当注释
- 演示 WeakMap 的「私有」特性

提交方式:评论区贴代码或 GitHub 链接


📚 总结 + 资源

一句话总结

本文学了三个「高级容器」:Set 自动去重Map 键值查询WeakMap 存私密属性——让数据操作从「土法炼钢」变成「机械化生产」。

延伸学习资源

  1. MDN Set 官方文档 —— 最权威的 API 参考
  2. MDN Map 官方文档 —— 有更多进阶用法
  3. 《JavaScript 高级程序设计》第 4 版,第 6 章「集合」—— 系统学习必读

互动钩子

你在实际项目里用过 Set 或 Map 吗? 比如去重、缓存、配置管理……评论区聊聊你的场景,老粉优先回复!


📌 下章预告:学会了集合三兄弟,下一章我们要用一个「学生成绩管理系统」把它们串起来——Set 去重、Map 查询、WeakMap 藏秘密,一个都不少!

声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理。