一、为什么几乎所有算法都离不开数组
在常见的数据结构里,有两大阵营:
-
线性结构
- 数组:连续内存,支持下标随机访问
- 栈:先进后出(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]);
}
-
优点:
- 能拿到下标
- 控制最灵活(可以
break、continue)
-
缺点:
- 可读性一般:
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:遍历方式对比
- 要求:分别用
for、forEach、map、for...of实现同一个逻辑 - 思考:哪一种可读性最好?哪一种最适合当前需求?
- 要求:分别用
-
练习 3:对象与数组遍历区分
- 要求:写一段代码,遍历一个对象和一个数组,体会
for...in与for...of的差别
- 要求:写一段代码,遍历一个对象和一个数组,体会
七、总结:理解数组,比记 API 更重要
本文从几个角度,带你从浅到深看数组:
-
从数据结构视角:数组是线性结构中“开箱即用”的高频选手
-
从内存视角:栈里放引用,堆里放连续的真实数据,动态扩容有成本
-
从代码实践:
- 如何在「已知内容」与「只知道长度」的场景下创建数组
- 如何选对遍历方式:
for/forEach/map/for...of/for...in各自的适用点 - 以及遍历结合异步时容易踩的坑
如果你现在不再本能地只写出那句:
for (let i = 0; i < arr.length; i++) {}
而是会先想一想「我到底要干什么」——是只做副作用、还是要生成新数组、是遍历对象还是遍历数组,那么你对数组的理解就已经比大多数人更“深入一层”了。