JS 中 Set 和 Array 有什么区别?何时该用 Set
深入解析 JavaScript 中 Set 与 Array 的核心区别,从数据模型、唯一性、检索复杂度到实际业务场景,给出明确的选型与使用指南。
map/filter)以及维持严格的业务排序;而 Set 是基于值的唯一集合,天然强制去重,底层依赖类哈希表的散列机制,在元素存在性检查(has)和增删操作上拥有常数级(接近 O(1))的高效表现。当业务侧重于“数据唯一性”或“高频查找判定”时应果断使用 Set;若依赖“数字索引”、“前后顺序控制”或“JSON 直接序列化”,则必须选择 Array。
一、核心模型:底层存储与索引机制差异
要透彻理解两者的区别,首先需要厘清它们在 JavaScript 引擎中所代表的完全不同的数据模型。
Array(数组)是 ECMAScript 中最经典、使用最广泛的线性数据结构。数组中的每个元素都有一个从 0 开始递增的整数下标(Index)。在 V8 等现代引擎中,如果数组元素类型密集,通常会被分配为连续内存以获得极致的访问速度。数组允许存放任意数量的相同值,并完全以插入或指定的顺序保留每一个元素。
Set(集合)是 ES6(ECMAScript 2015)正式引入的内建对象。它是一组唯一值的集合。Set 内部并不维护“下标 0、下标 1”这样的整数索引,而是将存储的“值”本身作为散列标识。当你试图向 Set 中插入已经存在的值时,引擎会自动检测并丢弃该重复写入操作。
如上图所示,由于两者底层架构的差异,导致了最关键的操作特质分化:
- 访问方式: Array 支持
arr[0]这种根据偏移量直接寻址的快速随机访问;Set 无法通过数值下标读写,要获取内部元素必须通过迭代器(如for...of、set.values())或将其展开。 - 唯一性控制: Array 不对内容重复性做任何拦截,重复插入会直接拉长数组;Set 无论写入多少次相同值,其尺寸
size始终严格等于唯一值的个数。 - 迭代顺序: 两者在现代规范中都保证了插入顺序(Insertion Order)。使用
for...of遍历 Set 时,元素会按照首次被add()进来的先后顺序依次输出。
二、性能量化:何时 Set 比 Array 快几个数量级?
在日常开发中,许多开发者习惯于使用 array.includes(target) 或 array.indexOf(target) !== -1 来判断一个元素是否存在。在数据量只有几十个时,这种写法的耗时微乎其微;但一旦数据规模增长到成千上万,两者的性能差距会变得非常悬殊。
Array 的查找本质是线性扫描(Linear Search)。引擎必须从索引 0 开始逐个比对,直到找到目标值或遍历至末尾,其时间复杂度为 O(N)。如果一个包含 100,000 个 ID 的数组,在每次用户交互时都要做多次 includes 查询,总比较次数会呈乘积级爆炸。
与之相反,Set 的底层是基于散列/哈希表构建的。通过 set.has(target) 进行匹配时,引擎能够通过散列函数直接计算存储桶位置,平均时间复杂度仅为 O(1)(常数时间)。无论 Set 中存放了 10 个还是 100,000 个元素,判断它是否存在的耗时几乎完全相同。
// 性能对比场景伪代码与原理演示
const count = 100000;
const rawList = Array.from({ length: count }, (_, i) => `item_${i}`);
const testArray = [...rawList];
const testSet = new Set(rawList);
// 场景:高频检索不存在或位于末尾的元素
console.time("Array includes");
testArray.includes("item_99999"); // 需要扫描接近 100,000 次
console.timeEnd("Array includes"); // 耗时通常在数毫秒至数十毫秒不等
console.time("Set has");
testSet.has("item_99999"); // 散列直接定位,耗时约为 0.01 毫秒
console.timeEnd("Set has"); // 差距可达数百至数千倍
同样地,对于元素的删除操作:Array 使用 splice 移除元素后,后续所有元素在内存中的下标都必须向前移动一位,开销同样为 O(N);而 Set 提供的 set.delete(val) 只需要定位并置空对应的散列槽位,开销稳定在 O(1)。
三、实战指南:Set 的核心用法与典型场景
1. 数组极速去重
将重复数据清洗为唯一列表是 Set 最广为人知的用途。得益于 ES6 展开运算符(Spread Operator)与 Array.from,开发者可以只用一行代码完成去重并回归数组形态。
// 基础数据类型去重
const duplicatedNumbers = [1, 3, 5, 3, 2, 1, 4, 5];
const uniqueNumbers = [...new Set(duplicatedNumbers)];
// 输出结果: [1, 3, 5, 2, 4](严格保留首次出现的顺序)
2. 高频交互状态判定(选中态、白名单、黑名单)
在前端交互界面中,我们经常需要维护“被用户选中的行/卡片 ID”或“只读字段白名单”。在列表项快速点击、全选、反选等频繁触发的场景下,将已选 ID 存储在 Set 中能带来明显的响应提升。
// 维护列表选中项的 ID 集合
const selectedIds = new Set();
// 切换选中状态
function toggleSelection(id) {
if (selectedIds.has(id)) {
selectedIds.delete(id);
} else {
selectedIds.add(id);
}
}
// 渲染判断(在成千上万行渲染循环中极其轻快)
function isRowSelected(id) {
return selectedIds.has(id); // O(1) 瞬时判断
}
3. 数学集合运算:并集、交集与差集
在权限比对、标签过滤和好友关系匹配中,经常需要计算两个集合的共有项或差异项。借助 Set 的 has 配合迭代,可以用非常干净的声明式语法完成运算。
const setA = new Set(["React", "Vue", "Node"]);
const setB = new Set(["Vue", "Angular", "Node"]);
// 并集 (Union)
const union = new Set([...setA, ...setB]);
// Set(4) { "React", "Vue", "Node", "Angular" }
// 交集 (Intersection)
const intersection = new Set([...setA].filter(x => setB.has(x)));
// Set(2) { "Vue", "Node" }
// 差集 (Difference: 属于 A 但不属于 B)
const difference = new Set([...setA].filter(x => !setB.has(x)));
// Set(1) { "React" }
setA.union(setB)、setA.intersection(setB)、setA.difference(setB)、setA.isSubsetOf(setB) 等。在现代浏览器(Chrome 122+、Safari 17+)或 Node.js 22+ 环境中,可直接调用原生方法而无需手动展开转数组。
四、避坑指南:使用 Set 必须警惕的边界陷阱
1. 引用类型的判等陷阱(SameValueZero 规则)
Set 内部判定两个值是否相同所采用的是 SameValueZero 算法。这种机制与严格相等运算符 === 极为接近,唯一的微小差别在于它认为 NaN 与 NaN 是相等的,且 +0 与 -0 是相等的。
极易踩坑的地方在于对象与数组等引用类型:
const objectSet = new Set();
objectSet.add({ id: 101 });
objectSet.add({ id: 101 });
console.log(objectSet.size); // 输出 2!未能去重!
console.log(objectSet.has({ id: 101 })); // 输出 false!
原因在于:两个独立字面量 { id: 101 } 在堆内存中开辟了不同的存储地址,两者的引用指针不同。如果你需要对包含复杂对象的数组按某字段去重,单纯用 new Set(arr) 是无效的,必须借助 Map 记录唯一 key,或手动维护唯一标识。
2. JSON 序列化缺失
JSON.stringify() 无法原生直接序列化 Set。如果直接将包含 Set 的对象传给序列化函数,Set 会被简单地转换为一个空对象 {}:
const payload = {
username: "alice",
roles: new Set(["admin", "editor"])
};
console.log(JSON.stringify(payload));
// 结果是: '{"username":"alice","roles":{}}' —— roles 中的数据全部丢失!
// 正确做法:在序列化前显式转回数组
payload.roles = Array.from(payload.roles);
console.log(JSON.stringify(payload));
// 结果: '{"username":"alice","roles":["admin","editor"]}'
3. 原型方法缺失与内存占用
Set 没有 map、filter、reduce、slice、sort 等数组高频操作方法。当需要对集合中的各项数据进行变换或重排序时,必须先将 Set 转为 Array。此外,Set 为了实现 O(1) 散列定位,其内部节点需要额外的哈希桶和链表指针空间,相同数据量下 Set 占用的内存通常比紧凑的原生数组要高出 30% 到 100%。
五、选型准则:何时应该坚守使用 Array?
尽管 Set 在特定场景下优势显著,但在日常大部分业务开发中,Array 依旧是绝对的主角。在以下场景下,你不应该盲目替换为 Set:
- 需要按索引精确定位: 如果业务逻辑包含“取第 1 个元素”、“取最后 1 个元素”、“通过滑块跳转到第 N 项”,或者需要使用二分查找法,必须使用具备下标机制的 Array。
- 需要排序与位置调整: 列表拖拽重排、按时间/金额升序降序排列(
sort)、元素翻转(reverse)等操作,均基于连续索引展开。 - 本身包含合法重复数据: 例如用户操作日志(多次点击相同按钮)、财务流水、聊天消息流等。这类业务本身不允许去重,必须完整保留每一次重复发生的事实。
- 频繁的多步数据流处理: 需要大量链式调用
.map().filter().reduce().flatMap()的数据清洗工作,Array 提供的原生方法生态最为完善直观。
六、全景选型决策树与对照总结
为了在实际工程开发中快速做出最清晰的抉择,可以通过下面的决策路径图与对照表进行综合研判:
| 比对维度 | Array (数组) | Set (集合) | 工程选型建议 |
|---|---|---|---|
| 数据唯一性 | 允许任意重复值 | 强制唯一,自动去重 | 需要无重复名单时用 Set,需保留重复记录用 Array |
| 随机索引访问 | 支持 arr[index],O(1) |
不支持下标访问 | 需要按位置/索引存取元素时必须使用 Array |
| 存在性判断 (has) | includes() 为 O(N) 线性扫描 |
has() 为 O(1) 散列定位 |
高频次在海量数据中检索某项是否存在时首选 Set |
| 元素删除 (delete) | splice() 需移动后续下标,O(N) |
delete() 散列标记,O(1) |
频繁动态新增和移除大量 ID 状态时使用 Set |
| 内置方法丰富度 | 拥有 map, filter, sort, slice 等几十个方法 | 仅基础增删查清 (add, delete, has, clear) | 重度数据管道变换用 Array;简单集合操作用 Set |
| JSON 直接序列化 | 完全原生支持序列化为 [...] |
原生 JSON.stringify 会输出为 {} |
前后端网络接口传输、本地存储前务必使用 Array |
总结: 在现代 JavaScript 架构中,Set 和 Array 绝非替代关系,而是互补的利器。最优秀的实践往往是“取长补短、协同使用”:在需要去重和高频判定的内部环节中把数据交给 Set 处理,在需要对外序列化传输、进行复杂排序或交给视图模板循环渲染时,再顺畅地还原为 Array。
JS处理含Emoji或中文的字符串转数组乱码问题
2026-11-05 16:12:28
JS怎么使用展开运算符合并对象
2026-11-05 14:42:18
ES6+环境下判断JS对象为空的简洁写法
2026-11-05 12:12:23
怎么通过replacer函数自定义JS中JSON的格式化输出
2026-11-05 11:19:44
JS正则表达式基础:字面量与RegExp构造函数的区别及常用元字符
2026-11-05 11:16:58







![JS创建二维数组:为什么Array(n).fill([])会导致数据联动](https://img.javascript.cn/tit/articles/13236/20261102/f618c991891ff37efb12df6af2ce0ccb0da8779a907fe1d2f7275d60c84fdd77.webp)
