Skip to content

Map 与 Set:现代 JavaScript 的高效集合

在图书馆中,不同类型的资料有不同的管理方式:字典按字母顺序排列词条,期刊按日期归档,而会员卡系统则记录每个唯一的会员编号。传统的 JavaScript 对象和数组可以处理这些任务,但 ES6 引入的 Map 和 Set 提供了更专业、更高效的解决方案。Set 就像一个不允许重复的会员系统,而 Map 则像一本可以用任何东西作为索引的智能字典。它们不仅提供了更清晰的语义,还在性能和功能上有显著优势。

Set - 唯一值的集合

Set 是一个存储唯一值的集合,自动去除重复项。它就像一个自动防重的容器,无论你添加多少次相同的值,最终只会保留一个。

创建和基本操作

javascript
// 创建空 Set
let emptySet = new Set();

// 创建包含初始值的 Set
let numbers = new Set([1, 2, 3, 4, 5]);
console.log(numbers); // Set(5) { 1, 2, 3, 4, 5 }

// 自动去重
let withDuplicates = new Set([1, 2, 2, 3, 3, 3, 4]);
console.log(withDuplicates); // Set(4) { 1, 2, 3, 4 }

// 添加值
numbers.add(6);
console.log(numbers.size); // 6

// 添加重复值不会有效果
numbers.add(3);
console.log(numbers.size); // 6 (仍然是 6)

// 检查值是否存在
console.log(numbers.has(3)); // true
console.log(numbers.has(10)); // false

// 删除值
numbers.delete(3);
console.log(numbers.has(3)); // false

// 清空 Set
numbers.clear();
console.log(numbers.size); // 0

Set 的核心方法简单明了:add() 添加值,has() 检查存在,delete() 删除值,clear() 清空集合,size 属性获取元素数量。

遍历 Set

Set 是可迭代的,可以用多种方式遍历:

javascript
let colors = new Set(["red", "green", "blue", "yellow"]);

// 使用 for...of
for (let color of colors) {
  console.log(color);
}

// 使用 forEach
colors.forEach((color) => {
  console.log(color);
});

// Set 的 forEach 回调接收三个参数:值、值(是的,第二个参数也是值)、Set 本身
colors.forEach((value, valueAgain, set) => {
  console.log(`${value} === ${valueAgain}`); // true
});

// 转换为数组
let colorArray = [...colors];
console.log(colorArray); // ["red", "green", "blue", "yellow"]

// 使用 Array.from
let colorArray2 = Array.from(colors);

实际应用:数组去重

Set 最常见的用途之一就是数组去重:

javascript
let products = [
  "laptop",
  "mouse",
  "keyboard",
  "mouse",
  "laptop",
  "monitor",
  "keyboard",
  "headphones",
];

// 使用 Set 去重
let uniqueProducts = [...new Set(products)];
console.log(uniqueProducts);
// ["laptop", "mouse", "keyboard", "monitor", "headphones"]

// 统计唯一值数量
function countUnique(arr) {
  return new Set(arr).size;
}

console.log(countUnique(products)); // 5

集合运算

Set 可以很方便地实现数学集合运算:

javascript
let setA = new Set([1, 2, 3, 4]);
let setB = new Set([3, 4, 5, 6]);

// 并集(Union)
let union = new Set([...setA, ...setB]);
console.log([...union]); // [1, 2, 3, 4, 5, 6]

// 交集(Intersection)
let intersection = new Set([...setA].filter((x) => setB.has(x)));
console.log([...intersection]); // [3, 4]

// 差集(Difference) - A 中有但 B 中没有的
let difference = new Set([...setA].filter((x) => !setB.has(x)));
console.log([...difference]); // [1, 2]

// 对称差集(Symmetric Difference) - 在 A 或 B 中,但不在两者交集中
let symmetricDiff = new Set([
  ...[...setA].filter((x) => !setB.has(x)),
  ...[...setB].filter((x) => !setA.has(x)),
]);
console.log([...symmetricDiff]); // [1, 2, 5, 6]

实际应用:标签系统

javascript
class TagManager {
  constructor() {
    this.tags = new Set();
  }

  // 添加单个标签
  addTag(tag) {
    this.tags.add(tag.toLowerCase());
  }

  // 批量添加标签
  addTags(...tags) {
    tags.forEach(tag => this.tags.add(tag.toLowerCase());
  }

  // 删除标签
  removeTag(tag) {
    this.tags.delete(tag.toLowerCase());
  }

  // 检查标签是否存在
  hasTag(tag) {
    return this.tags.has(tag.toLowerCase());
  }

  // 获取所有标签
  getAllTags() {
    return Array.from(this.tags).sort();
  }

  // 清空标签
  clear() {
    this.tags.clear();
  }

  // 标签数量
  count() {
    return this.tags.size;
  }
}

// 使用示例
let articleTags = new TagManager();
articleTags.addTags("javascript", "programming", "web");
articleTags.addTag("JAVASCRIPT"); // 自动去重(转小写后相同)

console.log(articleTags.getAllTags());
// ["javascript", "programming", "web"]

console.log(articleTags.hasTag("Web")); // true (不区分大小写)

Map - 键值对集合

Map 是一个键值对集合,类似于对象,但有几个重要的区别:键可以是任何类型(不仅仅是字符串),保持插入顺序,并提供了更丰富的方法。

创建和基本操作

javascript
// 创建空 Map
let emptyMap = new Map();

// 从数组创建 Map
let userRoles = new Map([
  ["alice", "admin"],
  ["bob", "editor"],
  ["charlie", "viewer"],
]);

// 设置键值对
let config = new Map();
config.set("theme", "dark");
config.set("language", "en");
config.set("fontSize", 14);

// 链式调用
let settings = new Map()
  .set("host", "localhost")
  .set("port", 3000)
  .set("timeout", 5000);

// 获取值
console.log(config.get("theme")); // "dark"
console.log(config.get("missing")); // undefined

// 检查键是否存在
console.log(config.has("theme")); // true
console.log(config.has("color")); // false

// 删除键值对
config.delete("fontSize");
console.log(config.has("fontSize")); // false

// 获取大小
console.log(userRoles.size); // 3

// 清空 Map
config.clear();
console.log(config.size); // 0

任何类型都可以作为键

这是 Map 相比普通对象的最大优势:

javascript
let map = new Map();

// 对象作为键
let objKey = { id: 1 };
map.set(objKey, "Object as key");
console.log(map.get(objKey)); // "Object as key"

// 函数作为键
let funcKey = function () {};
map.set(funcKey, "Function as key");

// 数组作为键
let arrKey = [1, 2, 3];
map.set(arrKey, "Array as key");

// DOM 元素作为键(在浏览器中)
// let buttonKey = document.querySelector('button');
// map.set(buttonKey, 'Button element');

// Map 作为键
let mapKey = new Map();
map.set(mapKey, "Map as key");

console.log(map.size); // 4

// 注意:引用相同才能找到值
console.log(map.get({ id: 1 })); // undefined (不同的对象引用)
console.log(map.get(objKey)); // "Object as key" (相同引用)

遍历 Map

javascript
let prices = new Map([
  ["laptop", 1299],
  ["mouse", 29],
  ["keyboard", 89],
  ["monitor", 399],
]);

// 使用 for...of 遍历键值对
for (let [product, price] of prices) {
  console.log(`${product}: $${price}`);
}

// 使用 forEach
prices.forEach((price, product) => {
  console.log(`${product}: $${price}`);
});

// 只遍历键
for (let product of prices.keys()) {
  console.log(product);
}

// 只遍历值
for (let price of prices.values()) {
  console.log(price);
}

// 获取所有条目
console.log([...prices.entries()]);
// [["laptop", 1299], ["mouse", 29], ...]

Map vs Object

让我们对比 Map 和普通对象:

javascript
// 1. 键的类型
let obj = {};
let map = new Map();

// 对象:键会被转换为字符串
obj[1] = "one";
obj[true] = "yes";
console.log(Object.keys(obj)); // ["1", "true"] - 都变成字符串了

// Map:键保持原类型
map.set(1, "one");
map.set(true, "yes");
console.log([...map.keys()]); // [1, true] - 保持原类型

// 2. 性能
// Map 在频繁添加/删除键值对时性能更好
console.time("Object");
let testObj = {};
for (let i = 0; i < 10000; i++) {
  testObj[`key${i}`] = i;
}
for (let i = 0; i < 10000; i++) {
  delete testObj[`key${i}`];
}
console.timeEnd("Object");

console.time("Map");
let testMap = new Map();
for (let i = 0; i < 10000; i++) {
  testMap.set(`key${i}`, i);
}
for (let i = 0; i < 10000; i++) {
  testMap.delete(`key${i}`);
}
console.timeEnd("Map");

// 3. 顺序保证
// Map 保证遍历顺序就是插入顺序
let orderedMap = new Map();
orderedMap.set("z", 1);
orderedMap.set("a", 2);
orderedMap.set("m", 3);
console.log([...orderedMap.keys()]); // ["z", "a", "m"] - 插入顺序

// 4. 大小获取
console.log(map.size); // 直接属性
console.log(Object.keys(obj).length); // 需要计算

实际应用:缓存系统

javascript
class Cache {
  constructor(maxSize = 100) {
    this.cache = new Map();
    this.maxSize = maxSize;
  }

  // 获取缓存值
  get(key) {
    if (!this.cache.has(key)) {
      return null;
    }

    // LRU: 将访问的项移到最后(最近使用)
    let value = this.cache.get(key);
    this.cache.delete(key);
    this.cache.set(key, value);

    return value;
  }

  // 设置缓存值
  set(key, value) {
    // 如果键已存在,先删除
    if (this.cache.has(key)) {
      this.cache.delete(key);
    }

    // 如果超过最大容量,删除最旧的项(第一个)
    if (this.cache.size >= this.maxSize) {
      let firstKey = this.cache.keys().next().value;
      this.cache.delete(firstKey);
    }

    this.cache.set(key, value);
  }

  // 检查是否有缓存
  has(key) {
    return this.cache.has(key);
  }

  // 清空缓存
  clear() {
    this.cache.clear();
  }

  // 获取缓存大小
  size() {
    return this.cache.size;
  }

  // 获取所有键
  keys() {
    return Array.from(this.cache.keys());
  }
}

// 使用示例
let apiCache = new Cache(3); // 最多缓存 3 项

apiCache.set("/api/users", [{ id: 1, name: "Alice" }]);
apiCache.set("/api/products", [{ id: 101, name: "Laptop" }]);
apiCache.set("/api/orders", [{ id: 1001, status: "pending" }]);

console.log(apiCache.keys());
// ["/api/users", "/api/products", "/api/orders"]

// 添加第四项会移除最旧的
apiCache.set("/api/settings", { theme: "dark" });
console.log(apiCache.keys());
// ["/api/products", "/api/orders", "/api/settings"]
// "/api/users" 被移除了

// 访问某项会将其移到最后
apiCache.get("/api/products");
console.log(apiCache.keys());
// ["/api/orders", "/api/settings", "/api/products"]
// "/api/products" 被移到最后了

对象与 Map 相互转换

javascript
// 对象转 Map
let obj = {
  name: "Emma",
  age: 24,
  city: "London",
};
let mapFromObj = new Map(Object.entries(obj));
console.log(mapFromObj.get("name")); // "Emma"

// Map 转对象
let map = new Map([
  ["theme", "dark"],
  ["language", "en"],
  ["notifications", true],
]);
let objFromMap = Object.fromEntries(map);
console.log(objFromMap);
// { theme: "dark", language: "en", notifications: true }

实战案例:用户权限管理系统

让我们综合运用 Map 和 Set 构建一个权限管理系统:

javascript
class PermissionSystem {
  constructor() {
    // 使用 Map 存储用户权限(用户 ID -> Set<权限>)
    this.userPermissions = new Map();

    // 使用 Map 存储角色权限(角色名 -> Set<权限>)
    this.rolePermissions = new Map();

    // 使用 Map 存储用户角色(用户 ID -> Set<角色>)
    this.userRoles = new Map();
  }

  // 定义角色权限
  defineRole(roleName, permissions) {
    this.rolePermissions.set(roleName, new Set(permissions));
  }

  // 为用户分配角色
  assignRole(userId, roleName) {
    if (!this.userRoles.has(userId)) {
      this.userRoles.set(userId, new Set());
    }
    this.userRoles.get(userId).add(roleName);
  }

  // 为用户授予直接权限
  grantPermission(userId, permission) {
    if (!this.userPermissions.has(userId)) {
      this.userPermissions.set(userId, new Set());
    }
    this.userPermissions.get(userId).add(permission);
  }

  // 撤销用户的直接权限
  revokePermission(userId, permission) {
    if (this.userPermissions.has(userId)) {
      this.userPermissions.get(userId).delete(permission);
    }
  }

  // 获取用户所有权限(角色权限 + 直接权限)
  getUserPermissions(userId) {
    let permissions = new Set();

    // 添加直接权限
    if (this.userPermissions.has(userId)) {
      for (let perm of this.userPermissions.get(userId)) {
        permissions.add(perm);
      }
    }

    // 添加角色权限
    if (this.userRoles.has(userId)) {
      for (let role of this.userRoles.get(userId)) {
        if (this.rolePermissions.has(role)) {
          for (let perm of this.rolePermissions.get(role)) {
            permissions.add(perm);
          }
        }
      }
    }

    return permissions;
  }

  // 检查用户是否有某个权限
  hasPermission(userId, permission) {
    let permissions = this.getUserPermissions(userId);
    return permissions.has(permission);
  }

  // 检查用户是否有所有指定权限
  hasAllPermissions(userId, ...requiredPermissions) {
    let permissions = this.getUserPermissions(userId);
    return requiredPermissions.every((perm) => permissions.has(perm));
  }

  // 检查用户是否有任一指定权限
  hasAnyPermission(userId, ...requiredPermissions) {
    let permissions = this.getUserPermissions(userId);
    return requiredPermissions.some((perm) => permissions.has(perm));
  }

  // 获取统计信息
  getStats() {
    return {
      totalUsers: this.userRoles.size,
      totalRoles: this.rolePermissions.size,
      rolesInfo: Array.from(this.rolePermissions.entries()).map(
        ([role, perms]) => ({
          role,
          permissionCount: perms.size,
          permissions: Array.from(perms),
        })
      ),
    };
  }
}

// 使用示例
let permSystem = new PermissionSystem();

// 定义角色
permSystem.defineRole("admin", [
  "read",
  "write",
  "delete",
  "manage_users",
  "manage_settings",
]);

permSystem.defineRole("editor", ["read", "write", "edit"]);

permSystem.defineRole("viewer", ["read"]);

// 为用户分配角色
permSystem.assignRole("user_001", "admin");
permSystem.assignRole("user_002", "editor");
permSystem.assignRole("user_003", "viewer");

// user_002 额外授予一个直接权限
permSystem.grantPermission("user_002", "manage_comments");

// 检查权限
console.log(permSystem.hasPermission("user_001", "delete")); // true
console.log(permSystem.hasPermission("user_002", "delete")); // false
console.log(permSystem.hasPermission("user_002", "manage_comments")); // true

// 获取用户所有权限
console.log([...permSystem.getUserPermissions("user_002")]);
// ["read", "write", "edit", "manage_comments"]

// 检查多个权限
console.log(
  permSystem.hasAllPermissions("user_001", "read", "write", "delete")
);
// true

console.log(permSystem.hasAnyPermission("user_003", "write", "delete"));
// false (viewer 只有 read 权限)

// 获取统计
console.log(permSystem.getStats());
// {
//   totalUsers: 3,
//   totalRoles: 3,
//   rolesInfo: [...]
// }

性能对比

理解 Map/Set 与传统方式的性能差异很重要:

javascript
let size = 10000;

// Set vs Array(去重)
console.time("Array unique");
let arr = [];
for (let i = 0; i < size; i++) {
  if (!arr.includes(i % 100)) {
    arr.push(i % 100);
  }
}
console.timeEnd("Array unique");

console.time("Set unique");
let set = new Set();
for (let i = 0; i < size; i++) {
  set.add(i % 100);
}
console.timeEnd("Set unique");
// Set 快得多!

// Map vs Object(查找)
console.time("Object lookup");
let obj = {};
for (let i = 0; i < size; i++) {
  obj[`key${i}`] = i;
}
for (let i = 0; i < size; i++) {
  let value = obj[`key${i}`];
}
console.timeEnd("Object lookup");

console.time("Map lookup");
let map = new Map();
for (let i = 0; i < size; i++) {
  map.set(`key${i}`, i);
}
for (let i = 0; i < size; i++) {
  let value = map.get(`key${i}`);
}
console.timeEnd("Map lookup");
// 性能相近,但 Map 功能更强

选择指南

使用 Set 当:

  • 需要存储唯一值
  • 需要快速检查某个值是否存在
  • 需要去重
  • 需要集合运算(并集、交集等)

使用 Map 当:

  • 需要键值对存储,且键不仅仅是字符串
  • 需要频繁添加/删除键值对
  • 需要保持插入顺序
  • 需要高性能的键查找

使用 Object 当:

  • 需要 JSON 序列化(Map/Set 不能直接 JSON.stringify)
  • 键确定是字符串或符号
  • 需要使用原型继承
  • 数据结构相对固定

使用 Array 当:

  • 需要索引访问
  • 需要数组专有方法(map, filter, reduce 等)
  • 允许重复值
  • 需要排序

常见陷阱

1. Set 的相等性判断

javascript
let set = new Set();

set.add({ id: 1 });
set.add({ id: 1 }); // 不同对象引用,不会去重

console.log(set.size); // 2 (有两个对象)

// 基本类型正常去重
let nums = new Set();
nums.add(1);
nums.add(1);
console.log(nums.size); // 1

2. Map 键的比较

javascript
let map = new Map();

// NaN 作为键
map.set(NaN, "value");
console.log(map.get(NaN)); // "value" (Map 认为 NaN === NaN)

// +0 和 -0 被认为相同
map.set(+0, "plus zero");
map.set(-0, "minus zero");
console.log(map.size); // 1 (两个键被认为相同)

3. 转换时的限制

javascript
let map = new Map();
map.set({ id: 1 }, "object key");

// JSON.stringify 不能直接序列化 Map
console.log(JSON.stringify(map)); // "{}" - 丢失数据!

// 正确方式:先转为数组
let serialized = JSON.stringify([...map]);
let deserialized = new Map(JSON.parse(serialized));

总结

Map 和 Set 是 ES6 引入的强大数据结构:

  • Set - 唯一值集合,自动去重,高效的存在性检查
  • Map - 键值对集合,任何类型的键,保持插入顺序,性能优异

它们相比传统的对象和数组提供了:

  • 更清晰的语义(意图明确)
  • 更好的性能(特别是大数据量时)
  • 更丰富的功能(如 size 属性、专门的遍历方法)
  • 更灵活的键类型(Map)

在现代 JavaScript 开发中,合理选择和使用 Map、Set、Object、Array 能显著提升代码质量和性能。当需要唯一性时用 Set,需要灵活键值对时用 Map,它们会让你的代码更简洁、更高效。