你是怎么理解ES6新增Set、Map两种数据结构的?
问题解析
这个问题考察对ES6新增数据结构Set和Map的理解。需要掌握它们的区别、基本操作、遍历方法、特殊变体(WeakSet、WeakMap)以及实际应用场景。重点在于理解它们的特性如何解决了传统对象和数组的局限性。
核心概念
1. Set与Map的本质区别
- Set:集合,值-值存储,值唯一
- Map:字典,键-值存储,键可以是任意类型
- 对比Object:Object键只能是字符串或Symbol,Map键可以是任意类型
2. 核心特性
- Set:自动去重、判断值存在性O(1)
- Map:保持插入顺序、任意类型键、size属性
- WeakSet/WeakMap:弱引用、不阻止垃圾回收、无遍历方法
3. 使用场景
- Set:去重、集合运算、成员判断
- Map:频繁增删键值对、非字符串键、保持顺序
- WeakMap:DOM元数据存储、私有属性实现
详细解答
Set基本操作
// 1. 创建Set
const set1 = new Set();
const set2 = new Set([1, 2, 3, 3, 3]); // {1, 2, 3}
const set3 = new Set('hello'); // {'h', 'e', 'l', 'o'}
// 2. 添加元素
const set = new Set();
set.add(1);
set.add(2).add(3); // 链式调用
// 3. 删除元素
set.delete(2); // true(删除成功)
set.delete(100); // false(不存在)
// 4. 判断存在
set.has(1); // true
set.has(100); // false
// 5. 清空
set.clear();
// 6. 获取大小
console.log(set.size);
// 7. Set中NaN等于NaN(与===不同)
const nanSet = new Set();
nanSet.add(NaN);
nanSet.add(NaN);
console.log(nanSet.size); // 1
// 8. 对象比较是引用比较
const obj = {};
const objSet = new Set();
objSet.add(obj);
objSet.add({}); // 不同的对象
console.log(objSet.size); // 2
Set遍历方法
const set = new Set(['a', 'b', 'c']);
// 1. keys() - 返回键名(Set中键名=键值)
for (const key of set.keys()) {
console.log(key); // 'a', 'b', 'c'
}
// 2. values() - 返回值
for (const value of set.values()) {
console.log(value); // 'a', 'b', 'c'
}
// 3. entries() - 返回键值对
for (const [key, value] of set.entries()) {
console.log(key, value); // 'a' 'a', 'b' 'b', 'c' 'c'
}
// 4. forEach
set.forEach((value, key, set) => {
console.log(value, key); // value === key
});
// 5. for...of(默认遍历values)
for (const item of set) {
console.log(item);
}
// 6. 展开运算符
const arr = [...set]; // ['a', 'b', 'c']
Map基本操作
// 1. 创建Map
const map1 = new Map();
const map2 = new Map([
['name', 'Tom'],
['age', 25],
[true, 'yes'] // 任意类型键
]);
// 2. 设置值
const map = new Map();
map.set('name', 'Tom');
map.set('age', 25);
map.set({ id: 1 }, 'object key'); // 对象作为键
map.set(() => {}, 'function key'); // 函数作为键
// 3. 获取值
map.get('name'); // 'Tom'
map.get('unknown'); // undefined
// 4. 判断存在
map.has('name'); // true
// 5. 删除
map.delete('name');
// 6. 清空
map.clear();
// 7. 获取大小
console.log(map.size);
// 8. 相同键的覆盖(引用类型必须是同一引用)
const key = { id: 1 };
map.set(key, 'first');
map.set(key, 'second'); // 覆盖
map.set({ id: 1 }, 'third'); // 新键(不同引用)
console.log(map.size); // 2
Map遍历方法
const map = new Map([
['name', 'Tom'],
['age', 25]
]);
// 1. keys()
for (const key of map.keys()) {
console.log(key); // 'name', 'age'
}
// 2. values()
for (const value of map.values()) {
console.log(value); // 'Tom', 25
}
// 3. entries()
for (const [key, value] of map.entries()) {
console.log(key, value);
}
// 4. forEach
map.forEach((value, key, map) => {
console.log(key, value);
});
// 5. for...of(默认遍历entries)
for (const [key, value] of map) {
console.log(key, value);
}
// 6. 解构
const arr = [...map]; // [['name', 'Tom'], ['age', 25]]
数组/字符串去重
// 1. 数组去重
const arr = [1, 2, 2, 3, 3, 3];
const unique = [...new Set(arr)]; // [1, 2, 3]
// 2. 字符串去重
const str = 'hello world';
const uniqueStr = [...new Set(str)].join(''); // 'helo wrd'
// 3. 对象数组去重(根据某个属性)
const users = [
{ id: 1, name: 'Tom' },
{ id: 2, name: 'Jerry' },
{ id: 1, name: 'Tom' }
];
const uniqueUsers = [...new Map(users.map(u => [u.id, u])).values()];
// [{ id: 1, name: 'Tom' }, { id: 2, name: 'Jerry' }]
// 4. 多维数组去重
const matrix = [[1, 2], [1, 2], [3, 4]];
const uniqueMatrix = [...new Set(matrix.map(JSON.stringify))]
.map(JSON.parse); // [[1, 2], [3, 4]]
集合运算
const setA = new Set([1, 2, 3, 4]);
const setB = new Set([3, 4, 5, 6]);
// 1. 并集(Union)
const union = new Set([...setA, ...setB]);
// {1, 2, 3, 4, 5, 6}
// 2. 交集(Intersection)
const intersection = new Set(
[...setA].filter(x => setB.has(x))
);
// {3, 4}
// 3. 差集(Difference)
const difference = new Set(
[...setA].filter(x => !setB.has(x))
);
// {1, 2}
// 4. 对称差集(Symmetric Difference)
const symmetricDiff = new Set(
[...setA].filter(x => !setB.has(x))
.concat([...setB].filter(x => !setA.has(x)))
);
// {1, 2, 5, 6}
// 封装为类
class ExtendedSet extends Set {
union(other) {
return new ExtendedSet([...this, ...other]);
}
intersection(other) {
return new ExtendedSet([...this].filter(x => other.has(x)));
}
difference(other) {
return new ExtendedSet([...this].filter(x => !other.has(x)));
}
symmetricDifference(other) {
return this.union(other)
.difference(this.intersection(other));
}
}
WeakSet
// WeakSet只能存储引用类型
const ws = new WeakSet();
const obj = {};
ws.add(obj);
// ws.add(1); // TypeError: Invalid value used in weak set
// 特点:
// 1. 弱引用,不阻止垃圾回收
// 2. 无size属性
// 3. 无遍历方法(keys/values/entries/forEach)
// 4. 无法清空(no clear方法)
// 使用场景:标记对象状态
const visited = new WeakSet();
function process(obj) {
if (visited.has(obj)) {
return; // 已处理过
}
visited.add(obj);
// 处理逻辑
}
// 当obj不再被其他地方引用时,visited中的记录会自动消失
WeakMap
// WeakMap只接受对象作为键
const wm = new WeakMap();
const key = {};
wm.set(key, 'value');
// wm.set('string', 'value'); // TypeError: Invalid value used as weak map key
// 特点与WeakSet相同
// 使用场景1:DOM节点关联数据
const elementData = new WeakMap();
function setData(element, data) {
elementData.set(element, data);
}
function getData(element) {
return elementData.get(element);
}
// 当DOM节点被移除,关联数据自动释放
// 使用场景2:私有属性实现
const privateData = new WeakMap();
class Person {
constructor(name, age) {
privateData.set(this, { name, age });
}
getName() {
return privateData.get(this).name;
}
getAge() {
return privateData.get(this).age;
}
}
const person = new Person('Tom', 25);
console.log(person.getName()); // 'Tom'
// 无法直接访问privateData
深入理解
1. Set/Map与Object的性能对比
// Map vs Object 在频繁增删场景的性能
// Object的问题:
// 1. 键只能是字符串或Symbol
// 2. 原型链可能带来意外属性
// 3. 无size属性,需要Object.keys().length
// 4. 遍历顺序不保证(虽然现代引擎按插入顺序,但规范不保证)
// Map的优势:
// 1. 任意类型键
// 2. 无原型链问题
// 3. 内置size
// 4. 保证插入顺序
// 5. 迭代性能更好
// 性能测试
console.time('Object');
const obj = {};
for (let i = 0; i < 100000; i++) {
obj[i] = i;
}
for (let i = 0; i < 100000; i++) {
delete obj[i];
}
console.timeEnd('Object');
console.time('Map');
const map = new Map();
for (let i = 0; i < 100000; i++) {
map.set(i, i);
}
for (let i = 0; i < 100000; i++) {
map.delete(i);
}
console.timeEnd('Map');
// 结论:频繁增删时,Map通常性能更好
2. WeakMap的内存管理原理
// WeakMap使用弱引用,键对象可被垃圾回收
let obj = { data: 'important' };
const wm = new WeakMap();
wm.set(obj, 'metadata');
// 此时obj有两个引用:
// 1. 变量obj
// 2. WeakMap中的弱引用(不计入垃圾回收)
obj = null; // 移除强引用
// 垃圾回收后,WeakMap中的条目自动消失
// 无法通过任何方式访问到该条目
// 对比Map(强引用)
let obj2 = { data: 'important' };
const m = new Map();
m.set(obj2, 'metadata');
obj2 = null;
// Map仍然持有强引用,对象不会被回收
// 导致内存泄漏
3. Set的唯一性判断机制
// Set使用SameValueZero算法判断相等
const set = new Set();
// 基本类型
set.add(0);
set.add(-0); // 被认为是相同的值
console.log(set.size); // 1
set.add(NaN);
set.add(NaN); // 被认为是相同的值(与===不同)
console.log(set.size); // 2
// 引用类型(比较引用)
const obj = {};
set.add(obj);
set.add(obj); // 相同引用
set.add({}); // 不同引用
console.log(set.size); // 4
// SameValueZero与===的区别:
// 1. NaN === NaN 为false,但SameValueZero认为相等
// 2. +0 === -0 为true,SameValueZero也认为相等
4. Map的迭代顺序保证
// Map保证按插入顺序迭代
const map = new Map();
map.set('z', 1);
map.set('a', 2);
map.set('m', 3);
for (const [key] of map) {
console.log(key); // 'z', 'a', 'm'
}
// Object的遍历顺序(ES2015后规范定义):
// 1. 整数键按升序
// 2. 字符串键按插入顺序
// 3. Symbol键按插入顺序
const obj = {
'z': 1,
'a': 2,
'1': 3, // 整数键
'm': 4
};
console.log(Object.keys(obj)); // ['1', 'z', 'a', 'm']
// 整数键'1'排在了最前面
最佳实践
1. 何时使用Set
// 1. 需要去重时
const uniqueItems = [...new Set(items)];
// 2. 需要高效判断成员存在性时
const allowedTags = new Set(['div', 'span', 'p']);
if (allowedTags.has(tagName)) {
// ...
}
// 3. 需要集合运算时
const intersection = new Set([...setA].filter(x => setB.has(x)));
// 4. 避免使用Set的场景:
// - 需要按键访问值(用Map)
// - 需要存储重复值(用Array)
2. 何时使用Map
// 1. 键不是字符串时
const userRoles = new Map();
userRoles.set(userObj, 'admin');
// 2. 频繁增删键值对时
// Map的增删操作通常比Object更快
// 3. 需要保持插入顺序时
// Map严格保证插入顺序
// 4. 需要获取大小
if (cacheMap.size > 100) {
// 清理缓存
}
// 5. 避免使用Map的场景:
// - 只有字符串键且固定结构(用Object更简洁)
// - 需要JSON序列化(Map需要额外处理)
3. WeakMap的私有属性模式
// 使用WeakMap实现真正的私有属性
const _name = new WeakMap();
const _age = new WeakMap();
class Person {
constructor(name, age) {
_name.set(this, name);
_age.set(this, age);
}
get name() {
return _name.get(this);
}
get age() {
return _age.get(this);
}
celebrateBirthday() {
_age.set(this, _age.get(this) + 1);
}
}
// 对比其他私有属性方案:
// 1. _prefix约定:只是约定,不是真正的私有
// 2. Symbol:外部仍可访问(通过Object.getOwnPropertySymbols)
// 3. 闭包:每个实例创建新的方法,内存开销大
// 4. #private(ES2022):语言级私有字段
4. 缓存实现
// 使用Map实现LRU缓存
class LRUCache {
constructor(capacity) {
this.capacity = capacity;
this.cache = new Map();
}
get(key) {
if (!this.cache.has(key)) {
return -1;
}
// 移动到最新
const value = this.cache.get(key);
this.cache.delete(key);
this.cache.set(key, value);
return value;
}
put(key, value) {
if (this.cache.has(key)) {
this.cache.delete(key);
}
if (this.cache.size >= this.capacity) {
// 删除最旧的(Map的第一个元素)
const oldestKey = this.cache.keys().next().value;
this.cache.delete(oldestKey);
}
this.cache.set(key, value);
}
}
面试要点
-
Set与Map的本质区别:
- Set:值-值集合,值唯一
- Map:键-值字典,键可以是任意类型
- Object:键只能是字符串或Symbol
-
Set的核心方法:
- add、delete、has、clear
- size属性
- 遍历:keys/values/entries/forEach
-
Map的核心方法:
- set、get、has、delete、clear
- size属性
- 支持任意类型键
-
WeakSet/WeakMap的特点:
- 只存储引用类型(WeakSet)/只接受对象键(WeakMap)
- 弱引用,不阻止垃圾回收
- 无遍历方法、无size、无clear
- 适合DOM数据存储、私有属性实现
-
去重技巧:
[...new Set(arr)]- 字符串去重
- 对象数组去重(结合Map)
-
集合运算:
- 并集:
new Set([...a, ...b]) - 交集:
[...a].filter(x => b.has(x)) - 差集:
[...a].filter(x => !b.has(x))
- 并集:
-
使用场景对比:
- Set:去重、成员判断、集合运算
- Map:非字符串键、频繁增删、保持顺序
- WeakMap:私有属性、DOM元数据、避免内存泄漏
-
注意事项:
- Set中NaN等于NaN
- Set中对象比较是引用比较
- Map的键是引用比较
- WeakMap的键必须是对象