跳到主要内容

Set 和 Map:去重、映射与高效查找

当你遇到下面这些需求时,SetMap 往往比 ArrayObject 更合适:

  • 数组去重
  • 快速判断某个值是否存在
  • 统计词频 / 计数
  • 用对象、函数、DOM 节点当键
  • 需要“按插入顺序遍历”的键值结构

很多人知道它们“能用”,但不知道为什么该用、和 Array / Object 到底差在哪、有哪些坑

这篇文档的目标是:用尽量短的时间,把 Set、Map、WeakSet、WeakMap 的核心知识一次讲清楚。


1. 先建立整体认知:Set 和 Map 分别解决什么问题?

1.1 Set:一组“唯一值”

Set 本质上是一个值的集合,特点是:同一个值只能出现一次

const set = new Set([1, 2, 2, 3]);

console.log(set); // Set(3) {1, 2, 3}
console.log(set.size); // 3

典型用途:

  • 数组去重
  • “是否访问过”标记
  • 白名单 / 黑名单判断
  • 求并集、交集、差集

1.2 Map:更强的键值对结构

Map 本质上是一个键值对集合,但它比 Object 更灵活:

  • 键可以是任意类型,不只限于字符串
  • 保留插入顺序
  • 天然适合频次统计、缓存、索引表
const map = new Map();
const user = {id: 1};

map.set(user, '管理员');

console.log(map.get(user)); // 管理员

1.3 一张表看懂差异

结构存什么是否允许重复键类型是否保留插入顺序典型场景
Array一组值允许下标列表、顺序处理
Object键值对键不能重复字符串 / Symbol不适合当“严格有序映射”理解普通配置对象
Set一组值不允许值重复-去重、成员判断
Map键值对键不能重复任意类型字典、缓存、索引

2. Set 核心用法

2.1 创建与增删查

const set = new Set();

set.add(1);
set.add(2);
set.add(2);

console.log(set.size); // 2
console.log(set.has(1)); // true

set.delete(1);
console.log(set.has(1)); // false

set.clear();
console.log(set.size); // 0

常用 API:

  • new Set(iterable):创建集合
  • add(value):添加值
  • has(value):判断是否存在
  • delete(value):删除值
  • clear():清空
  • size:元素个数

2.2 最常见用途:数组去重

const nums = [1, 2, 2, 3, 3, 3];
const uniqueNums = [...new Set(nums)];

console.log(uniqueNums); // [1, 2, 3]

这是 Set 最经典的写法。

因为:

  1. Set 天然去重
  2. 展开运算符 ... 可以再转回数组

2.3 遍历 Set

const set = new Set(['a', 'b', 'c']);

for (const value of set) {
console.log(value);
}

set.forEach((value) => {
console.log(value);
});

注意:Set 遍历时拿到的就是“值本身”。

2.4 并集、交集、差集

JavaScript 面试里经常会让你手写这三个操作。

const a = new Set([1, 2, 3]);
const b = new Set([3, 4, 5]);

const union = new Set([...a, ...b]);
const intersection = new Set([...a].filter((x) => b.has(x)));
const difference = new Set([...a].filter((x) => !b.has(x)));

console.log(union); // Set(5) {1, 2, 3, 4, 5}
console.log(intersection); // Set(1) {3}
console.log(difference); // Set(2) {1, 2}

2.5 Set 判断“相等”时要注意什么?

Set 判断值是否重复,不是简单的 === 字面理解,而是基于 SameValueZero 规则。

你可以先记住两个结论:

  • NaNSet 里会被认为和自己相等
  • 对象看的是引用地址,不是内容
const set = new Set();

set.add(NaN);
set.add(NaN);
console.log(set.size); // 1

set.add({name: 'Tom'});
set.add({name: 'Tom'});
console.log(set.size); // 3

为什么后面会变成 3?因为这两个对象虽然内容一样,但它们是两个不同的引用

常见坑:Set 不能直接按“对象内容”去重

如果你要对对象数组按某个字段去重,通常要结合 Map 或额外的 key 处理,而不是直接 new Set(arrayOfObjects)


3. Map 核心用法

3.1 创建与增删查

const map = new Map();

map.set('name', 'Alice');
map.set('age', 18);

console.log(map.get('name')); // Alice
console.log(map.has('age')); // true
console.log(map.size); // 2

map.delete('age');
console.log(map.has('age')); // false

map.clear();
console.log(map.size); // 0

常用 API:

  • new Map(iterable):创建映射
  • set(key, value):设置键值
  • get(key):获取值
  • has(key):判断键是否存在
  • delete(key):删除键
  • clear():清空
  • size:键值对数量

3.2 为什么 MapObject 更灵活?

看一个最直接的例子:

const objKey = {id: 1};

const map = new Map();
map.set(objKey, '用户信息');

console.log(map.get(objKey)); // 用户信息

如果换成普通对象:

const objKey = {id: 1};
const data = {};

data[objKey] = '用户信息';

console.log(data); // { '[object Object]': '用户信息' }

原因是:Object 的键最终会被转成字符串,而 Map 不会。

3.3 遍历 Map

const map = new Map([
['name', 'Alice'],
['age', 18],
]);

for (const [key, value] of map) {
console.log(key, value);
}

console.log([...map.keys()]); // ['name', 'age']
console.log([...map.values()]); // ['Alice', 18]
console.log([...map.entries()]); // [['name', 'Alice'], ['age', 18]]

3.4 高频场景:统计出现次数

const words = ['js', 'ts', 'js', 'react', 'js', 'ts'];
const countMap = new Map();

for (const word of words) {
countMap.set(word, (countMap.get(word) || 0) + 1);
}

console.log(countMap);
// Map(3) {'js' => 3, 'ts' => 2, 'react' => 1}

这个模式非常常见:

  • 统计词频
  • 统计接口错误码次数
  • 统计用户点击次数

3.5 Map 与对象互转

const obj = {name: 'Alice', age: 18};

const map = new Map(Object.entries(obj));
console.log(map.get('name')); // Alice

const backToObj = Object.fromEntries(map);
console.log(backToObj); // { name: 'Alice', age: 18 }
怎么选?
  • 如果只是一个简单 JSON 风格对象,Object 很自然
  • 如果你需要频繁增删、遍历、保持插入顺序,或者键不是字符串,优先考虑 Map

4. Set / Map 为什么通常比 Array / Object 更适合查找?

先看一个直觉表:

操作ArraySetObjectMap
查某个值是否存在includes(),通常要线性扫描has(),通常更快in / hasOwnProperty()has(),通常更快
添加元素push()add()直接赋值set()
删除元素splice() / 过滤delete()delete obj[key]delete()
是否保序不适合当严格有序结构

一般可以这样理解:

  • Array 更擅长顺序处理
  • Set 更擅长成员判断与去重
  • Object 更像简单配置对象
  • Map 更适合真正的字典结构
关于复杂度

工程里通常把 Set.has()Map.get()Map.has() 理解为平均接近 O(1) 的查找。实际性能还会受到引擎实现、数据规模、键类型等影响,但在语义和使用场景上,它们就是为“高效查找”设计的。


5. WeakSetWeakMap:为什么前面要加 Weak?

WeakSetWeakMap 适合处理对象关联信息,并且不想因为这层关联而阻止垃圾回收。

5.1 WeakMap 特点

  • 键只能是对象
  • 键是“弱引用语义”
  • 不能遍历
  • 没有 size
const wm = new WeakMap();
let user = {id: 1};

wm.set(user, {lastVisit: Date.now()});

console.log(wm.get(user));

user = null;
// 之后如果没有别的地方再引用原对象,它就可以被垃圾回收

典型用途:

  • 给对象挂“私有元数据”
  • 缓存某个对象对应的计算结果
  • 避免因为缓存而造成内存泄漏

5.2 WeakSet 特点

  • 只能存对象
  • 成员是弱引用语义
  • 不能遍历
  • 没有 size
const visited = new WeakSet();
const obj = {};

visited.add(obj);
console.log(visited.has(obj)); // true

5.3 什么时候该用 Weak 版本?

如果你有这样的问题:

  • “我想给对象做标记,但对象销毁后我不想手动清理”
  • “我想缓存对象相关数据,但不想把对象强行留在内存里”

那就应该考虑 WeakMap / WeakSet


6. 实战场景

6.1 对象数组按字段去重

Set 不能直接按对象内容去重,但可以配合 Map 按某个字段去重。

const users = [
{id: 1, name: 'Alice'},
{id: 2, name: 'Bob'},
{id: 1, name: 'Alice-重复'},
];

const uniqueUsers = [...new Map(users.map((user) => [user.id, user])).values()];

console.log(uniqueUsers);
// [
// { id: 1, name: 'Alice-重复' },
// { id: 2, name: 'Bob' }
// ]

这个技巧非常实用。

6.2 做“访问过”标记

const visited = new Set();

function visit(nodeId) {
if (visited.has(nodeId)) return;

visited.add(nodeId);
console.log('处理节点:', nodeId);
}

这在图遍历、树遍历、路由去重、防重复请求中都很常见。

6.3 缓存计算结果

const cache = new Map();

function fib(n) {
if (n <= 1) return n;
if (cache.has(n)) return cache.get(n);

const value = fib(n - 1) + fib(n - 2);
cache.set(n, value);
return value;
}

这就是最基础的记忆化缓存思路。


7. 常见坑

7.1 Map 不能像对象一样用点语法

const map = new Map();
map.set('name', 'Alice');

console.log(map.name); // undefined
console.log(map.get('name')); // Alice

7.2 Set / Map 不能直接 JSON 序列化成你想要的样子

console.log(JSON.stringify(new Set([1, 2, 3]))); // {}
console.log(JSON.stringify(new Map([['a', 1]]))); // {}

如果要序列化,通常先转成数组:

const setJson = JSON.stringify([...new Set([1, 2, 3])]);
const mapJson = JSON.stringify([...new Map([['a', 1]])]);

7.3 Object 的“存在性判断”容易踩原型链坑

const obj = Object.create({fromProto: true});
obj.own = true;

console.log('fromProto' in obj); // true
console.log(Object.hasOwn(obj, 'fromProto')); // false

如果你的需求是真正的字典结构,Map 往往更省心。


8. 怎么选:一套实用判断法

  • 只是列表,允许重复,主要做顺序处理Array
  • 想去重 / 判断是否存在Set
  • 只是简单配置对象、JSON 数据Object
  • 需要真正的键值映射,尤其键不是字符串Map
  • 键必须是对象,且不想影响垃圾回收WeakMap
  • 只想给对象打标记,且不想影响垃圾回收WeakSet

你可以把它们记成一句话:

列表用 Array,去重用 Set,字典用 Map,简单配置用 Object,对象私有关系用 WeakMap / WeakSet。


高频面试题(含参考答案)

Q1:Set 和数组去重相比,优势是什么?

A: Set 天然保证值唯一,写法简洁,成员判断通常也比数组线性扫描更适合大规模数据场景。最常见写法是 const unique = [...new Set(arr)]

Q2:MapObject 的核心区别是什么?

A: Map 的键可以是任意类型,保留插入顺序,API 更适合做字典;Object 的键本质上主要是字符串 / Symbol,更适合表达普通配置数据。

Q3:为什么 new Set([{a: 1}, {a: 1}]) 不能去重?

A: 因为对象比较的是引用,不是内容。两个字面量对象虽然结构一样,但它们在内存里是两个不同对象,所以 Set 会把它们当成两个值。

Q4:WeakMap 为什么不能遍历?

A: 因为它的键是弱引用语义,键对象可能随时被垃圾回收。如果允许稳定遍历,就会和垃圾回收机制产生语义冲突,所以规范不提供遍历能力,也没有 size

Q5:Map 会保证遍历顺序吗?

A: 会。Map 按插入顺序遍历,这也是它和 Object 很重要的区别之一。

Q6:什么时候应该优先用 Set

A: 当你主要关注“某个值在不在”“值是否唯一”“是否访问过”这类问题时,优先考虑 Set

Q7:什么时候应该优先用 Map

A: 当你需要“键 → 值”的映射关系,并且希望键可以是任意类型、需要频繁增删查、希望遍历顺序稳定时,优先考虑 Map