JavaScript 数组深度解析:从纯函数到二维数组陷阱,一文吃透前端数据结构核心
🚀 数组是前端开发中最常用的数据结构,但你真的了解它吗? 本文从内存模型、纯函数 vs 非纯函数、高阶方法到二维数组陷阱,结合 LeetCode Hot 100 面试视角,带你彻底搞懂 JS 数组的底层原理与实战技巧。
📖 前言
数组(Array)是几乎所有编程语言内置的数据结构,JavaScript 中的数组尤其灵活------不需要限定每项类型、不需要固定长度、API 丰富到让人眼花缭乱。
但灵活的背后也隐藏着陷阱:
- ❓
push、pop会修改原数组,怎么保证数据不可变? - ❓
forEach、map、filter怎么选? - ❓
(new Array(7)).fill([])为什么所有行会变成同一个数组? - ❓ 纯函数和非纯函数有什么区别?
这篇文章将带你从内存模型 到面试实战,彻底掌握 JS 数组的核心知识。
🧠 知识图谱
scss
JS 数组深度解析
├── 📦 一、数组的本质与内存模型
│ ├── 连续内存空间
│ ├── 起始地址 + 偏移量
│ └── ADT(抽象数据类型)
│
├── 🛠️ 二、数组的创建方式
│ ├── 字面量 []
│ ├── new Array()
│ ├── new Array(7).fill()
│ └── 二维数组初始化陷阱
│
├── 🔄 三、修改原数组的方法(非纯函数)
│ ├── push / pop
│ ├── unshift / shift
│ └── 纯函数 vs 非纯函数
│
├── ✨ 四、不修改原数组的方法(纯函数)
│ ├── map --- 映射
│ ├── filter --- 筛选
│ ├── every / some --- 判断
│ └── reduce --- 归约
│
├── 🏃 五、数组遍历方法对比
│ ├── for
│ ├── for...of
│ ├── forEach
│ └── 怎么选?
│
└── 🕳️ 六、二维数组与引用类型陷阱
├── 矩阵结构
├── fill([]) 的坑
└── 正确初始化方式
📦 一、数组的本质与内存模型
1.1 什么是数组?
💡 数组是一段连续的内存空间 ,通过起始地址 + 偏移量来访问元素。
css
内存中的数组:
起始地址:0x1000
┌─────────┬─────────┬─────────┬─────────┐
│ arr[0] │ arr[1] │ arr[2] │ arr[3] │
│ 'a' │ 'b' │ 'c' │ 1 │
├─────────┼─────────┼─────────┼─────────┤
│ 0x1000 │ 0x1008 │ 0x1010 │ 0x1018 │ ←── 起始地址 + 索引 × 元素大小
└─────────┴─────────┴─────────┴─────────┘
访问 arr[2]:
地址 = 起始地址 + 2 × 元素大小
地址 = 0x1000 + 2 × 8 = 0x1010
→ 直接定位,O(1) 时间复杂度!
1.2 ADT:抽象数据类型
数组作为一种 ADT(Abstract Data Type,抽象数据类型),具有两个核心特征:
| 特征 | 说明 |
|---|---|
| 连续的存储空间 | 元素在内存中连续排列 |
| 特定的操作 | push、pop、map、filter 等 |
💡 JS 数组的特殊性:虽然底层实现可能不是严格的连续内存(V8 引擎有优化),但从使用角度看,它表现出数组的所有特征。
🛠️ 二、数组的创建方式
2.1 字面量创建(最常用)
javascript
// ✅ 最常用:方括号 + 元素
const arr = [1, 2, 3, 4];
const strArr = ['a', 'b', 'c'];
// JS 数组更灵活:不需要每项类型一样
const mixed = [1, 'hello', true, { a: 1 }];
2.2 new Array() 创建
javascript
// 空数组
const arr1 = new Array(); // → []
// 指定长度(元素为空)
const arr2 = new Array(7); // → [empty × 7]
// 指定元素
const arr3 = new Array(1, 2, 3); // → [1, 2, 3]
⚠️ 注意 :
new Array(7)创建的是长度为 7 的空数组 ,不是[7]!
2.3 fill() 填充数组
javascript
// 创建并填充
const arr = (new Array(7)).fill(1);
// → [1, 1, 1, 1, 1, 1, 1]
// 理解 empty
const emptyArr = new Array(7);
console.log(emptyArr); // → [empty × 7]
console.log(emptyArr[0]); // → undefined
console.log(0 in emptyArr); // → false(这个位置还没有被占据)
| 状态 | 说明 |
|---|---|
empty |
数组位置未被占据,不属于任何类型 |
undefined |
位置被占据,值为 undefined |
🔄 三、修改原数组的方法(非纯函数)
3.1 四个核心方法
javascript
const arr = ['a', 'b', 'c'];
// push:尾部添加,返回新长度
arr.push(1); // → 4(返回长度)
arr.push(2); // arr = ['a', 'b', 'c', 1, 2]
// unshift:头部添加,返回新长度
arr.unshift(3); // → 6(返回长度)
// arr = [3, 'a', 'b', 'c', 1, 2]
// pop:尾部删除,返回被删除的元素
arr.pop(); // → 2(返回被删除的值)
// arr = [3, 'a', 'b', 'c', 1]
// shift:头部删除,返回被删除的元素
arr.shift(); // → 3(返回被删除的值)
// arr = ['a', 'b', 'c', 1]
| 方法 | 操作位置 | 功能 | 返回值 |
|---|---|---|---|
push(x) |
尾部 | 添加元素 | 新数组长度 |
pop() |
尾部 | 删除元素 | 被删除的元素 |
unshift(x) |
头部 | 添加元素 | 新数组长度 |
shift() |
头部 | 删除元素 | 被删除的元素 |
3.2 纯函数 vs 非纯函数
这是函数式编程中的核心概念:
javascript
// ❌ 非纯函数:依赖外部变量,修改外部状态
let num = 0;
function add(b) {
num += b; // 修改外部变量
return num; // 结果不可控
}
add(1); // → 1
add(1); // → 2(同样的输入,不同的输出!)
// ✅ 纯函数:不依赖外部状态,相同输入永远相同输出
function pureAdd(a, b) {
return a + b; // 只依赖参数,不修改外部
}
pureAdd(1, 2); // → 3
pureAdd(1, 2); // → 3(永远相同)
| 特性 | 纯函数 | 非纯函数 |
|---|---|---|
| 副作用 | ❌ 无 | ✅ 有(修改外部状态) |
| 可预测性 | ✅ 相同输入相同输出 | ❌ 结果不可控 |
| 可测试性 | ✅ 容易测试 | ❌ 难以测试 |
| 并发安全 | ✅ 线程安全 | ❌ 可能冲突 |
💡 数组方法的分类:
- 非纯函数 :
push、pop、shift、unshift、splice、sort、reverse(修改原数组)- 纯函数 :
map、filter、slice、concat、reduce(返回新数组)
✨ 四、不修改原数组的方法(纯函数)
4.1 map ------ 映射
javascript
const arr = [1, 2, 3, 4, 5];
// 每一项乘以 2
const newArr = arr.map((item, index, self) => {
return item * 2;
});
console.log(arr); // → [1, 2, 3, 4, 5](原数组不变!)
console.log(newArr); // → [2, 4, 6, 8, 10]
| 参数 | 说明 |
|---|---|
item |
当前元素 |
index |
当前索引 |
self |
原数组本身 |
4.2 filter ------ 筛选
javascript
const arr = [1, 2, 3, 4, 5];
// 筛选大于 2 的元素
const filtered = arr.filter((item) => {
return item > 2;
});
console.log(filtered); // → [3, 4, 5]
💡 filter 规则 :函数返回
true的元素留下,返回false的过滤掉。
4.3 every / some ------ 判断
javascript
const arr = [1, 2, 3, 4, 5];
// every:每一项都满足条件才返回 true
console.log(arr.every((item) => item > 0)); // → true(都大于 0)
console.log(arr.every((item) => item > 3)); // → false(1,2,3 不满足)
// some:有一项满足条件就返回 true
console.log(arr.some((item) => item > 2)); // → true(3,4,5 满足)
console.log(arr.some((item) => item > 10)); // → false(都不满足)
| 方法 | 条件 | 返回 true 的情况 |
|---|---|---|
every |
所有项 | 每一项都满足 |
some |
任意一项 | 至少一项满足 |
4.4 reduce ------ 归约
javascript
const arr = [1, 2, 3, 4, 5];
// 求和
const sum = arr.reduce((pre, cur, index) => {
console.log(pre, cur, index);
return pre + cur;
}, 0); // 0 是初始值
// 执行过程:
// 0 1 0 ← pre=0(初始值), cur=1, index=0
// 1 2 1 ← pre=1, cur=2, index=1
// 3 3 2 ← pre=3, cur=3, index=2
// 6 4 3 ← pre=6, cur=4, index=3
// 10 5 4 ← pre=10, cur=5, index=4
// 最终结果:15
| 参数 | 说明 |
|---|---|
pre |
累加器(上一次回调的返回值) |
cur |
当前元素 |
index |
当前索引 |
初始值 |
reduce 的第二个参数(可选但推荐) |
⚠️ 初始值的重要性 :如果不传初始值,
pre第一次是arr[0],cur是arr[1],空数组会报错!
4.5 高阶方法对比总结
| 方法 | 功能 | 返回值 | 是否纯函数 |
|---|---|---|---|
map |
映射转换 | 新数组 | ✅ |
filter |
筛选过滤 | 新数组 | ✅ |
every |
全部满足? | boolean | ✅ |
some |
部分满足? | boolean | ✅ |
reduce |
归约汇总 | 任意值 | ✅ |
forEach |
遍历执行 | undefined | ✅(不返回值) |
🏃 五、数组遍历方法对比
5.1 四种遍历方式
javascript
const arr = ['a', 'b', 'c'];
// ① for 计数循环
for (let i = 0; i < arr.length; i++) {
console.log(arr[i]);
}
// ② for...of(ES6)
for (const item of arr) {
console.log(item);
}
// ③ forEach
arr.forEach((item, index, self) => {
console.log(item, index, self);
});
// ④ map(如果需要用返回值)
arr.map((item) => {
console.log(item);
return item;
});
5.2 怎么选?
| 方法 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| for | 性能最好,可 break/continue | 可读性一般 | 性能敏感、需要中断 |
| for...of | 语义清晰,可 break/continue | 无索引 | 只关心元素值 |
| forEach | 功能强大,代码简洁 | 不能中断(break) | 简单遍历操作 |
| map | 返回新数组,链式调用 | 必须返回 | 需要转换数据 |
💡 选择建议:
- 需要 break/continue → 用
for或for...of- 只需要遍历 → 用
forEach- 需要转换数据 → 用
map- 需要筛选 → 用
filter
5.3 forEach 不能中断
javascript
const arr = [1, 2, 3, 4, 5];
// ❌ forEach 中不能用 break
arr.forEach((item) => {
if (item === 3) {
break; // SyntaxError: Illegal break statement
}
});
// ✅ 用 for...of 可以 break
for (const item of arr) {
if (item === 3) {
break; // 正常中断
}
}
🕳️ 六、二维数组与引用类型陷阱
6.1 什么是二维数组?
css
二维数组(矩阵):
0 1 2
┌───┬───┬───┐
0 │ 1 │ 2 │ 3 │
├───┼───┼───┤
1 │ 4 │ 5 │ 6 │
├───┼───┼───┤
2 │ 7 │ 8 │ 9 │
└───┴───┴───┘
arr[0][0] = 1 arr[0][1] = 2 arr[0][2] = 3
arr[1][0] = 4 arr[1][1] = 5 arr[1][2] = 6
arr[2][0] = 7 arr[2][1] = 8 arr[2][2] = 9
6.2 二维数组的创建陷阱
javascript
// ❌ 错误方式:fill([]) 的坑
const arr = (new Array(7)).fill([]);
// 创建了一个包含 7 个引用的数组
// 这 7 个引用都指向**同一个**空数组!
arr[0][0] = 1;
console.log(arr);
// → [[1], [1], [1], [1], [1], [1], [1]]
// 修改一个,全部变了!
为什么会这样?
scss
错误方式:(new Array(7)).fill([])
内存模型:
┌─────────┬─────────┬─────────┬─────────┐
│ arr[0] │ arr[1] │ arr[2] │ ... │
│ │ │ │ │ │ │ │
│ │ │ │ │ │ │ │
│ └─────┴───┘ └───┘ │ │
│ │ │ │
│ ▼ │ │
│ ┌─────┐ │ │
│ │ [] │ ←──────────────┘ │
│ └─────┘ │
│ (同一个数组对象!) │
└───────────────────────────────────────┘
所有 7 个位置都指向同一个 [] 对象
修改 arr[0][0] 等于修改所有行的第一个元素
6.3 正确初始化二维数组
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], [], [], [], [], [], []]
// 只修改了第一行!
正确方式的内存模型:
css
正确方式:循环创建
内存模型:
┌─────────┬─────────┬─────────┬─────────┐
│ arr[0] │ arr[1] │ arr[2] │ ... │
│ │ │ │ │ │ │ │
│ ▼ │ ▼ │ ▼ │ │
│ ┌───┐ │ ┌───┐ │ ┌───┐ │ │
│ │[1]│ │ │[] │ │ │[] │ │ │
│ └───┘ │ └───┘ │ └───┘ │ │
│ 独立 │ 独立 │ 独立 │ │
└─────────┴─────────┴─────────┴─────────┘
每个位置指向不同的数组对象
修改 arr[0] 不会影响其他行
6.4 二维数组的遍历
javascript
const arr = [
[1, 2, 3],
[4, 5, 6],
[7, 8, 9]
];
// 嵌套循环遍历
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]);
}
}
// 优化:缓存 length,避免重复访问属性
// arr.length 和 arr[i].length 是属性访问,有性能开销
💡 性能优化 :在循环中缓存
arr.length和arr[i].length,避免每次迭代都进行属性查找。
📊 核心知识速查表
数组方法分类
| 类别 | 方法 | 修改原数组? | 返回值 |
|---|---|---|---|
| 添加 | push |
✅ | 新长度 |
| 添加 | unshift |
✅ | 新长度 |
| 删除 | pop |
✅ | 被删元素 |
| 删除 | shift |
✅ | 被删元素 |
| 映射 | map |
❌ | 新数组 |
| 筛选 | filter |
❌ | 新数组 |
| 判断 | every |
❌ | boolean |
| 判断 | some |
❌ | boolean |
| 归约 | reduce |
❌ | 任意值 |
| 遍历 | forEach |
❌ | undefined |
纯函数 vs 非纯函数
| 纯函数 | 非纯函数 |
|---|---|
map |
push |
filter |
pop |
slice |
shift |
concat |
unshift |
reduce |
splice |
sort |
|
reverse |
💡 学习建议
- 优先使用纯函数 :
map、filter、reduce不修改原数组,代码更安全、更可预测 - 注意 fill 的引用陷阱 :
fill([])、fill({})都会让所有位置指向同一个对象 - 缓存 length 优化性能 :在循环中先
const len = arr.length - 选择正确的遍历方式 :需要中断用
for/for...of,简单遍历用forEach - 面试准备:LeetCode Hot 100 中大量题目涉及数组操作,熟练掌握这些方法
📚 推荐阅读
- MDN - Array
- MDN - 纯函数
- LeetCode Hot 100 --- 数组相关题目
- 《JavaScript 高级程序设计》第 4 版 --- 数组章节
- 《数据结构与算法 JavaScript 描述》--- 数组与列表
🏷️ 标签 :
JavaScript数组数据结构纯函数mapfilterreduce二维数组前端面试
如果这篇文章帮你理清了 JS 数组的核心知识,欢迎点赞 + 收藏 + 关注!有任何疑问欢迎在评论区交流~ 🎉