12k
All articles

JavaScript 新 Set 方法实用指南

JavaScript 新 Set 方法详解:union、intersection、difference、symmetricDifference 和子集检查,并说明 Map 支持与浏览器兼容性。

OpenReplay Team
OpenReplay Team
JavaScript 新 Set 方法实用指南

JavaScript 的 Set 对象新增了七个实例方法——union()intersection()difference()symmetricDifference()isSubsetOf()isSupersetOf()isDisjointFrom()——这些方法已于 2024 年 6 月 11 日成为 Baseline Newly Available。这些方法取代了前端开发者多年来手动实现的 Array.filter() + Array.includes() 模式来比较集合,并以基于集合的查找替代了嵌套数组扫描。如果你已经熟悉 ES2015 的 Set API——addhasdelete、迭代——本指南将为你补充那些发布公告中略去的内容:允许传入 Map 的 Set-like 参数协议、会悄无声息地破坏对象比较的引用相等性陷阱、可直接复制使用的前端代码模式,以及一份如实呈现的浏览器支持表。

核心要点

  • 全部七个新 Set 方法均已于 2024 年 6 月 11 日成为 Baseline Newly Available,在 Chrome 122、Edge 122、Firefox 127 和 Safari 17 以及 Node.js 22.0.0 中均已内置——针对当前目标环境无需 polyfill。
  • 这些方法的参数不必是 Set 实例;它只需暴露一个数值类型的 size 属性、一个可调用的 has 方法和一个可调用的 keys 方法,这意味着 Map 可以直接作为参数传入。
  • Set 按引用比较值,因此 new Set([{id:1}]).intersection(new Set([{id:1}])) 会返回一个空集合——应改为对稳定的原始类型 ID 构成的 Set 进行求交。
  • difference() 不满足交换律,而 symmetricDifference() 会先按接收方顺序返回仅属于接收方的元素。
  • [...a].filter(x => b.includes(x)) 替换为 a.intersection(b),可将 O(n²) 的数组模式换成在规范要求的平均次线性 Set 访问下随较小集合规模扩展的方案。

七个方法概览

新方法分为两组:四个返回新 Set,三个返回布尔值。这一划分是最实用的心智模型——它能让你立刻判断自己是在构建集合还是在做是/否判断。

返回新 Set 的操作:

  • union(other) — 返回此集合另一集合中的元素(类比 SQL FULL OUTER JOIN)。
  • intersection(other) — 返回同时存在于两个集合中的元素(类比 INNER JOIN)。
  • difference(other) — 返回存在于此集合但不在另一集合中的元素(类比 LEFT JOIN)。
  • symmetricDifference(other) — 返回存在于任一集合但不同时存在于两者的元素。

返回布尔值的谓词:

新增这些方法的提案是 TC39 的 Set methods 提案,该提案已进入 Stage 4,目前处于非活跃状态,已被纳入 ECMAScript 规范,并成为 ECMAScript 2025 的一部分。

在真实前端代码中使用返回 Set 的方法

四个返回 Set 的方法——union()intersection()difference()symmetricDifference()——每个都会返回一个新的 Set 而不会修改任何输入,且每个都能直接对应前端开发者已经在手动处理的任务。

union():将功能标志覆盖项与默认值合并

union() 返回一个包含两个集合中所有元素的新集合,并自动去重。

const defaults = new Set(["new-dashboard", "dark-mode"]);
const overrides = new Set(["dark-mode", "beta-search"]);

const enabled = defaults.union(overrides);
// Set(3) { "new-dashboard", "dark-mode", "beta-search" }

此前的写法是 new Set([...defaults, ...overrides])union() 能更直接地表达意图。当你将一组已启用的基础功能标志与每个用户或每个环境的覆盖项合并时,union() 只需一次调用即可得到最终生效的标志集合。

intersection():找出用户实际可访问的路由

intersection() 返回一个只包含两个集合中共有元素的新集合。

const requiredForRoute = new Set<string>(["billing:read", "billing:write"]);
const userPermissions = new Set<string>(["billing:read", "users:read"]);

const satisfied = requiredForRoute.intersection(userPermissions);
// Set(1) { "billing:read" }

在路由访问控制场景中,将路由所需权限集合与用户拥有的权限集合求交。通过比较结果的大小与所需集合的大小,即可判断访问权限是部分满足还是完全满足。

difference():对多选组件的前后选中状态进行差异比较

difference() 返回一个包含此集合中有、但另一集合中没有的元素的新集合——且它不满足交换律

const prevSelected = new Set<string>(["a", "b", "c"]);
const nextSelected = new Set<string>(["b", "c", "d"]);

const added = nextSelected.difference(prevSelected);   // Set(1) { "d" }
const removed = prevSelected.difference(nextSelected); // Set(1) { "a" }

对于多选组件,nextSelected.difference(prevSelected) 给出刚刚新增的项,prevSelected.difference(nextSelected) 给出刚刚移除的项——两次集合操作取代了原本需要排序或嵌套循环的模式。a.difference(b) 返回在 a 中存在但在 b 中缺失的元素,而 b.difference(a) 则返回反向结果;参数顺序即决定了操作语义。

symmetricDifference():高亮显示两个快照之间发生变化的键

symmetricDifference() 返回一个包含存在于任一集合但不同时存在于两者的元素的新集合。

const before = new Set<string>(["name", "email", "phone"]);
const after = new Set<string>(["name", "email", "address"]);

const changedKeys = before.symmetricDifference(after);
// Set(2) { "phone", "address" }

要高亮显示状态对象两个快照之间出现或消失的键,只需对其键集合求对称差即可。有一个细节是各发布公告所忽略的:迭代顺序取决于接收方。根据 ECMA-262 2025symmetricDifference() 先按接收方顺序返回仅属于接收方的元素,再按 other.keys() 的顺序返回仅属于另一方的元素。无论从哪一侧调用,结果的元素集合是相同的,但顺序不同。

使用布尔谓词进行权限检查

三个谓词方法——isSubsetOf()isSupersetOf()isDisjointFrom()——每个都返回一个布尔值,且每个都能直接对应常见的授权或输入验证场景。

isSupersetOf():检查是否具备所有必要的 scope

isSupersetOf() 在此集合包含给定集合的每个元素时返回 true

const grantedScopes = new Set<string>(["read", "write", "delete"]);
const requiredScopes = new Set<string>(["read", "write"]);

const hasAllRequiredScopes = grantedScopes.isSupersetOf(requiredScopes);
// true

要检查用户已授予的 OAuth scope 是否覆盖某个操作所需的全部 scope,grantedScopes.isSupersetOf(requiredScopes) 只需一次调用即可返回结果——等价于 [...requiredScopes].every(s => grantedScopes.has(s)),但以集合关系的方式表达更为清晰。

isSubsetOf():验证标签列表是否完全受支持

isSubsetOf() 在此集合的每个元素都在给定集合中时返回 true

const supportedTags = new Set<string>(["sale", "new", "featured", "clearance"]);
const requestedTags = new Set<string>(["sale", "new"]);

const allSupported = requestedTags.isSubsetOf(supportedTags);
// true

当调用方传入一组标签或过滤条件时,requestedTags.isSubsetOf(supportedTags) 可在执行查询前确认每个标签都是已知的。

isDisjointFrom():检测冲突的修饰键

isDisjointFrom() 在两个集合没有共同元素时返回 true

const pressedKeys = new Set<string>(["Meta", "Shift"]);
const conflictingModifiers = new Set<string>(["Control", "Alt"]);

const noConflict = pressedKeys.isDisjointFrom(conflictingModifiers);
// true

在键盘快捷键处理场景中,isDisjointFrom() 可让你在触发操作前断言没有冲突的修饰键被按下。

Set-like 协议:参数不必是 Set

这些方法的参数不必是 Set 实例——它只需是一个具有数值类型 size 属性、可调用的 has 方法和可调用的 keys 方法的对象。Map 原生满足这一要求,这意味着 mySet.intersection(myMap) 是合法的,并会针对 map 的键进行检查。几乎所有发布公告都将参数描述为”另一个 set”,这在技术上是不完整的。

该协议在 ECMA-262 规范的 GetSetRecord 中定义,MDN 也直接记录了 Set-like 对象的要求

const map = new Map([
  ["a", 1],
  ["b", 2],
]);
const set = new Set(["a", "c"]);

set.intersection(map);
// Set(1) { "a" }  — 针对 map 的键进行检查

在 Node v22.16.0 中验证,set.intersection(map) 返回 Set { 'a' },因为 "a" 是该 set 中唯一也出现在 map 键中的元素。这对互操作性很重要:你可以将一个 Set 与一个 Map 的键求交,而无需先构建一个中间的 new Set(map.keys());任何暴露了 sizehaskeys 的不可变集合库或自定义索引对象都可以直接插入这些方法,无需转换。

对象同一性陷阱

Set 按引用而非结构比较值。MDN 的值相等性文档对此有明确说明。当你的集合中存放对象时,这一特性会带来实际的陷阱:

new Set([{ id: 1 }]).intersection(new Set([{ id: 1 }]));
// Set(0) {}

引用相等性是这里的陷阱所在:此处返回空集合,因为两个 {id: 1} 对象是不同的引用,即使它们看起来完全相同。解决方案是对稳定的原始类型 ID 构成的 Set 进行操作,再从查找 map 中重新获取对象:

type User = { id: number; name: string };

const prev: User[] = [{ id: 1, name: "Ada" }, { id: 2, name: "Lin" }];
const next: User[] = [{ id: 2, name: "Lin" }, { id: 3, name: "Mo" }];

const byId = new Map(next.map((u) => [u.id, u]));

const prevIds = new Set(prev.map((u) => u.id));
const nextIds = new Set(next.map((u) => u.id));

const addedIds = nextIds.difference(prevIds); // Set(1) { 3 }
const added = [...addedIds].map((id) => byId.get(id)!);
// [{ id: 3, name: "Mo" }]

这类引用相等性 bug 不会留下任何堆栈跟踪。集合操作会正常完成,返回一个空集合,而 UI 就是不更新——多选组件无法改变状态、权限徽章无法清除、diff 视图显示没有任何变化。由于不会抛出异常,它不会出现在错误监控中。Session replay 是一种能让这类静默失败 bug 可见的技术:你可以观察用户的交互过程、操作的完成,以及界面对此毫无响应。

性能:为何优于 filter + includes

[...a].filter(x => b.includes(x)) 替换为 a.intersection(b) 不仅仅是可读性的提升。Array.includes() 的时间复杂度为 O(n),使得 filter 模式在两个含 n 个元素的集合上的复杂度为 O(n²),而 intersection() 在规范要求的平均次线性 Set 访问下随两个集合中较小者的规模扩展。ECMA-262 Set 对象章节要求 Set 访问在平均情况下以次线性复杂度执行——这并不保证 O(1)——MDN 的 intersection() 参考文档也描述了随较小集合规模扩展的行为。

操作数组模式复杂度原生 Set 方法复杂度
求交[...a].filter(x => b.includes(x))O(n²)a.intersection(b)随较小集合扩展,平均次线性访问
求并new Set([...a, ...b])O(n + m)a.union(b)O(n + m)
求差[...a].filter(x => !b.includes(x))O(n²)a.difference(b)a 扩展,平均次线性访问

差距随集合规模的增大而扩大:元素数量较少时影响可忽略不计,但数组模式会以二次方速度退化,而原生方法则不会。

迁移方案

将现有的数组比较代码直接映射到新方法上。以下三种替换涵盖了绝大多数实际场景。

旧模式新方法
[...a].filter(x => b.includes(x))a.intersection(b)
[...new Set([...a, ...b])]a.union(b)(返回一个 Set
[...a].filter(x => !b.includes(x))a.difference(b)

如果你的数据存放在数组中,只需将两侧各包装一次 new Set(...),执行操作,如果下游 API 需要数组,再展开回去即可:

const a = ["x", "y", "z"];
const b = ["y", "z", "w"];

const common = [...new Set(a).intersection(new Set(b))];
// ["y", "z"]

转换为 Set 本身是 O(n) 的,但这一来回转换避免了 O(n²) 的 filter + includes 模式所需的嵌套扫描,并随集合规模的增长表现出更好的扩展性。

浏览器支持与 polyfill

全部七个 Set 方法均已于 2024 年 6 月 11 日成为 Baseline Newly Available,在 Chrome 122(2024 年 2 月 20 日)、Edge 122(2024 年 2 月 23 日)、Firefox 127(2024 年 6 月 11 日)和 Safari 17(2023 年 9 月 18 日)中均已内置,针对当前浏览器目标无需 polyfill。它们也通过 V8 12.4 在 Node.js 22.0.0(2024 年 4 月 24 日)中提供支持。

环境版本发布日期
Chrome1222024 年 2 月 20 日
Edge1222024 年 2 月 23 日
Firefox1272024 年 6 月 11 日
Safari172023 年 9 月 18 日
Node.js22.0.0(V8 12.4)2024 年 4 月 24 日

对于旧版目标环境,core-js 库和 es-shims 项目提供了符合规范的 polyfill。如果你只需支持当前的常青浏览器和 Node.js 22+,则可以完全省去 polyfill。

下一步

审查你的代码库,找出对去重数据使用 filter + includesfilter + !includes 的地方——这些都是直接替换为 intersection()difference() 的候选对象。在迁移存放对象的集合之前,先将其切换为由稳定原始类型 ID 构成的 Set,以避免引用相等性陷阱;同时记住,任何暴露了 sizehaskeysMap、不可变集合或自定义索引都可以直接作为参数传入。这些方法已是 Baseline,语义已在规范中稳定,而它们所取代的数组模式从来都没有看起来那么廉价。

常见问题

我可以直接将 Map 传给 Set.intersection(),还是需要先转换?

可以直接传入 Map。新的 Set 方法接受任何 Set-like 对象,ECMA-262 规范将其定义为具有数值类型 size 属性、可调用的 has 方法和可调用的 keys 方法的对象。Map 原生满足这一要求,因此 mySet.intersection(myMap) 可直接针对 map 的键进行检查,无需构建中间的 new Set(map.keys())。暴露了相同三个成员的不可变集合库 set 和自定义索引对象也无需转换即可直接使用。

为什么当两个集合包含相同的对象时,Set 求交返回空集合?

因为 Set 按引用而非结构比较值。表达式 new Set([{id:1}]).intersection(new Set([{id:1}])) 返回空集合,因为两个 {id:1} 对象是不同的引用,即使它们看起来完全相同,在 Node v22.16.0 中已验证。解决方案是构建由稳定原始类型 ID 组成的 Set,在这些 ID 上执行操作,然后从以 ID 为键的查找 Map 中重新获取匹配的对象。

difference() 和 symmetricDifference() 有什么区别?

difference() 是有方向性的,不满足交换律:a.difference(b) 返回在 a 中存在但在 b 中缺失的元素,而 b.difference(a) 则返回反向结果。symmetricDifference() 返回存在于任一集合但不同时存在于两者的元素,在内容上与调用顺序无关。两者的迭代顺序也不同:symmetricDifference() 先按接收方顺序返回仅属于接收方的元素,再按 other.keys() 的顺序返回仅属于另一方的元素,因此无论从哪一侧调用,结果的元素集合相同,但顺序不同。

在生产环境中,新的 Set 方法还需要 polyfill 吗?

对于当前浏览器目标,不需要。全部七个方法均已于 2024 年 6 月 11 日成为 Baseline Newly Available,在 Chrome 122、Edge 122、Firefox 127、Safari 17 以及通过 V8 12.4 的 Node.js 22.0.0 中均已内置。如果你只需支持当前的常青浏览器和 Node.js 22 及以上版本,可以完全省去 polyfill。对于旧版目标环境,core-js 库和 es-shims 项目提供了符合规范的 polyfill,可按需引入。

Open-source session replay

Complete picture for complete understanding

Capture every clue your frontend is leaving so you can instantly get to the root cause of any issue with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.