返回首页

你是怎么理解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);
  }
}

面试要点

  1. Set与Map的本质区别

    • Set:值-值集合,值唯一
    • Map:键-值字典,键可以是任意类型
    • Object:键只能是字符串或Symbol
  2. Set的核心方法

    • add、delete、has、clear
    • size属性
    • 遍历:keys/values/entries/forEach
  3. Map的核心方法

    • set、get、has、delete、clear
    • size属性
    • 支持任意类型键
  4. WeakSet/WeakMap的特点

    • 只存储引用类型(WeakSet)/只接受对象键(WeakMap)
    • 弱引用,不阻止垃圾回收
    • 无遍历方法、无size、无clear
    • 适合DOM数据存储、私有属性实现
  5. 去重技巧

    • [...new Set(arr)]
    • 字符串去重
    • 对象数组去重(结合Map)
  6. 集合运算

    • 并集:new Set([...a, ...b])
    • 交集:[...a].filter(x => b.has(x))
    • 差集:[...a].filter(x => !b.has(x))
  7. 使用场景对比

    • Set:去重、成员判断、集合运算
    • Map:非字符串键、频繁增删、保持顺序
    • WeakMap:私有属性、DOM元数据、避免内存泄漏
  8. 注意事项

    • Set中NaN等于NaN
    • Set中对象比较是引用比较
    • Map的键是引用比较
    • WeakMap的键必须是对象