别再只会 for 循环了:一文吃透 JS 数组的创建与遍历套路

66 阅读6分钟

一、为什么几乎所有算法都离不开数组

在常见的数据结构里,有两大阵营:

  • 线性结构

    • 数组:连续内存,支持下标随机访问
    • :先进后出(FILO)
    • 队列:先进先出(FIFO)
    • 链表:节点离散,用指针串起来
  • 非线性结构

    • :如二叉树等,更适合表示层级结构

在这些里面,数组几乎是你写 JS 时最常用、最好上手的结构,可以说是「开箱即用」的数据结构。
但要真正写好代码,不仅要会用数组,还要理解它在内存中的样子、如何高效创建和遍历。

二、从内存看数组:栈里的“地址”,堆里的“真身”

在 JS 里,数组是引用类型,可以简单理解为:

  • 栈内存

    • 存的是一个引用地址,指向真正的数据
  • 堆内存

    • 存的是 [1, 2, 3, 4, 5, 6] 这样的真实内容
    • 在底层会申请一段连续的内存空间

这种连续内存带来两个直接好处:

  • 随机访问快arr[index] 时间复杂度是 O(1)
  • 缓存友好:CPU 读取连续内存更高效

但也有代价:
当你要动态扩容时,如果原位置放不下,就可能需要:

  • 重新申请一块更大的连续空间
  • 把旧数组里的元素「搬家」过去

这就是所谓的动态扩容成本。
少量数据下,数组非常优秀;数据量大且频繁插入删除中间位置时,链表会更有优势。

三、数组怎么“长”出来:从字面量到动态初始化

1. 直接写出来:最直观的方式

  • 适用场景:你已经知道每一项是什么
const arr = [1, 2, 3, 4, 5, 6];
  • 优点:语义清晰、可读性最好
  • 缺点:不适合先有长度,后填内容的场景

2. 只知道长度,不知道内容:构造函数 + 填充

  • 先申请一块固定长度的空间
const arr = new Array(6);
console.log(arr); // [empty × 6]

此时数组有长度,但元素是“空槽位”,很多遍历方法会跳过它们。

  • 再用统一的初始值填充
const arr = (new Array(6)).fill(0);
console.log(arr); // [0, 0, 0, 0, 0, 0]
  • 优点

    • 一次性申请指定长度的空间
    • 用同一个初始值填充,后续再逐步修改
  • 思考点

    • 要多了浪费:申请 1000 个位置只用 10 个,会浪费空间
    • 要少了不够用:后面继续往里塞,就可能触发扩容和“搬家”,有一定开销

这也是为什么在考虑「动态性」时,数组在某些场景下会比链表差。

四、数组遍历的“进化史”:从计数循环到语义化遍历

数组最常见的操作,就是把每一项都走一遍。这一块你越熟练,写的代码越自然。

1. 传统计数循环:从 0 数到 length - 1

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

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

    • 能拿到下标
    • 控制最灵活(可以 breakcontinue
  • 缺点

    • 可读性一般:i = 0; i < len; i++ 很“机械”
    • 容易写错边界条件

2. forEach:专注「遍历副作用」

当你只是想对每一项做点事,不需要返回新数组时:

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

arr.forEach((item, index) => {
  console.log(item, index);
});
  • 特点

    • 回调里直接拿到 item 和 index
    • 更像是在表达:对数组的每一项执行一次这个函数
  • 注意

    • 不返回新数组,主要用于打印、累加、更新外部变量等副作用
    • 不能通过 return 终止整个遍历(想中途停下来,用普通 for 更合适)

3. map:遍历 + 加工 = 新数组

如果你想在遍历的同时生成一个新的数组,就该用 map 了:

const arr = [1, 2, 3, 4, 5, 6];
const newArr = arr.map(item => item + 1);
// newArr: [2, 3, 4, 5, 6, 7]
  • 核心语义

    • 「这个数组映射成另一个数组」
  • 适用场景

    • 格式转换、批量计算、生成视图数据等

和 forEach 对比:

  • forEach:我只是走一圈,顺便做点事
  • map:我要在走一圈的同时,构造一个新数组

4. for...of:更自然地拿「值」

当你只关心每一项的值,不关心下标时:

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

    • 语义简单清晰:of → “从这个集合中拿出每一项”
    • 可读性好,适合大多数「只用值」的场景

5. for...in:本来是给对象设计的

对普通对象来说:

const obj = {
  name: '黄',
  age: '18',
  hobbies: ['篮球', '足球']
};

for (let key in obj) {
  console.log(key, obj[key]);
}
  • 语义:遍历的是「可枚举属性的键」

用在数组上时:

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

for (let key in arr) {
  console.log(key, arr[key]); // key 是下标(字符串形式)
}
  • 特点

    • key 是下标,但类型是字符串
    • 会枚举到自定义属性,不适合纯粹的数组遍历

🚀实践建议

  • 对象 → 用 for...in

  • 数组 → 用 for / forEach / map / for...of

五、循环 + 异步的坑:顺便提一下 var 的经典问题

在讲数组遍历时,经常会结合定时器、异步一起举例,这里顺带提一个经典坑:

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

一年后再看,很多人还是会被这个输出“整懵”。

在 JavaScript 引擎眼中实际上是这样的顺序:

var i; 
for (i = 0; i < 10; i++) {}
  • 关键点

    • var 没有块级作用域 (共享一个 i )
    • 循环结束后,i 已经变成最终值
    • 定时器回调拿到的是同一个 i

let 关键字支持块级作用域。在 for 循环中使用 let 声明变量时,每次迭代都会创建一个新的、独立的变量实例

这和数组本身无关,但和「如何正确遍历并处理每一项」关系紧密。
实战里你在遍历数组时,只要碰上异步,就必须考虑「当前项」在回调里是不是还对得上。

六、从“会用”到“用好”:数组学习的几个小练习

可以基于上面的内容,做几组练习来加深理解:

  • 练习 1:创建与初始化

    • 要求:创建一个固定长度的数组,并用某个默认值填充
    • 升级:尝试用不同方法填充不同的初始结构(如对象、嵌套数组)
  • 练习 2:遍历方式对比

    • 要求:分别用 forforEachmapfor...of 实现同一个逻辑
    • 思考:哪一种可读性最好?哪一种最适合当前需求?
  • 练习 3:对象与数组遍历区分

    • 要求:写一段代码,遍历一个对象和一个数组,体会 for...in 与 for...of 的差别

七、总结:理解数组,比记 API 更重要

本文从几个角度,带你从浅到深看数组:

  • 从数据结构视角:数组是线性结构中“开箱即用”的高频选手

  • 从内存视角:栈里放引用,堆里放连续的真实数据,动态扩容有成本

  • 从代码实践

    • 如何在「已知内容」与「只知道长度」的场景下创建数组
    • 如何选对遍历方式:for / forEach / map / for...of / for...in 各自的适用点
    • 以及遍历结合异步时容易踩的坑

如果你现在不再本能地只写出那句:

for (let i = 0; i < arr.length; i++) {}

而是会先想一想「我到底要干什么」——是只做副作用、还是要生成新数组、是遍历对象还是遍历数组,那么你对数组的理解就已经比大多数人更“深入一层”了。