吃透 JS 数组:从底层内存到遍历实战,新手也能懂的核心逻辑

54 阅读10分钟

在前端开发中,有个数据结构我们天天用却未必真的懂 —— 数组。它既是 “开箱即用” 的工具人,又是面试高频考点,从底层内存布局到遍历性能差异,藏着太多影响代码效率的关键逻辑。今天就从底层原理到实战用法,把数组的核心知识点扒得明明白白,不管是新手入门还是老手查漏补缺,都能有所收获。

一、数据结构入门:为什么数组是 “性价比之王”

先搞清楚一个基础问题:数据结构那么多,为什么数组能成为前端开发的 “高频选手”?

数据结构主要分两类:线性结构和非线性结构。线性结构像排队一样有序排列,非线性结构则是更复杂的层级关系。

  • 线性数据结构:数组、栈(先进后出)、队列(先进先出)、链表(离散存储)
  • 非线性数据结构:树(二叉树为主)、图等

在这些结构里,数组之所以脱颖而出,核心原因是平衡了易用性和性能。它不像栈、队列那样有严格的操作限制,也不像链表那样需要处理复杂的指针关系,更不像树结构那样有较高的学习成本。

对前端场景来说,我们大多时候需要的是 “简单存储 + 快速访问 + 便捷遍历” 的功能,数组恰好完美适配这些需求。但要真正用好数组,关键得先搞懂它的底层逻辑 —— 这也是很多人用了几年数组,却依然写不出高效代码的根源。

二、数组底层揭秘:连续内存 + 引用机制,决定了它的 “脾性”

要理解数组的行为,首先要搞懂它在内存中的存储方式 —— 这是所有特性的 “根”。

1. 内存布局:栈存引用,堆存数据

当你写下 const arr = [1,2,3,4,5,6] 时,内存里发生了两件事:

  • 栈内存中会存储一个 “引用地址”,就像一张写着仓库位置的纸条,变量 arr 其实指向的是这个地址。
  • 堆内存中会开辟一块连续的内存空间,专门存储 1、2、3 这些数据,每个数据占一个固定大小的 “格子”,依次排列。

这种 “连续存储” 的设计,直接决定了数组最核心的优势:随机访问速度极快。访问 arr[2] 时,计算机不用从头遍历查找,而是通过公式 “初始地址 + 索引 × 数据大小”,直接定位到目标数据,时间复杂度只有 O (1)。

打个比方,数组就像停车场里的连续车位,每个车位编号(索引)从 0 开始,要找第 3 个车位(索引 2),直接按编号走就行;而链表则像分散在城市各处的独立车位,要找某个车位,必须顺着前一个车位的指引一步步找,效率天差地别。

2. 动态扩容:JS 数组的 “隐藏操作”

很多人以为数组的长度是固定的,但 JS 数组其实支持动态扩容 —— 这是它和其他语言(比如 Java)数组的重要区别。

当你创建数组时,JS 会根据初始值或长度,在堆内存中申请一块连续空间。但如果后续添加的元素超过了当前空间容量,JS 会自动执行 “扩容操作”:

  1. 在堆内存中申请一块更大的连续空间(通常是原容量的 1.5 倍或 2 倍);
  2. 把原数组的所有数据 “搬家” 到新空间;
  3. 释放原空间的内存;
  4. 把新元素添加到新空间末尾。

这个过程看似无感,但其实有性能开销 —— 数据量越大,搬家的成本越高。这也是为什么数组在 “频繁增删尾部以外的元素” 场景下,效率不如链表;但如果是少量数据、以访问和遍历为主,数组的连续存储优势会完全覆盖扩容的开销。

三、数组创建:3 种方式 + 避坑指南,新手必看

创建数组看似简单,但不同方式的底层逻辑和适用场景差异很大,用错了容易踩坑。

1. 直接赋值创建:已知元素时首选

javascript

运行

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

这是最常用的方式,适合已知数组元素的场景。底层逻辑是直接在堆内存中开辟对应大小的连续空间,把元素依次存入,没有多余开销,效率最高。

2. 构造函数创建:空数组或指定长度

javascript

运行

const arr1 = new Array(); // 创建空数组,等价于 const arr1 = []
const arr2 = new Array(6); // 创建长度为6的空数组(empty*6)

这里有个关键坑:new Array(6) 创建的是 “长度为 6,但没有实际元素” 的数组,而非包含 6 个 undefined 的数组。你可以通过 arr2.length 拿到长度 6,但遍历它时(比如用 forEach),会跳过这些空元素。

3. fill 方法初始化:指定长度 + 默认值

如果想创建 “长度确定、元素值相同” 的数组,fill 方法是最佳选择:

javascript

运行

const arr = (new Array(6)).fill(0); // [0,0,0,0,0,0]

fill 方法会把数组的每个位置都填充为指定值,避免了 “empty 元素” 的坑。但要注意:如果填充的是引用类型(比如对象、数组),所有位置会指向同一个引用,修改一个会影响所有,这是后续需要注意的点。

创建方式对比总结

  • 已知元素:直接用 [1,2,3],高效无坑;
  • 未知元素但知道长度:用 new Array(length).fill(defaultValue),避免 empty 元素;
  • 空数组:[] 比 new Array() 更简洁,可读性更强。

四、遍历方法大 PK:性能 + 场景全覆盖,再也不用瞎选

遍历是数组最核心的操作之一,但 forforEachmapfor...offor...in 到底该怎么选?关键看底层逻辑和性能表现。

1. 计数循环(for (let i=0; i<len; i++)):性能之王

javascript

运行

const arr = [1,2,3,4,5,6];
const len = arr.length; // 提前缓存长度,减少属性访问开销
for(let i = 0; i < len; i++){
    console.log(arr[i]);
}

这是性能最好的遍历方式,没有之一。底层原因是:

  • 逻辑最简单,和 CPU 的工作机制高度契合,没有多余的函数调用或类型转换;
  • 提前缓存 arr.length,避免每次循环都访问数组的 length 属性(属性访问有额外开销);
  • 支持 break 和 continue,可以灵活控制循环流程。

唯一的缺点是可读性稍差,尤其是嵌套循环时,ij 容易混淆。

2. forEach:简洁但有局限

javascript

运行

arr.forEach((item, index) => {
    console.log(item, index);
    // 无法用break终止循环,return也只能跳过当前迭代
});

forEach 的优势是语法简洁,不用手动管理索引,但底层逻辑决定了它的局限性:

  • 本质是数组的方法,遍历过程中会调用回调函数,函数的入栈和出栈会带来性能开销;
  • 不支持 break 和 continue,即使遇到错误也只能硬着头皮遍历到底;
  • 遍历 empty 元素时会跳过,和计数循环的行为不一致。

适合场景:不需要中断循环、简单遍历元素的场景,追求代码简洁性。

3. map:遍历 + 加工,返回新数组

javascript

运行

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

map 的核心作用不是遍历,而是 “对数组元素进行加工,返回新数组”,底层逻辑和 forEach 类似,但有两个关键区别:

  • 回调函数必须返回一个值,否则新数组对应位置会是 undefined;
  • 会创建一个和原数组长度相同的新数组,不改变原数组(纯函数特性)。

适合场景:需要对数组元素进行统一加工(比如格式化数据、转换类型)的场景,不要用 map 单纯遍历(浪费内存创建新数组)。

4. for...of:ES6 新特性,平衡可读性和灵活性

javascript

运行

for(let item of arr){
    console.log(item);
    if(item === 3) break; // 支持break
}

for...of 是 ES6 专门为 “可迭代对象”(数组、字符串、Map 等)设计的遍历方式,底层基于迭代器(Iterator),优势很明显:

  • 语法简洁,可读性比计数循环好,不用手动管理索引;
  • 支持 breakcontinue 和 return,控制流程灵活;
  • 遍历的是数组元素本身,不用通过索引访问;
  • 性能介于计数循环和 forEach 之间,日常开发中性价比最高。

适合场景:大多数日常遍历场景,既想简洁又需要灵活控制循环。

5. for...in:遍历对象专用,数组慎用

javascript

运行

// 数组本质是对象,索引是对象的属性(字符串类型)
for(let key in arr){
    console.log(key, arr[key]); // key"0""1""2"...字符串
}

for...in 的设计初衷是遍历对象的属性,而非数组,用它遍历数组会有明显问题:

  • 遍历的是数组的所有可枚举属性,包括原型链上的属性(比如给 Array.prototype 添加了一个方法,也会被遍历到);
  • 索引是字符串类型,进行数值运算时需要手动转换;
  • 遍历顺序不固定,可能不是按数组索引的顺序遍历;
  • 性能最差,比 forEach 还慢。

总结:除非需要遍历数组的额外属性,否则坚决不用 for...in 遍历数组。

遍历方法性能 & 场景对比表

方法性能级别支持 break适用场景
计数循环🌟🌟🌟🌟🌟大数据量、性能敏感场景
for...of🌟🌟🌟🌟日常遍历、追求简洁灵活
forEach🌟🌟🌟简单遍历、不需要中断循环
map🌟🌟🌟元素加工、返回新数组
for...in🌟🌟遍历对象属性(数组慎用)

五、致命坑点:setTimeout 里的数组遍历陷阱

很多人都遇到过这个问题:用循环创建多个 setTimeout,期望输出 0-9,结果却输出 10 个 10。

javascript

运行

// 反面例子:用var声明i
for(var i = 0; i < 10; i++){
    setTimeout(function(){
        console.log(i); // 输出10个10
    }, 1000);
}

// 正面例子:用let声明i
for(let i = 0; i < 10; i++){
    setTimeout(function(){
        console.log(i); // 输出0-9
    }, 1000);
}

这个坑的核心是作用域和闭包的底层逻辑:

  • var 声明的 i 是函数作用域,整个循环共用一个 i 变量。setTimeout 是异步执行,1 秒后回调函数执行时,循环已经结束,i 的值已经变成 10;
  • let 声明的 i 是块级作用域,每次循环都会创建一个新的 i 变量,回调函数捕获的是当前循环的 i,所以会正确输出 0-9。

这个例子也侧面说明:理解底层逻辑比死记语法更重要,同样的代码结构,只是变量声明方式不同,结果就天差地别。

六、总结:数组的正确打开方式

数组作为前端最常用的数据结构,核心优势是 “连续内存带来的快速访问” 和 “丰富的 API 带来的易用性”,但要用好它,关键记住 3 点:

  1. 选对创建方式:已知元素用[],已知长度用fill,避免 empty 元素;
  2. 选对遍历方法:性能优先用计数循环,日常遍历用 for...of,加工元素用 map,拒绝用 for...in 遍历数组;
  3. 关注底层细节:动态扩容有开销,频繁增删中间元素时可考虑链表;闭包和作用域容易踩坑,var 和 let 的区别要记牢。

其实数组的底层逻辑并不复杂,关键是把 “连续内存”“作用域”“异步执行” 这些知识点串联起来,理解每个 API 背后的原理,而不是死记硬背用法。只有这样,遇到问题时才能快速定位根源,写出高效、稳健的代码。