Skip to content

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中最基础的有序集合,它具有以下特点:

  • 有序:元素按插入顺序排列,有明确的索引
  • 可重复:可以存储重复的值
  • 动态大小:可以随时添加或删除元素
  • 索引访问:可以通过数字索引直接访问元素
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等方法
javascript
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()查找元素的索引索引或-1arr.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()为每个元素执行函数undefinedarr.forEach(x => console.log(x))

Set主要API详解

API描述返回值示例
add()添加一个新元素Set对象本身set.add('apple')
delete()删除一个元素布尔值,成功为trueset.delete('apple')
has()是否存在某元素布尔值set.has('apple')
clear()清空所有元素undefinedset.clear()
forEach()对每个元素执行函数undefinedset.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适用场景
      需要保证元素唯一性
      频繁检查元素是否存在
      需要快速去重
      需要高效的交集、并集、差集操作
      不需要通过索引访问
      只关心值是否存在,不需要存储额外数据
      需要避免重复添加相同数据

性能对比

操作ArraySet
查找元素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快,特别是数据量较大时:

javascript
// 示例:比较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等
  • 直接量语法:可以使用{}直接创建
javascript
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等专用方法
javascript
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.propobj['prop']
属性赋值通过.或[]设置属性赋值obj.prop = value
删除属性使用delete操作符删除属性布尔值delete obj.prop

Map主要API详解

API描述返回值示例
set()设置键值对Map对象本身map.set(key, value)
get()获取指定键的值值或undefinedmap.get(key)
has()检查键是否存在布尔值map.has(key)
delete()删除指定键的键值对布尔值,成功为truemap.delete(key)
clear()清空所有键值对undefinedmap.clear()
size获取键值对数量数值map.size
forEach()对每个键值对执行函数undefinedmap.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适用场景
      键可能是非字符串
      需要频繁增删键值对
      需要维护键值对顺序
      需要高效遍历所有键值
      需要获取键值对数量
      需要以对象作为键
      需要保证键不会被意外覆盖
      对性能有较高要求的场景

性能对比

操作ObjectMap
查找键7085
添加键值8085
删除键值6585
迭代6090
获取大小50100

*注:数值表示相对性能,越高越好(满分100)

在实际的性能测试中,Map在频繁添加和删除键值对时通常比Object更高效,特别是处理大量数据时:

javascript
// 示例:比较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实现原理

  • 使用哈希表加链表的复合结构
  • 键可以是任意类型,引用类型键通过内存地址区分
  • 内部维护插入顺序,使用链表实现
  • 为了支持高效迭代,维护了额外的数据结构

综合对比表

特性ArraySetObjectMap
存储内容值的有序列表唯一值的集合字符串/Symbol键的值任意类型键的值
元素顺序有序有序(插入顺序)无序(ES2015前)
基本有序(ES2015后)
有序(插入顺序)
键的类型数字索引N/A字符串或Symbol任意值
重复元素允许自动去重键不可重复键不可重复
访问元素arr[0]迭代或转数组obj.key
obj['key']
map.get(key)
判断大小array.lengthset.size需手动计算
Object.keys(obj).length
map.size
添加元素push()
unshift()
add()obj[key] = valueset(key, value)
删除元素splice()
pop()
delete()delete obj[key]delete(key)
查找元素indexOf()
includes()
has()key in obj
obj.hasOwnProperty()
has(key)
迭代性能优秀优秀一般优秀
内存占用中等较高较低较高
序列化原生支持JSON需转为数组原生支持JSON需转为数组或对象
适用场景有序数据
需要索引访问
需要唯一值
频繁查重
简单键值对
JSON兼容
复杂键值对
频繁增删

实际应用示例

  1. 数组去重
javascript
// 使用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倍
  1. 频繁查找
javascript
// 使用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");  // 使用对象作为键
  1. 集合操作
javascript
// 使用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]
  1. 使用Map处理复杂键
javascript
// 使用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开发中,我认为对这四种数据结构的选择应该基于以下几个原则:

  1. 数据的唯一性需求:需要保证唯一性时首选Set
  2. 键值对的键类型:非字符串键首选Map
  3. 操作频率:频繁查找优先使用Set/Map,频繁整体操作选择Array/Object
  4. 序列化需求:需要JSON序列化时使用Array/Object
  5. 顺序重要性:顺序重要且需要频繁重排时使用Array,键值对顺序重要时使用Map
  6. 内存和性能平衡:大数据量时,谨慎使用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开发中,应该充分利用这些数据结构的特点,根据实际需求选择最合适的数据结构,以便编写出更高效、更可维护的代码。