1. 为什么我们需要Set和Map?
在ES5时代,JavaScript开发者处理集合数据时主要依赖Array和Object这两种数据结构。但随着前端应用复杂度不断提升,这两种数据结构在某些场景下显得力不从心。比如:
- 需要快速判断某个值是否存在于集合中时,Array的includes()或indexOf()方法需要遍历整个数组,时间复杂度为O(n)
- 当使用Object作为键值对集合时,键名只能是字符串或Symbol,无法使用对象作为键
- 需要维护元素插入顺序时,Object的键遍历顺序并不总是可靠
ES6引入的Set和Map正是为了解决这些问题而生的。它们不仅提供了更高效的API,还带来了更符合直觉的语义表达。在实际项目中,合理选择Set或Map往往能让代码更简洁、性能更优。
提示:虽然Set和Map是ES6规范的一部分,但它们的polyfill实现可以追溯到更早。现代浏览器和Node.js环境都已原生支持,无需额外引入库。
1.1 Set的核心特性
Set是一种值的集合,其核心特点包括:
- 成员值唯一,没有重复
- 插入顺序即遍历顺序
- 键和值相同(Set没有单独的键概念)
javascript复制const uniqueNumbers = new Set();
uniqueNumbers.add(1);
uniqueNumbers.add(2);
uniqueNumbers.add(1); // 重复添加无效
console.log(uniqueNumbers.size); // 2
console.log([...uniqueNumbers]); // [1, 2]
1.2 Map的核心特性
Map是键值对的集合,与Object的主要区别在于:
- 键可以是任意类型(包括对象)
- 维护键值对的插入顺序
- 提供更直观的size属性
- 针对频繁增删操作做了优化
javascript复制const userMap = new Map();
const user1 = { id: 1 };
const user2 = { id: 2 };
userMap.set(user1, 'Alice');
userMap.set(user2, 'Bob');
console.log(userMap.get(user1)); // 'Alice'
console.log(userMap.size); // 2
2. Set与Map的相同点剖析
虽然Set和Map解决的问题不同,但它们共享了许多底层实现和API设计理念。理解这些共性有助于我们更深入地掌握它们的特性。
2.1 基于哈希表的实现机制
Set和Map在大多数JavaScript引擎中都是基于哈希表实现的,这带来了几个共同特性:
- 查找效率高:平均情况下,has()、get()等操作的时间复杂度为O(1)
- 元素唯一性:Set的值和Map的键都是唯一的
- 引用相等判断:使用SameValueZero算法比较键/值(类似于===,但认为NaN等于NaN)
javascript复制const map = new Map();
map.set(NaN, 'Not a Number');
console.log(map.get(NaN)); // 'Not a Number' (NaN可以作为键)
const set = new Set();
set.add(NaN);
set.add(NaN);
console.log(set.size); // 1 (NaN被认为是相同的)
2.2 一致的迭代协议
Set和Map都实现了可迭代协议,提供了三种迭代器方法:
- keys():返回键的迭代器(对Set来说,keys()与values()相同)
- values():返回值的迭代器
- entries():返回键值对迭代器(对Set来说,每个条目是[value, value])
javascript复制const colorSet = new Set(['red', 'green', 'blue']);
// 三种迭代方式等价
for (let color of colorSet) { console.log(color); }
for (let color of colorSet.values()) { console.log(color); }
for (let [key, value] of colorSet.entries()) { console.log(value); }
2.3 相同的初始化语法
两者都支持通过可迭代对象初始化:
javascript复制// Set初始化
const setFromArray = new Set([1, 2, 3]);
const setFromString = new Set('hello'); // {'h', 'e', 'l', 'o'}
// Map初始化
const mapFromArray = new Map([
['key1', 'value1'],
['key2', 'value2']
]);
2.4 共有的API设计
Set和Map共享以下核心方法:
| 方法 | Set | Map |
|---|---|---|
| 添加元素 | add(value) | set(key, value) |
| 删除元素 | delete(value/key) | delete(key) |
| 检查存在 | has(value) | has(key) |
| 清空集合 | clear() | clear() |
| 获取大小 | size属性 | size属性 |
3. Set与Map的核心差异详解
虽然Set和Map有许多相似之处,但它们的设计目的和使用场景有本质区别。理解这些差异是正确选型的关键。
3.1 数据结构本质差异
Set本质上是值的集合,关注的是"这个值是否存在"。它更像是数学上的集合(Set)概念,适用于需要保证元素唯一性的场景。
Map则是键值对的映射集合,关注的是"如何通过键找到对应的值"。它扩展了Object的键值对概念,提供了更灵活的键类型和更可靠的遍历顺序。
javascript复制// Set示例:追踪已访问的页面
const visitedPages = new Set();
function visitPage(url) {
if (visitedPages.has(url)) return;
visitedPages.add(url);
// 记录访问逻辑...
}
// Map示例:建立对象到元数据的映射
const metadataMap = new Map();
function attachMetadata(obj, metadata) {
metadataMap.set(obj, metadata);
}
3.2 性能特性对比
虽然两者都基于哈希表,但由于用途不同,在某些操作上性能表现有差异:
-
插入性能:
- Set的add()通常比Map的set()稍快,因为不需要处理值存储
- 实测:在Chrome中,Set.add()比Map.set()快约15-20%
-
查找性能:
- 简单值查找:Set.has()略快于Map.has()
- 复杂对象键查找:两者性能相当
-
内存占用:
- 存储相同数量的元素时,Map通常比Set多占用20-30%内存
- 原因:Map需要额外存储值引用
注意:这些性能差异在大多数应用中影响不大,只有在极端性能敏感场景才需要考虑。
3.3 使用模式差异
Set的典型使用模式:
- 去重:数组去重、事件去重等
- 集合运算:并集、交集、差集
- 存在性检查:白名单/黑名单过滤
javascript复制// 数组去重
const uniqueArray = [...new Set([1, 2, 2, 3])]; // [1, 2, 3]
// 集合运算
const setA = new Set([1, 2, 3]);
const setB = new Set([2, 3, 4]);
const union = new Set([...setA, ...setB]); // {1, 2, 3, 4}
const intersection = new Set([...setA].filter(x => setB.has(x))); // {2, 3}
Map的典型使用模式:
- 对象关联数据存储
- 缓存实现
- 需要非字符串键的字典
- 维护插入顺序的键值集合
javascript复制// 对象元数据存储
const privateData = new Map();
function getPrivateData(obj) {
if (!privateData.has(obj)) {
privateData.set(obj, {});
}
return privateData.get(obj);
}
// LRU缓存简单实现
class SimpleLRU {
constructor(limit = 100) {
this.limit = limit;
this.cache = new Map();
}
get(key) {
if (!this.cache.has(key)) return undefined;
const value = this.cache.get(key);
this.cache.delete(key);
this.cache.set(key, value);
return value;
}
set(key, value) {
if (this.cache.size >= this.limit) {
const oldestKey = this.cache.keys().next().value;
this.cache.delete(oldestKey);
}
this.cache.set(key, value);
}
}
4. 实战选型指南与性能优化
在实际项目中,如何正确选择Set或Map?以下是我总结的决策流程图和优化建议。
4.1 选型决策流程图
code复制是否需要存储键值对?
├─ 是 → 键是否需要是非字符串类型?
│ ├─ 是 → 使用Map
│ └─ 否 → 考虑Object或Map(如果需要有序遍历选Map)
└─ 否 → 是否需要保证值唯一?
├─ 是 → 使用Set
└─ 否 → 使用Array
4.2 Set性能优化技巧
- 初始化时去重:如果源数据可能有重复,直接在构造函数中传入数组,比逐个add()快30-50%
javascript复制// 慢
const slowSet = new Set();
for (let item of array) slowSet.add(item);
// 快
const fastSet = new Set(array);
-
避免频繁转换:在Set和Array之间转换有开销,尽量保持在单一数据类型中操作
-
大规模数据预分配:某些引擎(如V8)对预先知道大小的Set有优化
javascript复制// 预分配大小(非标准API,但某些引擎有效)
const bigSet = new Set();
bigSet.size = 1e6; // 非标准,仅示例
4.3 Map性能优化技巧
-
键选择原则:
- 优先使用原始类型(string、number等)作为键
- 使用对象作为键时,尽量保持对象引用稳定
- 避免使用频繁变动的对象作为键
-
批量操作优化:
- 批量设置数据时,使用数组初始化比多次set()快2-3倍
- 批量删除时,先收集要删除的键,然后一次性处理
javascript复制// 批量设置优化
const entries = [['k1', 'v1'], ['k2', 'v2']];
const map1 = new Map(entries); // 快
const map2 = new Map(); // 慢
for (let [k, v] of entries) map2.set(k, v);
- 内存管理:
- 不再使用的Map应及时清空或置为null
- 对于长期存在的Map,定期清理无用条目防止内存泄漏
4.4 实际案例解析
案例1:前端路由权限控制
需求:根据用户角色控制可访问的路由
javascript复制// 使用Set的方案
const adminRoutes = new Set(['/dashboard', '/settings']);
const userRoutes = new Set(['/home', '/profile']);
function canAccess(route, role) {
const allowedRoutes = role === 'admin'
? adminRoutes
: userRoutes;
return allowedRoutes.has(route);
}
案例2:组件实例管理
需求:跟踪所有创建的组件实例并支持快速查找
javascript复制// 使用Map的方案
const componentRegistry = new Map();
class MyComponent {
constructor(id) {
this.id = id;
componentRegistry.set(id, this);
}
static findById(id) {
return componentRegistry.get(id);
}
destroy() {
componentRegistry.delete(this.id);
}
}
案例3:数据缓存层实现
需求:实现带过期时间的缓存系统
javascript复制class TimedCache {
constructor() {
this.cache = new Map();
this.timers = new Map();
}
set(key, value, ttl = 1000) {
this.clearTimeout(key);
this.cache.set(key, value);
this.timers.set(
key,
setTimeout(() => this.delete(key), ttl)
);
}
get(key) {
return this.cache.get(key);
}
delete(key) {
this.clearTimeout(key);
this.cache.delete(key);
}
clearTimeout(key) {
if (this.timers.has(key)) {
clearTimeout(this.timers.get(key));
this.timers.delete(key);
}
}
}
5. 高级用法与边界情况处理
掌握了基础用法后,我们来看一些高级技巧和常见问题的解决方案。
5.1 自定义值/键的相等性比较
默认情况下,Set和Map使用SameValueZero算法比较键/值。如果需要自定义相等逻辑,可以考虑以下模式:
javascript复制// 自定义相等的Set实现
class DeepEqualSet {
constructor() {
this.set = new Set();
this.keys = [];
}
add(obj) {
const key = JSON.stringify(obj);
if (!this.keys.includes(key)) {
this.keys.push(key);
this.set.add(obj);
}
return this;
}
has(obj) {
return this.keys.includes(JSON.stringify(obj));
}
}
const deepSet = new DeepEqualSet();
deepSet.add({ a: 1 });
console.log(deepSet.has({ a: 1 })); // true
5.2 弱引用版本:WeakSet和WeakMap
ES6还提供了弱引用版本的WeakSet和WeakMap,它们的特点是:
- 键必须是对象(不能是原始值)
- 不阻止垃圾回收(当键对象没有其他引用时会被自动回收)
- 不可枚举/迭代
- 没有size属性
典型使用场景:
- 存储对象的私有数据
- 维护对象关联的元数据
- 实现对象监听而不引起内存泄漏
javascript复制// WeakMap实现私有属性模式
const privateData = new WeakMap();
class Person {
constructor(name) {
privateData.set(this, { name });
}
getName() {
return privateData.get(this).name;
}
}
const person = new Person('Alice');
console.log(person.getName()); // 'Alice'
console.log(privateData.get(person)); // { name: 'Alice' }
5.3 序列化与反序列化
Set和Map默认的JSON序列化结果是空对象,需要特殊处理:
javascript复制// Set序列化
const set = new Set([1, 2, 3]);
const serializedSet = JSON.stringify([...set]);
const deserializedSet = new Set(JSON.parse(serializedSet));
// Map序列化
const map = new Map([['a', 1], ['b', 2]]);
const serializedMap = JSON.stringify([...map]);
const deserializedMap = new Map(JSON.parse(serializedMap));
5.4 常见问题与解决方案
问题1:如何合并多个Set/Map?
javascript复制// 合并Set
const set1 = new Set([1, 2]);
const set2 = new Set([2, 3]);
const mergedSet = new Set([...set1, ...set2]); // {1, 2, 3}
// 合并Map(后者覆盖前者)
const map1 = new Map([['a', 1], ['b', 2]]);
const map2 = new Map([['b', 3], ['c', 4]]);
const mergedMap = new Map([...map1, ...map2]); // {'a'=>1, 'b'=>3, 'c'=>4}
问题2:如何实现LRU缓存?
javascript复制class LRUCache {
constructor(limit) {
this.limit = limit;
this.map = new Map();
}
get(key) {
if (!this.map.has(key)) return undefined;
const value = this.map.get(key);
this.map.delete(key);
this.map.set(key, value);
return value;
}
set(key, value) {
if (this.map.size >= this.limit && !this.map.has(key)) {
this.map.delete(this.map.keys().next().value);
}
this.map.delete(key);
this.map.set(key, value);
}
}
问题3:如何实现观察者模式?
javascript复制class Observable {
constructor() {
this.observers = new Set();
}
subscribe(observer) {
this.observers.add(observer);
return () => this.observers.delete(observer);
}
notify(data) {
this.observers.forEach(observer => observer(data));
}
}
const observable = new Observable();
const unsubscribe = observable.subscribe(console.log);
observable.notify('Hello!'); // 输出 'Hello!'
unsubscribe();
在实际项目中,我经常发现开发者过度使用Array和Object,而忽略了Set和Map的优势。特别是在需要频繁检查存在性或使用对象作为键的场景,转换到Set/Map往往能带来显著的性能提升和更清晰的代码表达。
