JavaScript数据结构深度对比:Array与Set、Object与Map
目录
引言
在JavaScript开发中,我们经常需要在不同的数据结构中做选择。特别是ES6引入了Set和Map这两种新的数据结构后,我们有了更多的选择。本文将深入探讨Array与Set这一组有序集合,以及Object与Map这一组键值对集合之间的关系、区别和应用场景,帮助开发者在实际项目中做出更明智的选择。
数据结构概览
在开始详细比较之前,让我们先通过图表了解这四种数据结构的基本定位:
graph TD
A[JavaScript数据结构] --> B[有序集合]
A --> C[键值对集合]
B --> D[Array 数组]
B --> E[Set 集合]
C --> F[Object 对象]
C --> G[Map 映射]
D --> D1[可重复元素]
D --> D2[索引访问]
E --> E1[唯一元素]
E --> E2[值访问]
F --> F1[字符串键]
F --> F2[原型链]
G --> G1[任意类型键]
G --> G2[无原型污染]
style D fill:#ffdddd
style E fill:#ddffdd
style F fill:#ddddff
style G fill:#ffffdd
Array与Set:有序集合的两种选择
Array和Set都属于有序集合,用于存储一系列值。它们都保持元素的插入顺序,但在元素唯一性、访问方式和API设计上有明显差异。
Array基础特性
数组是JavaScript中最基础的有序集合,它具有以下特点:
- 有序:元素按插入顺序排列,有明确的索引
- 可重复:可以存储重复的值
- 动态大小:可以随时添加或删除元素
- 索引访问:可以通过数字索引直接访问元素
const fruits = ['apple', 'banana', 'apple', 'orange'];
console.log(fruits[0]); // 'apple'
console.log(fruits.length); // 4Set基础特性
Set是ES6引入的新数据结构,它是值的集合,具有以下特点:
- 无重复值:自动去除重复元素
- 有序:虽然没有索引,但元素按插入顺序排列
- 高效查找:检查元素存在性的性能较高
- 简洁API:提供了add、delete、has、clear等方法
const fruitsSet = new Set(['apple', 'banana', 'apple', 'orange']);
console.log(fruitsSet.size); // 3 (重复的'apple'被去除)
console.log(fruitsSet.has('banana')); // trueArray vs Set:API详解
Array和Set提供了不同的API方法用于操作数据。下面详细比较两者的主要API:
classDiagram
class Array {
+length: number
+push(item): number
+pop(): any
+shift(): any
+unshift(...items): number
+splice(start, deleteCount, ...items): Array
+slice(start, end): Array
+indexOf(item): number
+includes(item): boolean
+concat(...arrays): Array
+join(separator): string
+map(callback): Array
+filter(callback): Array
+reduce(callback): any
+sort(compareFn): Array
+reverse(): Array
}
class Set {
+size: number
+add(value): Set
+delete(value): boolean
+has(value): boolean
+clear(): void
+forEach(callback): void
+values(): Iterator
+keys(): Iterator
+entries(): Iterator
}
Array主要API详解:
| API | 描述 | 返回值 | 示例 |
|---|---|---|---|
push() | 在数组末尾添加一个或多个元素 | 新数组长度 | arr.push('apple') |
pop() | 移除并返回数组的最后一个元素 | 被移除的元素 | arr.pop() |
shift() | 移除并返回数组的第一个元素 | 被移除的元素 | arr.shift() |
unshift() | 在数组开头添加一个或多个元素 | 新数组长度 | arr.unshift('apple') |
splice() | 从数组中添加/删除元素 | 被删除元素的数组 | arr.splice(2, 1, 'new') |
slice() | 返回数组的一部分的浅拷贝 | 新数组 | arr.slice(1, 3) |
indexOf() | 查找元素的索引 | 索引或-1 | arr.indexOf('apple') |
includes() | 判断数组是否包含某元素 | 布尔值 | arr.includes('apple') |
map() | 对每个元素执行函数并返回新数组 | 新数组 | arr.map(x => x * 2) |
filter() | 过滤符合条件的元素到新数组 | 新数组 | arr.filter(x => x > 3) |
reduce() | 将数组元素计算为一个值 | 累积结果 | arr.reduce((a, b) => a + b) |
sort() | 对数组元素进行排序 | 排序后的数组 | arr.sort((a, b) => a - b) |
forEach() | 为每个元素执行函数 | undefined | arr.forEach(x => console.log(x)) |
Set主要API详解:
| API | 描述 | 返回值 | 示例 |
|---|---|---|---|
add() | 添加一个新元素 | Set对象本身 | set.add('apple') |
delete() | 删除一个元素 | 布尔值,成功为true | set.delete('apple') |
has() | 是否存在某元素 | 布尔值 | set.has('apple') |
clear() | 清空所有元素 | undefined | set.clear() |
forEach() | 对每个元素执行函数 | undefined | set.forEach(x => console.log(x)) |
values() | 返回值的迭代器 | 迭代器 | for (let v of set.values()) {...} |
keys() | 与values()相同(为兼容Map) | 迭代器 | for (let k of set.keys()) {...} |
entries() | 返回[值,值]的迭代器 | 迭代器 | for (let [k,v] of set.entries()) {...} |
graph LR
A[Array操作类型] --> B[增删元素]
A --> C[查找元素]
A --> D[迭代操作]
A --> E[变形操作]
B --> B1[push/pop]
B --> B2[shift/unshift]
B --> B3[splice]
C --> C1[indexOf]
C --> C2[includes]
C --> C3[find/findIndex]
D --> D1[forEach]
D --> D2[map]
D --> D3[filter]
D --> D4[reduce]
E --> E1[sort]
E --> E2[reverse]
E --> E3[concat]
F[Set操作类型] --> G[增删元素]
F --> H[查找元素]
F --> I[迭代操作]
G --> G1[add]
G --> G2[delete]
G --> G3[clear]
H --> H1[has]
I --> I1[forEach]
I --> I2[values/keys]
I --> I3[entries]
使用场景对比
下面是Array和Set在不同场景下的适用性比较:
mindmap
root((Array vs Set))
Array适用场景
需要通过索引访问元素
元素顺序重要且需维护
可能包含重复元素
需要频繁执行数组方法(map, filter, reduce等)
需要处理大量连续数据
需要对数据进行排序
需要使用栈或队列的数据结构
Set适用场景
需要保证元素唯一性
频繁检查元素是否存在
需要快速去重
需要高效的交集、并集、差集操作
不需要通过索引访问
只关心值是否存在,不需要存储额外数据
需要避免重复添加相同数据
性能对比
| 操作 | Array | Set |
|---|---|---|
| 查找元素 | O(n) | O(1) |
| 添加元素 | O(1)* | O(1) |
| 删除元素 | O(n) | O(1) |
| 检查包含 | O(n) | O(1) |
| 迭代元素 | O(n) | O(n) |
*注:数组在末尾添加元素为O(1),在开头或中间添加元素为O(n)
在实际的性能测试中,Set在查找、添加和删除操作上通常比Array快,特别是数据量较大时:
// 示例:比较Array和Set查找性能
const arr = Array.from({length: 1000000}, (_, i) => i);
const set = new Set(arr);
const target = 999999;
console.time('Array查找');
arr.includes(target);
console.timeEnd('Array查找'); // 通常在几十毫秒
console.time('Set查找');
set.has(target);
console.timeEnd('Set查找'); // 通常在微秒级别Object与Map:键值对集合的两种选择
Object和Map都属于键值对集合,用于存储"键->值"的映射关系。但它们在键的类型、顺序保证和API设计上存在显著差异。
Object基础特性
对象是JavaScript中最常用的键值对集合,它具有以下特点:
- 键限制:键只能是字符串或Symbol
- 原型链:继承原型链上的属性和方法
- 内置方法:如hasOwnProperty、toString等
- 直接量语法:可以使用{}直接创建
const user = {
name: 'Alice',
age: 30,
'user-id': 12345
};
console.log(user.name); // 'Alice'
console.log(user['user-id']); // 12345Map基础特性
Map是ES6引入的新数据结构,专门用于存储键值对,具有以下特点:
- 任意键类型:键可以是任何类型,包括对象、函数等
- 有序:键值对按插入顺序排列
- 大小获取:可以直接通过size属性获取长度
- 专用方法:提供了set、get、has、delete、clear等专用方法
const userMap = new Map();
const userIdKey = { id: 1 };
userMap.set(userIdKey, { name: 'Alice', age: 30 });
userMap.set('role', 'admin');
console.log(userMap.get(userIdKey)); // { name: 'Alice', age: 30 }
console.log(userMap.size); // 2Object vs Map:API详解
Object和Map提供了不同的API方法用于操作键值对数据。下面详细比较两者的主要API:
classDiagram
class Object {
+Object.keys(obj): Array
+Object.values(obj): Array
+Object.entries(obj): Array
+Object.assign(target, ...sources): Object
+Object.create(proto): Object
+Object.defineProperty(obj, prop, descriptor): Object
+obj.hasOwnProperty(prop): boolean
+obj.propertyIsEnumerable(prop): boolean
+obj.toString(): string
+obj.valueOf(): primitive
}
class Map {
+size: number
+set(key, value): Map
+get(key): any
+has(key): boolean
+delete(key): boolean
+clear(): void
+forEach(callback): void
+keys(): Iterator
+values(): Iterator
+entries(): Iterator
}
Object主要API详解:
| API | 描述 | 返回值 | 示例 |
|---|---|---|---|
Object.keys() | 返回对象自身可枚举属性名数组 | 数组 | Object.keys(obj) |
Object.values() | 返回对象自身可枚举属性值数组 | 数组 | Object.values(obj) |
Object.entries() | 返回对象自身可枚举属性[键,值]数组 | 数组 | Object.entries(obj) |
Object.assign() | 将源对象属性复制到目标对象 | 目标对象 | Object.assign({}, obj) |
Object.create() | 创建指定原型的新对象 | 新对象 | Object.create(proto) |
hasOwnProperty() | 检查是否有自己的属性(非原型) | 布尔值 | obj.hasOwnProperty('prop') |
in操作符 | 检查属性是否存在(含原型链) | 布尔值 | 'prop' in obj |
| 属性访问 | 通过.或[]访问属性 | 属性值 | obj.prop或obj['prop'] |
| 属性赋值 | 通过.或[]设置属性 | 赋值 | obj.prop = value |
| 删除属性 | 使用delete操作符删除属性 | 布尔值 | delete obj.prop |
Map主要API详解:
| API | 描述 | 返回值 | 示例 |
|---|---|---|---|
set() | 设置键值对 | Map对象本身 | map.set(key, value) |
get() | 获取指定键的值 | 值或undefined | map.get(key) |
has() | 检查键是否存在 | 布尔值 | map.has(key) |
delete() | 删除指定键的键值对 | 布尔值,成功为true | map.delete(key) |
clear() | 清空所有键值对 | undefined | map.clear() |
size | 获取键值对数量 | 数值 | map.size |
forEach() | 对每个键值对执行函数 | undefined | map.forEach((v, k) => console.log(k, v)) |
keys() | 返回键的迭代器 | 迭代器 | for (let k of map.keys()) {...} |
values() | 返回值的迭代器 | 迭代器 | for (let v of map.values()) {...} |
entries() | 返回[键,值]的迭代器 | 迭代器 | for (let [k, v] of map.entries()) {...} |
graph LR
A[Object操作类型] --> B[属性访问]
A --> C[属性检查]
A --> D[遍历属性]
A --> E[对象操作]
B --> B1["obj.prop / obj['prop']"]
B --> B2["obj.prop = value"]
B --> B3["delete obj.prop"]
C --> C1["'prop' in obj"]
C --> C2["obj.hasOwnProperty('prop')"]
D --> D1["Object.keys(obj)"]
D --> D2["Object.values(obj)"]
D --> D3["Object.entries(obj)"]
D --> D4["for...in循环"]
E --> E1["Object.assign()"]
E --> E2["Object.create()"]
E --> E3["Object.freeze()"]
F[Map操作类型] --> G[键值操作]
F --> H[检查操作]
F --> I[遍历操作]
F --> J[整体操作]
G --> G1["map.set(key, value)"]
G --> G2["map.get(key)"]
G --> G3["map.delete(key)"]
H --> H1["map.has(key)"]
H --> H2["map.size"]
I --> I1["map.forEach()"]
I --> I2["map.keys()"]
I --> I3["map.values()"]
I --> I4["map.entries()"]
J --> J1["map.clear()"]
J --> J2["new Map([...])"]
J --> J3["[...map]"]
使用场景对比
下面是Object和Map在不同场景下的适用性比较:
mindmap
root((Object vs Map))
Object适用场景
简单的键值存储
JSON兼容性要求
需要使用原型链
键都是字符串或符号
需要使用对象字面量
需要使用JSON.stringify/parse
作为类的实例或原型
配置对象和参数对象
Map适用场景
键可能是非字符串
需要频繁增删键值对
需要维护键值对顺序
需要高效遍历所有键值
需要获取键值对数量
需要以对象作为键
需要保证键不会被意外覆盖
对性能有较高要求的场景
性能对比
| 操作 | Object | Map |
|---|---|---|
| 查找键 | 70 | 85 |
| 添加键值 | 80 | 85 |
| 删除键值 | 65 | 85 |
| 迭代 | 60 | 90 |
| 获取大小 | 50 | 100 |
*注:数值表示相对性能,越高越好(满分100)
在实际的性能测试中,Map在频繁添加和删除键值对时通常比Object更高效,特别是处理大量数据时:
// 示例:比较Object和Map的频繁操作性能
const count = 1000000;
// 测试Object
console.time('Object操作');
const obj = {};
for (let i = 0; i < count; i++) {
const key = `key${i}`;
obj[key] = i; // 添加
}
for (let i = 0; i < count; i++) {
const key = `key${i}`;
const val = obj[key]; // 读取
delete obj[key]; // 删除
}
console.timeEnd('Object操作');
// 测试Map
console.time('Map操作');
const map = new Map();
for (let i = 0; i < count; i++) {
const key = `key${i}`;
map.set(key, i); // 添加
}
for (let i = 0; i < count; i++) {
const key = `key${i}`;
const val = map.get(key); // 读取
map.delete(key); // 删除
}
console.timeEnd('Map操作');
// Map通常快10-30%内部实现原理
以下是这四种数据结构在JavaScript引擎中的简化实现原理:
| 数据结构 | 内部实现特点 |
|---|---|
| Array | • 连续内存区域 • 动态调整大小 • 稀疏数组优化 |
| Set | • 基于哈希表 • 引用值的Hash处理 • 高效查找算法 |
| Object | • 基于哈希表 • 字符串键优化 • 隐藏类优化 • 原型链查找 |
| Map | • 哈希表 + 链表 • 保持插入顺序 • 键的引用存储 • 高效迭代实现 |
各数据结构的内部实现详解:
Array实现原理:
- 在大多数JavaScript引擎中,数组是使用连续内存块实现的
- 对于稀疏数组(有空洞的数组),引擎会使用哈希表结构
- 当数组长度变化时,引擎会重新分配内存并复制元素
- 数组索引实际上是转换为字符串的特殊属性
Set实现原理:
- 基于哈希表实现,提供近似O(1)的查找、添加和删除操作
- 对于对象和数组等引用类型值,通过内存地址进行唯一性判断
- 内部使用特殊的标记来记录元素的插入顺序
- 由于要保证唯一性,需要额外的内存来存储哈希信息
Object实现原理:
- 基于哈希表实现,键被转换为字符串或符号
- 使用"隐藏类"(hidden class)优化相同结构对象的访问速度
- 原型链查找使用指针链接,影响深层属性的查找性能
- 属性有多种内部标记(数据属性、访问器属性等)
Map实现原理:
- 使用哈希表加链表的复合结构
- 键可以是任意类型,引用类型键通过内存地址区分
- 内部维护插入顺序,使用链表实现
- 为了支持高效迭代,维护了额外的数据结构
综合对比表
| 特性 | Array | Set | Object | Map |
|---|---|---|---|---|
| 存储内容 | 值的有序列表 | 唯一值的集合 | 字符串/Symbol键的值 | 任意类型键的值 |
| 元素顺序 | 有序 | 有序(插入顺序) | 无序(ES2015前) 基本有序(ES2015后) | 有序(插入顺序) |
| 键的类型 | 数字索引 | N/A | 字符串或Symbol | 任意值 |
| 重复元素 | 允许 | 自动去重 | 键不可重复 | 键不可重复 |
| 访问元素 | arr[0] | 迭代或转数组 | obj.keyobj['key'] | map.get(key) |
| 判断大小 | array.length | set.size | 需手动计算 Object.keys(obj).length | map.size |
| 添加元素 | push()unshift()等 | add() | obj[key] = value | set(key, value) |
| 删除元素 | splice()pop()等 | delete() | delete obj[key] | delete(key) |
| 查找元素 | indexOf()includes() | has() | key in objobj.hasOwnProperty() | has(key) |
| 迭代性能 | 优秀 | 优秀 | 一般 | 优秀 |
| 内存占用 | 中等 | 较高 | 较低 | 较高 |
| 序列化 | 原生支持JSON | 需转为数组 | 原生支持JSON | 需转为数组或对象 |
| 适用场景 | 有序数据 需要索引访问 | 需要唯一值 频繁查重 | 简单键值对 JSON兼容 | 复杂键值对 频繁增删 |
实际应用示例
- 数组去重
// 使用Array
function uniqueArray(array) {
const result = [];
for (const item of array) {
if (result.indexOf(item) === -1) {
result.push(item);
}
}
return result;
}
// 使用Set (更高效)
function uniqueWithSet(array) {
return [...new Set(array)];
}
// 性能测试
const largeArray = Array.from({length: 10000}, (_, i) => i % 1000);
console.time('Array去重');
uniqueArray(largeArray);
console.timeEnd('Array去重');
console.time('Set去重');
uniqueWithSet(largeArray);
console.timeEnd('Set去重');
// Set方法通常快10-100倍- 频繁查找
// 使用Object
function countWordsWithObject(words) {
const counts = {};
for (const word of words) {
counts[word] = (counts[word] || 0) + 1;
}
return counts;
}
// 使用Map
function countWordsWithMap(words) {
const counts = new Map();
for (const word of words) {
counts.set(word, (counts.get(word) || 0) + 1);
}
return counts;
}
// 当键是简单字符串时,性能相似
// 但当需要使用对象作为键时,Map是唯一选择
const userObj = { id: 1 };
const userMap = new Map();
userMap.set(userObj, "userData"); // 使用对象作为键- 集合操作
// 使用Set实现集合操作
const set1 = new Set([1, 2, 3, 4]);
const set2 = new Set([3, 4, 5, 6]);
// 并集
const union = new Set([...set1, ...set2]);
console.log([...union]); // [1, 2, 3, 4, 5, 6]
// 交集
const intersection = new Set([...set1].filter(x => set2.has(x)));
console.log([...intersection]); // [3, 4]
// 差集
const difference = new Set([...set1].filter(x => !set2.has(x)));
console.log([...difference]); // [1, 2]- 使用Map处理复杂键
// 使用Map保存用户数据,以DOM元素为键
const userDataMap = new Map();
// 存储DOM元素关联的数据
document.querySelectorAll('.user-item').forEach(element => {
userDataMap.set(element, {
id: element.dataset.id,
name: element.dataset.name,
score: parseInt(element.dataset.score)
});
});
// 事件处理中获取数据
document.body.addEventListener('click', event => {
const userItem = event.target.closest('.user-item');
if (userItem && userDataMap.has(userItem)) {
const userData = userDataMap.get(userItem);
console.log(`用户 ${userData.name} 的分数是 ${userData.score}`);
}
});我的思考与建议
在现代JavaScript开发中,我认为对这四种数据结构的选择应该基于以下几个原则:
- 数据的唯一性需求:需要保证唯一性时首选Set
- 键值对的键类型:非字符串键首选Map
- 操作频率:频繁查找优先使用Set/Map,频繁整体操作选择Array/Object
- 序列化需求:需要JSON序列化时使用Array/Object
- 顺序重要性:顺序重要且需要频繁重排时使用Array,键值对顺序重要时使用Map
- 内存和性能平衡:大数据量时,谨慎使用Set/Map,可能占用更多内存
flowchart TD
A[选择数据结构] --> B{存储形式?}
B -->|值列表| C{需要唯一性?}
B -->|键值对| D{键的类型?}
C -->|是| E[Set]
C -->|否| F{需要索引访问?}
F -->|是| G[Array]
F -->|否| E
D -->|字符串/Symbol| H{需要原型方法?}
D -->|其他类型| I[Map]
H -->|是| J[Object]
H -->|否| K{需要频繁操作?}
K -->|是| I
K -->|否| J
style E fill:#ddffdd
style G fill:#ffdddd
style I fill:#ffffdd
style J fill:#ddddff
我的建议是学习所有这些数据结构的优缺点,并根据具体场景选择最合适的:
- 新项目中,更多地使用Set和Map来获得更好的语义和性能
- 需要与旧代码互操作或序列化时,仍然使用Array和Object
- 对性能关键的代码,进行基准测试后再决定使用哪种数据结构
- 当处理唯一值集合时,总是优先考虑Set而不是Array加去重
- 当需要频繁查找、添加、删除键值对时,优先考虑Map而不是Object
结论
JavaScript的这四种核心数据结构各有所长,没有绝对的最佳选择。通过理解它们的异同和适用场景,我们可以在开发中做出更明智的选择:
- Array:有序、可重复、索引访问,适合有序数据集合
- Set:有序、唯一值、高效查找,适合需要唯一性的集合
- Object:键值对、字符串键、原型继承,适合简单的结构化数据
- Map:有序键值对、任意键类型、高效操作,适合复杂的键值对数据
这四种数据结构相辅相成,形成了JavaScript完整的数据处理能力。在现代JavaScript开发中,应该充分利用这些数据结构的特点,根据实际需求选择最合适的数据结构,以便编写出更高效、更可维护的代码。