前言:数组是 JS 里最常用的数据结构,但它的连续内存布局、增删方法的副作用、纯函数遍历族、以及二维数组的 fill 陷阱,你真的吃透了吗?本文从 ADT 概念出发,逐层拆解到二维矩阵。
目录
- [数组的底层:连续内存与 ADT](#数组的底层:连续内存与 ADT "#%E4%B8%80%E6%95%B0%E7%BB%84%E7%9A%84%E5%BA%95%E5%B1%82%E8%BF%9E%E7%BB%AD%E5%86%85%E5%AD%98%E4%B8%8E-adt")
- [增删四兄弟:push / pop / shift / unshift](#增删四兄弟:push / pop / shift / unshift "#%E4%BA%8C%E5%A2%9E%E5%88%A0%E5%9B%9B%E5%85%84%E5%BC%9Fpush--pop--shift--unshift")
- 纯函数与非纯函数
- [遍历六法:从 for 到 reduce](#遍历六法:从 for 到 reduce "#%E5%9B%9B%E9%81%8D%E5%8E%86%E5%85%AD%E6%B3%95%E4%BB%8E-for-%E5%88%B0-reduce")
- [二维数组与 fill 陷阱](#二维数组与 fill 陷阱 "#%E4%BA%94%E4%BA%8C%E7%BB%B4%E6%95%B0%E7%BB%84%E4%B8%8E-fill-%E9%99%B7%E9%98%B1")
- 总结
一、数组的底层:连续内存与 ADT
数组是几乎所有编程语言都内置的数据结构。在 JavaScript 里,数组尤其灵活:不要求每一项类型一致,也不需要预先声明长度。
javascript
const arr = ['a', 'b', 'c'];
但灵活不等于没有原理。数组在内存中的本质是一段连续的存储空间 ------每个元素紧挨着下一个,通过"起始地址 + 偏移量"一步算出任何元素的物理位置。这是它能够 O(1) 随机访问的根本原因。
ADT:数据结构 = 存储 + 操作
数据结构课里有一个关键概念叫 ADT(Abstract Data Type,抽象数据类型) ------任何数据结构都包含两部分:存储方式 + 特定操作 。数组的存储方式是连续内存,而它的特定操作就是那四个耳熟能详的方法:push、pop、shift、unshift。
Array 的原型链
在 JS 的"一切皆对象"体系下,Array 本身也是一个对象。创建一个数组时发生了什么?
javascript
const arr = new Array(); // 等价于 []
console.log(typeof Array); // "function"
console.log(Array.prototype); // 数组的所有方法所在地
console.log(Array.prototype.__proto__.constructor); // Object
console.log(Array.prototype.__proto__.__proto__); // null
原型链路径:实例 arr → Array.prototype → Object.prototype → null。所有数组方法(push、map、forEach......)都挂在 Array.prototype 上,实例通过 __proto__ 找到它们。
二、增删四兄弟:push / pop / shift / unshift
数组的四个基础操作恰好对应队尾 和队头的增删:
perl
unshift ← 队头 → shift
┌────┬────┬────┬────┐
│ 3 │ a │ b │ c │
└────┴────┴────┴────┘
push → 队尾 ← pop
javascript
const arr = ['a', 'b', 'c'];
arr.push(1); // 队尾入 → ['a','b','c',1],返回新长度 4
arr.push(2); // 队尾入 → ['a','b','c',1,2],返回新长度 5
arr.unshift(3); // 队头入 → [3,'a','b','c',1,2],返回新长度 6
arr.pop(); // 队尾出 → 返回 2,arr 变为 [3,'a','b','c',1]
arr.shift(); // 队头出 → 返回 3,arr 变为 ['a','b','c',1]
| 方法 | 位置 | 方向 | 返回值 | 副作用 |
|---|---|---|---|---|
push(x) |
队尾 | 入 | 新长度 | 修改原数组 |
pop() |
队尾 | 出 | 移除的值 | 修改原数组 |
unshift(x) |
队头 | 入 | 新长度 | 修改原数组 |
shift() |
队头 | 出 | 移除的值 | 修改原数组 |
注意
push/unshift返回的是新 length ,而pop/shift返回的是被移除的元素本身。这是新手最容易搞混的细节。
这四个方法共同的缺点是:会直接修改原数组。很多场景下我们不想破坏原始数据,这就引出了纯函数的概念。
三、纯函数与非纯函数
看一段对比代码:
javascript
let num = 0;
function add(b) {
num += b; // 修改了外部的 num
return num;
}
add(3); // num = 3
add(3); // num = 6 ------ 同样的输入,不同的输出!
add(3) 调用两次,第一次返回 3,第二次返回 6------相同的输入产生了不同的输出。因为它依赖并修改了外部变量 num ,这就是非纯函数。
纯函数的定义很简单:同样的输入永远产生同样的输出,且没有副作用(不修改外部状态)。
| 纯函数 | 非纯函数 | |
|---|---|---|
| 同样输入 → 同样输出 | ✅ | ❌(受外部状态影响) |
| 修改原数据 | ❌ 不改 | ✅ 会改 |
| 代表方法 | map / filter / reduce |
push / pop / shift / unshift |
| 适用场景 | 数据处理流水线 | 队列、栈等有状态场景 |
回到数组:push 和 pop 天然是非纯函数------它们修改了原数组。而在数据加工场景(如格式化 API 返回结果、筛选列表),我们应该倾向纯函数。
四、遍历六法:从 for 到 reduce
数组的遍历方法构成了一个从"机器化"到"语义化"的光谱:
创建一个初始数组
javascript
const arr = (new Array(7)).fill(1); // 长度为 7,每个元素都为 1
console.log(arr); // [1, 1, 1, 1, 1, 1, 1]
new Array(7) 创建一个长度为 7 的空数组------7 个位置都处于 empty 状态,未被任何值占据。.fill(1) 把它们全部初始化为 1。
遍历六法对比
以一个数值数组为例:
javascript
const arr = [6, 8, 12, 15];
① for 计数循环
javascript
for (let i = 0; i < arr.length; i++) {
console.log(arr[i]);
}
命令式写法,可读性一般,但性能最好------没有函数调用开销。
② forEach
javascript
arr.forEach((item, index, self) => {
console.log(item, index, self);
});
语义清晰,功能强大(回调拿到 item / index / 原数组),但不能中途 break。
③ map ------ 映射转换
javascript
const doubled = arr.map(item => item * 2);
console.log(doubled); // [12, 16, 24, 30]
console.log(arr); // [6, 8, 12, 15] ← 原数组不变
纯函数,返回一个新数组,每个元素是回调的返回值。
④ filter ------ 条件筛选
javascript
const evens = arr.filter(item => item % 2 === 0);
console.log(evens); // [6, 8, 12]
纯函数 ,回调返回 true 的元素留下,组成新数组。
⑤ every / some ------ 布尔判断
javascript
arr.every(item => item % 2 === 0); // false(15 不是偶数)
arr.some(item => item % 2 === 0); // true(至少有一个偶数)
every:全真则真。some:有一真即真。
⑥ reduce ------ 归并汇总
javascript
const sum = arr.reduce((prev, item, index) => {
// prev: 上次回调的返回值
// item: 当前元素
return prev + item;
}, 0); // 0 是初始值
console.log(sum); // 41
reduce 是最强大的遍历方法------求和、拼字符串、转对象、连 Promise 链,都能用它写。
怎么选?
| 场景 | 推荐方法 |
|---|---|
| 追求极限性能 | for 计数循环 |
| 每个元素执行操作、不需 break | forEach |
| 把 A 数组转换为 B 数组 | map |
| 按条件筛选子集 | filter |
| 判断是否全满足/存在满足 | every / some |
| 汇总成一个值(求和、拼接等) | reduce |
五、二维数组与 fill 陷阱
二维数组常见于矩阵运算------而 LLM 底层的向量计算正是大规模的矩阵运算。
fill(\[\]) 的经典坑
直觉上,你可能会这样创建一个 7 行的二维数组:
javascript
const arr = (new Array(7)).fill([]);
arr[0][0] = 1;
console.log(arr);
// 预期:[[1], [], [], [], [], [], []]
// 实际:[[1], [1], [1], [1], [1], [1], [1]]
所有行都被改了! 原因出在 fill 的机制:fill([]) 只创建了一个 数组对象,然后把它的引用 填入所有 7 个位置。arr[0]、arr[1]......全部指向同一个数组。
正确做法
javascript
const arr = new Array(7);
const len = arr.length;
for (let i = 0; i < len; i++) {
arr[i] = []; // 每一行都是独立的新数组
}
arr[0][0] = 1;
console.log(arr); // [[1], [], [], [], [], [], []] ← 正确!
嵌套遍历的性能细节
javascript
const outerLen = arr.length;
for (let i = 0; i < outerLen; i++) {
const innerLen = arr[i].length;
for (let j = 0; j < innerLen; j++) {
console.log(arr[i][j], i, j);
}
}
两个小优化:
- 把
arr.length缓存到局部变量outerLen------避免每次循环访问对象属性。 - 内层循环同样缓存
arr[i].length------原理相同。
引用类型与基本类型的核心区别在这里体现得淋漓尽致:基本类型
fill(1)是值拷贝,互不影响;引用类型fill([])是地址拷贝,牵一发动全身。
六、总结
- 数组底层 :连续内存 + ADT(存储 + 操作),原型链挂载在
Array.prototype。 - 增删四兄弟 :
push/pop/shift/unshift,需注意返回值(新长度 vs 被移除值),均修改原数组。 - 纯函数族 :
map/filter/every/some/reduce不修改原数组,适合数据处理流水线。 - 遍历抉择 :性能优先用
for,语义优先用forEach,转换用map,筛选用filter,汇总用reduce。 - 二维数组 :
fill([])是引用陷阱,必须用循环逐行创建独立数组。
一条核心直觉:数组在 JS 里既是"连续的",又是"对象的"------理解它的内存布局决定了你的代码写不写得对,理解它的方法族决定了你的代码写不写得好。
------ 数组虽基础,吃透不容易。