上周在重构一个后台管理系统的权限判断模块,里面有一段逻辑是把两个用户组拥有的权限标识做交叉比对,找出双方共有的许可。原来的代码写成了两层循环嵌套,再配合 `indexOf`,读起来头皮发麻。我在 Code Review 时顺手把它改成了 `Set` 加 `filter` 的写法,旁边一个同事瞄了一眼说:“现在浏览器已经直接支持 `intersection` 了,干嘛不直接用?” 当时我愣了一下,印象中这个提案确实进了 Stage 3,但没想到这么快就落了地。翻了一下 Can I Use,Chrome 122、Firefox 127、Node.js 22 都已经原生支持这组新的 Set 方法。于是花了半天时间把项目里所有跟集合操作相关的逻辑都翻新了一遍,改完之后的代码不仅量少了,可读性也上了个台阶。这篇文章就把这次整理过程中几个有代表性的场景分享出来,顺便把新方法的边界和坑点一次讲透。
一、这组新方法包含哪些,各自的职责是什么
在 ES6 引入 `Set` 的时候,我们只拿到了 `add`、`has`、`delete` 这几个基本方法,要完成两个集合之间的交集、并集、差集等操作,必须手动写循环或者借助 `filter`。新的提案给 `Set.prototype` 增加了七个方法,正好覆盖了集合论里最常用的那些关系:
- intersection(other) — 交集,返回一个新 Set,包含同时存在于两个集合中的元素。
- union(other) — 并集,返回包含两个集合中所有元素的新 Set。
- difference(other) — 差集,返回存在于当前集合但不存在于 `other` 中的元素。
- symmetricDifference(other) — 对称差集,返回只存在于其中一个集合(不同时在两个集合中)的元素。
- isSubsetOf(other) — 判断当前集合是否是 `other` 的子集。
- isSupersetOf(other) — 判断当前集合是否是 `other` 的超集。
- isDisjointFrom(other) — 判断两个集合是否没有交集。
所有方法都不会修改原集合,始终返回一个新的 Set 或布尔值,完全符合不可变数据的操作习惯。
一个特别好的设计是,`other` 参数不需要非得是 `Set` 实例,任何可迭代对象(比如数组、Map 的 keys、甚至生成器)都可以直接传入。这意味着你可以把数组和 Set 混在一起做集合运算,不需要手动转换。
二、实战场景一:内容标签的多选筛选与并集展示
假设一个文章管理界面,每篇文章都挂了一组标签。用户可以选择多个标签,界面需要显示“至少包含其中一个标签”的文章集合(并集),并且同时高亮那些“同时满足所有选中标签”的文章(交集)。以前写这种逻辑至少需要好几行 `filter` 和 `some`/`every` 的组合,用新方法可以非常直接地表达意图。
// 文章数据:每篇文章有一组标签
const articles = [
{ id: 1, tags: ['javascript', 'es2024', 'frontend'] },
{ id: 2, tags: ['css', 'layout', 'frontend'] },
{ id: 3, tags: ['javascript', 'algorithms'] },
{ id: 4, tags: ['es2024', 'nodejs', 'backend'] },
];
// 用户选中的标签
const selectedTags = ['javascript', 'es2024'];
// 计算并集文章:只要文章标签与选中标签有任何交集即可
const unionArticles = articles.filter(article => {
const articleTags = new Set(article.tags);
const selectedSet = new Set(selectedTags);
// 新方法 isDisjointFrom 判断是否无交集,取反即“有交集”
return !articleTags.isDisjointFrom(selectedSet);
});
console.log(unionArticles.map(a => a.id)); // [1, 3, 4]
// 计算交集文章:文章标签必须完全包含所有选中标签
const intersectionArticles = articles.filter(article => {
const articleTags = new Set(article.tags);
const selectedSet = new Set(selectedTags);
// selectedSet 是 articleTags 的子集,即文章包含全部选中标签
return selectedSet.isSubsetOf(articleTags);
});
console.log(intersectionArticles.map(a => a.id)); // [1, 4]
上面这段逻辑里,`isDisjointFrom` 和 `isSubsetOf` 恰好对应“至少一个”和“全部包含”的语义,读起来比 `some` 和 `every` 更贴切,因为 `some` 和 `every` 本质是数组遍历,而集合方法传达的是集合关系,意图更明确。而且 `isDisjointFrom` 内部做了短路优化,一旦发现第一个重合元素就立刻返回 `false`,不需要遍历整个集合。
三、实战场景二:团队权限的差异计算与审计日志
继续讲权限系统那个场景。假设一个管理员把用户A的权限集合调整了,我们想记录下具体增减了哪些权限,用于生成审计日志。用 `difference` 可以轻松算出新增和删除的权限项。
// 修改前后的权限集合
const oldPermissions = new Set(['read', 'write', 'export', 'manage_users']);
const newPermissions = new Set(['read', 'export', 'delete', 'manage_roles']);
// 新增的权限:在 newPermissions 中存在,但在 oldPermissions 中不存在
const added = newPermissions.difference(oldPermissions);
console.log('新增权限:', [...added]); // ['delete', 'manage_roles']
// 移除的权限:在 oldPermissions 中存在,但在 newPermissions 中不存在
const removed = oldPermissions.difference(newPermissions);
console.log('移除权限:', [...removed]); // ['write', 'manage_users']
// 没有发生变化的权限(交集)
const unchanged = oldPermissions.intersection(newPermissions);
console.log('未变更权限:', [...unchanged]); // ['read', 'export']
`difference` 的行为和数组的 `filter` 不同:它直接返回一个 Set,元素是严格等于(SameValueZero算法)比较的结果。对于字符串和数值这种基本类型,这个行为非常稳定。注意 `difference` 的顺序敏感:`A.difference(B)` 和 `B.difference(A)` 结果是相反的,计算时心里要先想清楚谁是基准集合。
四、实战场景三:购物车合并与对称差集的妙用
这个例子来自于一个电商小程序的开发经历。用户在未登录状态下往购物车里加了几件商品,登录后需要把本地购物车和服务器端保存的购物车合并,同时展示“哪些商品是两端各自独有的”,方便用户确认。这个需求正好是 `union` 和 `symmetricDifference` 的组合。
const localCart = new Set(['item_001', 'item_002', 'item_005']);
const serverCart = new Set(['item_002', 'item_003', 'item_004']);
// 合并后的完整购物车(并集)
const mergedCart = localCart.union(serverCart);
console.log('合并后:', [...mergedCart]);
// ['item_001', 'item_002', 'item_005', 'item_003', 'item_004']
// 两端独有商品(对称差集)
const onlyOnOneSide = localCart.symmetricDifference(serverCart);
console.log('仅在一端存在:', [...onlyOnOneSide]);
// ['item_001', 'item_005', 'item_003', 'item_004']
`symmetricDifference` 在数据一致性校验和差异高亮场景下非常实用。之前要实现同样的效果,得写两个 `filter` 然后拼数组,现在一行代码搞定,而且因为返回的是 Set,还能继续链式调用其他集合方法或者 `forEach`。
五、一个容易忽略的细节:对象元素的比较依据
之前有同事踩过一个坑,以为把对象放进 Set 也能用这些新方法做基于内容的比较。实际并非如此——Set 的成员判定始终基于引用相等(SameValueZero),对于对象而言,就是比较引用地址。两个内容相同的对象在 Set 中被视作不同的元素。
const setA = new Set([{ id: 1 }, { id: 2 }]);
const setB = new Set([{ id: 2 }, { id: 3 }]);
// 注意:下面这个交集计算会得到空集,因为对象引用不同
const intersectionResult = setA.intersection(setB);
console.log(intersectionResult.size); // 0
这个问题不是新方法引入的,而是 Set 底层比较机制决定的。如果你的业务需要按对象内容做集合运算,通常的做法是先映射到基本类型键值(比如 `id`),运算完再反查回原对象。或者使用 `Map` 配合键值来维护对象集合,对键值集合用这些新方法,再映射回对象。
// 正确做法:用id作为键
const mapA = new Map([[1, { id: 1 }], [2, { id: 2 }]]);
const mapB = new Map([[2, { id: 2 }], [3, { id: 3 }]]);
const keySetA = new Set(mapA.keys());
const keySetB = new Set(mapB.keys());
const commonKeys = keySetA.intersection(keySetB);
const commonObjects = [...commonKeys].map(k => mapA.get(k));
console.log(commonObjects); // [{ id: 2 }]
六、性能方面的一些直观感受
我本地用一万条随机数字做了一个简单对比:用 `for` 循环双遍历求交集,耗时大概在几十毫秒量级;用 `filter` 加 `has` 的方法稍快一些,因为 `has` 是 O(1);而用原生 `intersection` 的表现和 `filter + has` 不相上下,甚至在某些引擎上略微占优。但性能并不是这组方法最大的卖点,真正的优势在于语义化。当一段代码写成 `setA.intersection(setB)`,后来的人一眼就知道这是在求交集,而不需要从 `filter` 和 `has` 的组合里反向推断意图。
有一点值得提一下:`union` 方法在传入的可迭代对象包含重复元素时会自动去重,这和 Set 本身的特性一致。
七、兼容性与渐进式使用策略
这组集合方法已经是正式的 ES2024 标准,Chrome 122+(Edge、Opera 同内核)、Firefox 127+、Safari 17+、Node.js 22+ 都提供了原生支持。如果你的项目需要兼容稍微旧一点的浏览器,可以用下面的 polyfill 片段做兜底,它基于原生 Set 和数组方法实现,行为完全符合规范:
if (!Set.prototype.intersection) {
Set.prototype.intersection = function(other) {
const otherSet = new Set(other);
return new Set([...this].filter(x => otherSet.has(x)));
};
Set.prototype.union = function(other) {
return new Set([...this, ...other]);
};
Set.prototype.difference = function(other) {
const otherSet = new Set(other);
return new Set([...this].filter(x => !otherSet.has(x)));
};
Set.prototype.symmetricDifference = function(other) {
const otherSet = new Set(other);
return new Set([
...[...this].filter(x => !otherSet.has(x)),
...[...otherSet].filter(x => !this.has(x))
]);
};
Set.prototype.isSubsetOf = function(other) {
const otherSet = new Set(other);
return [...this].every(x => otherSet.has(x));
};
Set.prototype.isSupersetOf = function(other) {
const otherSet = new Set(other);
return [...otherSet].every(x => this.has(x));
};
Set.prototype.isDisjointFrom = function(other) {
const otherSet = new Set(other);
return [...this].every(x => !otherSet.has(x));
};
}
这个 polyfill 在体积上很小,对于尚不支持的环境能提供完全一致的使用体验。等覆盖率上来之后直接删掉即可,业务代码不需要做任何改动。
八、总结
Set 这组新方法并不是什么颠覆性的大功能,但它们把一类高频的、以前需要手动循环或借助数组方法才能实现的集合运算,提升到了语言原语级别。当你在代码里不再需要写 `[…a].filter(x => b.has(x))` 这种拐弯抹角的表达式,而是直接写 `a.intersection(b)` 的时候,背后消除掉的不仅是几行代码,还有每次读到这个逻辑时大脑里的“翻译成本”。
如果你最近在维护一个有大量数据去重、标签比对、权限交叉判断的项目,可以挑一个低风险的模块先试试水。打开控制台,建两个 Set,调一下 `intersection` 和 `union`,感受一下那种原生的轻快感。

