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); // 4
Set基础特性
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')); // true
Array 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']); // 12345
Map基础特性
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); // 2
Object 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.key obj['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 obj obj.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开发中,应该充分利用这些数据结构的特点,根据实际需求选择最合适的数据结构,以便编写出更高效、更可维护的代码。