一文搞懂数组:从内存原理到遍历技巧(JavaScript 数据结构基础)

42 阅读5分钟

本文面向正在学习 JavaScript 的同学,从“数组到底是什么”出发,深入理解数组在内存中的本质、创建方式、动态扩容机制、遍历性能差异,以及它与对象、链表的区别。阅读后,你不仅能熟练使用数组,还能理解其设计背后的逻辑。


一、数据结构的分类

在计算机中,数据结构大致分为两类:线性结构非线性结构

1. 线性数据结构

元素之间是一对一的线性关系,常见类型包括:

  • 数组(Array) :最常用、存储连续、访问高效。
  • 栈(Stack) :先进后出(FILO)。
  • 队列(Queue) :先进先出(FIFO)。
  • 链表(Linked List) :节点离散,通过指针相连。

2. 非线性数据结构

元素之间是一对多或多对多的关系,如:

  • 树(Tree) :如二叉树、平衡树。
  • 图(Graph) :节点连接关系更复杂。

本文将重点讲解最常见的线性结构——数组。


二、数组的本质:连续内存与索引访问

从底层角度看,数组是一段连续的内存空间,每个元素占据固定大小的存储单元,通过下标偏移即可直接访问。

const arr = [1, 2, 3, 4, 5, 6];

执行这行代码后:

  • 数组元素 [1, 2, 3, 4, 5, 6] 存放在堆内存中;
  • 栈内存中保存着一个引用地址 arr,指向堆内的实际数据。

因此数组是引用类型,复制时传递的是地址,而非值。


三、数组的创建方式

1. 字面量创建(最常见)

const arr = [1, 2, 3, 4, 5, 6];

这种方式最直观、最常用,推荐在绝大多数情况下使用。


2. 构造函数创建

const arr1 = new Array(6); // 创建一个长度为 6 的空数组
const arr2 = new Array(6).fill(0); // 创建并用 0 填充

new Array(6) 仅仅是分配了一个长度为 6 的空槽位,并没有真正初始化值;而 fill(0) 则会为每个位置赋初始值。


3. 空数组 + 动态扩容

const arr = [];
arr.push(1);
arr.push(2);

JavaScript 的数组可以动态扩容。当空间不足时,底层会申请一块更大的连续内存,并将原数组内容复制过去。这一过程被称为“搬家”。

动态扩容带来了灵活性,但频繁扩容会造成额外的性能开销。因此:

  • 若数据量较小、随机访问频繁,数组是高效的选择;
  • 若频繁插入或删除元素,链表更合适。

四、数组的访问与时间复杂度

数组支持通过下标直接访问任意元素:

arr[index]; // O(1)

由于数组内存空间是连续的,CPU 可以直接通过基址加偏移量定位到具体元素,访问效率极高。这是数组的核心优势。


五、数组的遍历方式与性能对比

遍历是数组的基础操作,不同遍历方式在语义、可读性与性能上各有优劣。


1. 计数循环(for)

const arr = new Array(6).fill(0);
const len = arr.length;

for (let i = 0; i < len; i++) {
    console.log(arr[i]);
}

特点:

  • 性能最佳;
  • 没有函数调用栈的开销;
  • CPU 对这种循环结构高度优化。

一般在性能要求高的场景(如算法题、大量数据计算)中使用。


2. forEach

const arr = [1, 2, 3, 4, 5, 6];
arr.forEach((item, index) => {
    console.log(item, index);
});

forEach 语义清晰,可读性强,但有几点需要注意:

  • 无法使用 breakreturn 中断循环;
  • 每次调用函数会产生额外的执行开销;
  • 性能略低于传统 for

适用于逻辑简单、以可读性为主的业务场景。


3. map(加工生成新数组)

const arr = [1, 2, 3, 4, 5, 6];
const newArr = arr.map(item => item + 1);
console.log(newArr);

区别在于:

  • forEach 仅执行函数,不返回结果;
  • map 会返回一个经过加工的新数组。

适用于需要对数组进行转换、生成新数组的场景。例如将一组数字全部加一或从对象中提取指定属性。


4. for...of(ES6 推荐写法)

const arr = [1, 2, 3, 4, 5, 6];
for (let item of arr) {
    console.log(item);
}

for...of 是 ES6 提供的语法糖,内部基于迭代器实现,语义上表示“遍历可迭代对象的值”。

优点:

  • 语法简洁;
  • 可读性强;
  • 可以在异步循环(如 async/await)中配合使用。

缺点:

  • 无法直接获取下标(可用 entries() 方法间接实现)。

5. for...in(不推荐用于数组)

const arr = [1, 2, 3, 4, 5, 6];
for (let key in arr) {
    console.log(key, arr[key]);
}

for...in 是为对象属性设计的。虽然数组也是对象,但使用 for...in 遍历数组会产生以下问题:

  • 遍历顺序不一定与索引顺序一致;
  • 可能遍历出原型链上的属性;
  • 性能较差。

因此,for...in 只适用于普通对象,不推荐用于数组。


六、作用域与闭包:循环中的陷阱

一个常见的例子:

for (let i = 0; i < 10; i++) {
    setTimeout(function() {
        console.log(i);
    }, 1000);
}

输出结果为 09
因为 let 声明的变量具有块级作用域,每次循环都会生成独立的词法环境。

若改为 var,则输出全是 10,因为 var 没有块级作用域,所有回调共享同一个 i


七、总结

  1. 数组的本质是连续的内存空间,通过下标实现 O(1) 访问。

  2. 动态扩容带来灵活性,但会有复制开销。

  3. 不同遍历方式各有优缺点:

    • for:性能最优;
    • forEach:语义清晰;
    • map:适合生成新数组;
    • for...of:语法现代;
    • for...in:仅适合对象。
  4. 作用域与闭包是遍历异步时常见的陷阱,应注意 varlet 的区别。

掌握这些内容后,你就能从原理层面理解数组的运行方式,不再只是“会用”,而是“知其所以然”。这也是理解更复杂数据结构(如链表、树、哈希表)的基础。