1. 数组不只是一排格子:为什么它是前端最常用的数据结构
每个前端工程师,不管是刚入行还是干了五六年,几乎每天都在和数组打交道。你可以不写复杂的树、不碰图论,但绝对绕不开一个数组。说“数组是前端最常用的数据结构”,一点都不夸张。它背后的原因其实很简单:前端的工作本质上是把数据从服务器拿过来、变成页面上的样子、再根据用户的操作把数据改回去——而这份“数据”,绝大多数时候就是以数组的形态存在的。用户列表是一组对象,购物车是一组商品,图表里的一组坐标点,后台返回的一组标签,本质上都是数组。
我在带新人或者做面试复盘的时候,经常发现一个现象:很多人能把数组的 API 背得滚瓜烂熟,什么 push、pop、shift、unshift、splice,倒背如流,但一碰到实际问题还是卡壳。问题在于,他们记住的是“这个方法叫什么、参数是什么”,却没有真正理解“这些方法背后解决的是什么问题、在什么业务场景下该选哪个”。这也是我写这一篇文章的初衷——不是单纯罗列 API,而是从“增删改查”这个最朴素的需求出发,把数组的这些操作串成一条清晰的逻辑线。学完之后,你不仅知道方法名,还能知道什么时候选它、为什么不选另一个。
这一篇是 JS 入门通关手册的第九篇,适合正在学 JavaScript 基础、准备前端实习或校招面试、或者已经工作了但发现自己数组用得很生疏的开发者。我会从一个实际需求开始——管理一组数据,把它从“新增一条”到“删除一条”,再到“修改一个字段”“查出一条记录”,整个闭环全部过一遍。走完这个流程,你会发现数组的所有核心操作,其实都是围绕这四个字展开的:增、删、改、查。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 数组的两个关键认知:有序的集合和不断变化的引用
2.1 数组是“有序”的,这个有序决定了它的所有行为
要理解数组的增删改查,先得建立两个基础认知。第一个认知是:数组是有序的。这个“有序”听着像废话,但它意味着每一元素都有一个确定的索引位置,从 0 开始连续编号。这个特性决定了我们查找元素的效率:你知道索引,直接取值,复杂度是 O(1),这是数组最强大的优势。比如 arr[3],不用遍历,一步到位。而如果我们要查找“某个值在不在数组里”,因为不知道它在哪个索引上,就得从头到尾遍历一遍,复杂度是 O(n)。理解了这个,你就明白了为什么很多场景下我们宁愿用一个对象来存“通过 id 找数据”,而不是每次都遍历数组——因为对象的属性查找在绝大多数情况下是 O(1) 的,而数组的线性查找则是 O(n)。这不是说数组不好,而是说我们要根据场景选择合适的结构。
第二个认知是:数组的很多操作,本质上改变的是“引用”而不是“值”。const arr = [] 这句话里,arr 保存的是一个指向数组的引用。你可以往这个数组里加东西、删东西,arr 这个引用不变,所以用 const 声明完全没问题。真正不能被改变的是“这个变量指向哪个数组”,而不是“这个数组里的内容”。很多初学者在这个地方犯迷糊:以为 const 声明的数组不能修改,结果一执行 arr.push(1) 发现居然没问题,然后又以为 const 数组的方法都能随便用。这两种理解都不全面,准确的表述是:const 锁住的是绑定关系,不是对象内容。这个认知会直接影响你后续对“哪些方法会改变原数组、哪些方法会返回新数组”的分类理解。
2.2 一句话说清增删改查
把“增删改查”四个字对应到数组操作上,其实就是四个问题:
- 增:往数组里添加一个或多个元素
- 删:从数组里去掉一个或多个元素
- 改:把数组里某个位置的元素替换成别的内容
- 查:找到某个元素的下标、或者判断某个元素是否存在
这个分类方式不仅仅是学习路径,在真实开发中也是解决问题的思路框架。遇到业务需求,先问自己一句:这是一个“增”的问题还是一个“改”的问题?然后对应的操作区间就缩小了。比如你接到一个需求:“用户点击收藏后,把商品加到收藏列表里,如果已经收藏了就提示”,你马上就意识到:第一步是“查”,第二步是“增”。思路清晰了,代码怎么写都顺。
3. 数组的创建与初始化:两种写法、一堆坑
创建数组表面上是“最基础的操作”,但这里埋的坑,面试里考的、实际工作里踩的都不少。
3.1 字面量和构造函数的区别
创建一个数组,最常见的两种方式:一种是字面量 let arr = [1, 2, 3],另一种是构造函数 let arr = new Array(1, 2, 3)。大多数情况下,我都推荐你用字面量。原因有两点:一是写法更简洁、可读性更高,二是不容易掉进构造函数的一个经典陷阱——new Array(3) 创建的并不是 [3],而是一个长度是 3、里面全是空位的数组。这个空位数组和 [3] 完全是两回事,我第一次给新人讲这个点的时候,很多人当场就懵了。
javascript复制let a = new Array(3); // 得到一个长度为 3 的空数组,不是 [3]
let b = new Array(3, 4); // 这才得到 [3, 4]
let c = [3]; // 字面量写法,得到 [3]
如果只有一位数字参数,new Array(n) 被解释为“创建长度为 n 的数组”;只要有两位以上参数,就解释为“用这些参数作为数组元素”。这种“同一种写法、不同参数个数意义却不同”的设计,本身就是个非常不友好的 API。这也是为什么业界普遍推荐用字面量创建数组,面试官如果问你这个区别,其实考察的不只是语法知识,还有你写代码时有没有规避陷阱的工程意识。
3.2 创建空数组的三种方式
有三种方式可以创建“空数组”:[]、new Array()、Array.from([])。后两种很少用,但 Array.from() 本身是个很有用的工具,值得一提。它可以从一个类数组对象或者可迭代对象创建数组。比如你拿到 DOM 元素集合 NodeList,它长得像数组但不是数组,你要用 map、filter 这些方法,就得先转换成真正的数组,最常见的做法就是 Array.from(nodeList),或者用 [...nodeList]。两者都能实现转换,区别不大,但从语义上来说,Array.from 更明确——“从一个东西创建出一个数组来”。还有一个常见操作是初始化一个固定长度的数组然后填充 0,可以用 new Array(5).fill(0),得到 [0, 0, 0, 0, 0]。这个 .fill() 在你需要初始化一个矩阵、一个计数器数组的时候非常有用,避免了写循环赋值的笨办法。
3.3 数组初始化的实际经验
实际开发中,数组初始化经常伴随“默认值”的需求。比如做一个日历组件,要生成一个长度为 42 的数组代表 6 周 42 天,每个元素初始是 null 或者对应的日期对象。用 new Array(42).fill(null) 一步到位。如果你需要的是一个二维数组,比如 3x3 的棋盘,不能直接 Array(3).fill(Array(3).fill(0))——这里有个大坑,.fill() 填入的是同一个数组引用,你改 matrix[0][0],会发现 matrix[1][0]、matrix[2][0] 也全变了。正确做法是先创建外层数组,再逐一填充每个内层数组:
javascript复制let matrix = Array.from({ length: 3 }, () => Array(3).fill(0));
这个坑我在实际项目中踩过。当时是做一个排班表,初始化为一个二维数组,结果勾选一个格子,整列跟着变,排查了很久才意识到是引用共享的问题。所以,凡是涉及数组嵌套、或对象作为数组元素时,都要问自己一个问题:我要的是各元素独立的对象,还是共享同一个引用的对象?大多数业务场景要的是前者。
4. 核心方法全景图:一张表看懂增删改查体系
数组的方法非常多,初学者很容易迷失在方法海里。我先给出一张核心方法速查表,你可以先收藏,用到的时候对照查看。这张表我从“是否改变原数组”这个维度做了划分,因为这是使用数组方法时必须建立的核心认知分类。
| 操作类型 | 方法名 | 作用 | 修改原数组? | 返回值 |
|---|---|---|---|---|
| 增(尾部) | push | 在末尾添加一个或多个元素 | 是 | 新数组的长度 |
| 删(尾部) | pop | 删除末尾一个元素 | 是 | 被删除的元素 |
| 增(头部) | unshift | 在开头添加一个或多个元素 | 是 | 新数组的长度 |
| 删(头部) | shift | 删除开头一个元素 | 是 | 被删除的元素 |
| 增删改(任意位置) | splice | 在任意位置添加、删除或替换元素 | 是 | 被删除的元素组成的数组 |
| 改(任意位置) | arr[i] = value | 直接通过索引替换元素 | 是 | 无(赋值操作) |
| 查(找下标) | indexOf / lastIndexOf | 返回元素首次/最后一次出现的位置 | 否 | 下标(找不到返回 -1) |
| 查(找元素) | includes | 判断是否包含某个元素 | 否 | 布尔值 |
| 查(按条件) | find / findIndex | 返回第一个通过测试的元素/下标 | 否 | 元素或 undefined / 下标或 -1 |
| 查(所有匹配) | filter | 返回所有通过测试的元素组成新数组 | 否 | 新数组 |
| 隐式增(拼接) | concat | 合并两个或多个数组 | 否 | 新数组 |
| 隐式删除/插入 | slice | 截取片段,创建新数组 | 否 | 新数组 |
| 遍历 | forEach | 依次对每个元素执行函数 | 否 | undefined |
| 变换 | map | 对每个元素处理后生成新数组 | 否 | 新数组 |
| 归并 | reduce | 从左到右累计计算 | 否 | 累计结果 |
| 排序 | sort | 原地排序 | 是 | 排序后的原数组引用 |
| 反转 | reverse | 反转数组顺序 | 是 | 反转后的原数组引用 |
这张表看起来简单,但它的价值在于:你可以从“我要做什么”和“我能不能接受原数组被改”两个维度来查表。比如你在 React 的 state 里保存了一个数组,更新 state 时通常要“返回一个新数组”,而不是直接修改旧数组——因为 React 需要用引用变化来判断是否重新渲染。这时候你就应该选 concat、filter、map 这类不修改原数组的方法,而不是 push、splice。这个区分不是语法细节,而是工程实践的真实需求,我在面试时经常用这个点来考察候选人有没有实战经验。
4.1 增:push/unshift 怎么选
push 在数组末尾添加元素,unshift 在数组头部添加元素。性能上,push 时间复杂度 O(1),unshift 最坏情况是 O(n)——因为要在头部插入一个元素,后面的所有元素都要整体后移一位。实际开发中,绝大多数场景用 push,把 unshift 留给频率极低的头部插入操作。比如你维护一个日志列表,新的日志应该加在末尾,那自然 push。如果你要做的是“最新消息置顶”,那 unshift 可能更适合。选型不是凭喜好,而是由数据的使用方式决定的。
4.2 删:pop/shift/splice 的定位差异
pop 和 shift 都是只删除一个元素,区别在于位置——一个从尾部,一个从头部。它们有个共同特点:会返回被删除的元素。这个返回值经常被忽略,但其实很实用。比如你要实现一个“撤销”功能,记录用户的操作历史,每一次操作入栈,undo() 的时候 pop 出上一次操作并执行回滚逻辑——这就是后进先出(LIFO)的经典场景。
splice 就更强大了,它是“增删改”三合一的通用方法,有三个核心参数:起始下标、删除个数、要添加的新元素(可以是多个)。理解 splice 的一个关键认知是:它的返回值是被删除的元素组成的数组,而不是修改后的原数组。这个返回值是很多初学者会忽视的,在面试中我经常听到“splice 返回新数组”这样的错误答案。正确答案是“splice 直接修改原数组,并返回被删除元素构成的数组”。如果删除个数为 0,那返回值就是个空数组,同时它完成了“在指定位置插入元素”的功能。
4.3 改:直接下标赋值和 splice 的边界
改操作最简单的方式是 arr[1] = '新值',这个方式很直白,但是有个前提——索引必须存在。如果索引超出了数组现有长度,JS 会“强行”扩容数组,中间空出的位置会被填充为稀疏空位。比如:
javascript复制let arr = [1, 2, 3];
arr[5] = 100;
// arr 现在是 [1, 2, 3, 空位×2, 100]
console.log(arr.length); // 6
这种“自动扩容”不是报错,但会造成数组中间出现稀疏空位,后续用 map 之类的遍历方法时,这些空位会被跳过去,容易产出诡异的结果。所以,在改一个不存在的下标时,先想清楚你是要“替换已有元素”还是“追加新元素”,前者需要确认下标有效,后者建议用 push 或 splice。
4.4 查:indexOf/includes/find/filter 的适用场景
查询操作是最能拉开人与人差距的地方。indexOf 用于找简单类型的下标,includes 用于判断存在性,两者对于对象元素都无能为力——因为它们比较的是引用而不是内容。如果你要判断“数组里是否包含 id 为 3 的那条记录”,直接用 arr.includes({ id: 3 }) 是一定返回 false 的,因为这是一个全新的对象,引用不同。这种情况下,需要用 find 或 some:
javascript复制let list = [{ id: 1 }, { id: 2 }, { id: 3 }];
let target = list.find(item => item.id === 3); // 找到元素
let index = list.findIndex(item => item.id === 3); // 找到下标
let exists = list.some(item => item.id === 3); // 只要存在就 true
find 和 filter 的区别在于:find 返回第一个匹配的元素,找到就走;filter 返回所有匹配的元素,遍历完整个数组。理解这一点,你就可以根据需求选择。如果是“查一条记录”,用 find 更高效且语义更准;如果是“查一组记录”,用 filter。
5. 原始数组 vs 返回新数组:一个改变编程习惯的认知
5.1 为什么这个分类如此重要
这一节我要重点展开,因为它直接关系到代码可维护性——尤其是在使用 React、Vue 这类响应式框架时。上面那张表里,每行标了“修改原数组?”这一列,我建议你把它背下来。为什么?因为在实际开发中,我们经常需要一个不可变更新(immutable update)的模式:你不修改旧数组,而是基于旧数组生成一个新数组,然后替换掉旧值。这样做的好处是,你可以轻松追踪数据变化——旧数组还在,新数组也明确生成,比较两个引用就能知道是否发生了变化。这在 React 的 useState 里几乎是强制性的:
javascript复制// 错误方式:直接修改 state 数组
const [list, setList] = useState([1, 2, 3]);
list.push(4); // 不触发重新渲染,React 认为 state 没变
setList(list); // 引用没变,视图不会更新
// 正确方式:返回新数组
setList([...list, 4]); // 展开运算符创建新数组
setList(list.concat(4)); // concat 返回新数组
setList(list.filter(i => i !== 2)); // 删除,返回新数组
这不是 React 特有的约束,Vue 3 的响应式系统内部同样会追踪变化。退一步说,即使不用框架,养成“非必要不修改原数组”的习惯,也能让你的代码更容易预测和调试。我见过太多线上 bug,最后定位到是因为某个函数在不知情的情况下修改了传入的数组,导致调用方数据被污染。给函数传入数组时,如果函数内部要增删元素,一定要问:这个函数是否应该修改调用方的数据?通常答案是否定的。
5.2 改变原数组的方法
改变原数组的方法有:push、pop、shift、unshift、splice、sort、reverse,以及直接赋值 arr[i] = value、arr.length = n 这种特殊的“截断”操作。注意,sort 和 reverse 都是原地操作,它们同时会返回原数组的引用。所以下面的写法其实是一个坑:
javascript复制let a = [3, 1, 2];
let b = a.sort();
console.log(a === b); // true
sort 排序后,原数组被修改了,返回的也是同一个引用。如果你是想在排序的同时保留原数组,需要先复制一份再排序:
javascript复制let sorted = [...a].sort((x, y) => x - y);
5.3 返回新数组的方法
返回新数组的方法有:concat、slice、filter、map、flat。这些方法不会修改调用它们的数组。它们的共同特点是“从旧数组派生新数组”,原始数据保持不变。有一句口诀可以用:修改原数组的方法,动词大多表示“动作”(push、pop、shift、splice、sort、reverse);返回新数组的方法,大多是“描述转换结果”(concat、filter、map、slice)。不绝对,但能帮助记忆。
5.4 展开运算符与 concat 的选择
在“合并数组”这个场景里,展开运算符 [...a, ...b] 和 concat 都能用。区别在于,concat 除了合并数组,还能直接把单个元素追加进来:[1].concat(2, 3) 得到 [1, 2, 3]。而展开运算符的可读性更好,尤其是在数组字面量中间插入元素时很直观。我个人的习惯是:小规模合并用展开运算符,因为写得爽、读得懂;如果确定只是合并两个已有的数组,两者随便选。但要记住一点:展开运算符进行的是浅拷贝,如果数组元素是对象,复制的是对象引用,修改内层对象依旧会影响原数组。
6. 实操案例:带着增删改查做一个生产级待办列表
前面讲的都是方法论,这一节我们进入实战。我会带着你用数组的增删改查实现一个完整的“待办事项管理器”。这个案例很小,但是涵盖了绝大多数数组操作的核心逻辑。为了更贴近真实项目,我会尽量模拟业务代码的写法,而不是玩具代码。
6.1 基础数据结构定义
待办事项的每一项是一个对象,包含 id、title、done 三个字段:
javascript复制let todos = [
{ id: 1, title: '学习数组方法', done: true },
{ id: 2, title: '写一篇总结笔记', done: false },
{ id: 3, title: '复习面试题', done: false }
];
为什么用对象而不是字符串?因为真实业务中的数据都是结构化对象,字段多、状态多。用对象你才能演示“查对象、改字段”这类最常见的操作。
6.2 增:添加一条待办
添加待办是通过表单提交完成的,新的待办对象只有一个 title,id 需要动态生成,done 默认为 false。
javascript复制function addTodo(title) {
let newTodo = { id: Date.now(), title, done: false };
todos.push(newTodo);
return newTodo;
}
这里用 Date.now() 当 id 是一种偷懒但有效的做法,它保证了一个页面内不会重复。真正严谨的场景,id 一般是后端生成的,但前端也要具备临时 id 的生成能力。如果你要追求“添加后原数组不变”,就用 todos = [...todos, newTodo],这在 React 中是标准写法。
6.3 删:删除一条待办
删除一条待办,通常是通过 id 定位,然后把它从数组里移除。
javascript复制function removeTodo(id) {
let index = todos.findIndex(todo => todo.id === id);
if (index !== -1) {
todos.splice(index, 1);
}
}
这里有两个要点:第一,findIndex 找不到时返回 -1,所以必须判断,否则 splice(-1, 1) 会把最后一个元素误删。这是实际开发中很常见的低级错误,我见过不止一次——原因是 splice 的起始下标是负数时,会被解释为“从倒数第几个开始”。如果你不判断,索引为 -1 时删除的就是最后一个元素,完全不是预期。第二,如果你要“不改变原数组”的删除方式,那么用 filter 更直接:
javascript复制todos = todos.filter(todo => todo.id !== id);
这一句话就能实现“删除指定 id 的元素”,它的语义是“保留所有不等于这个 id 的元素”,逻辑上等价于删除了目标元素。在 React 里,这种写法非常常见。
6.4 改:切换完成状态
改操作最常见的是切换 done 的状态。这里要特别注意,不能直接写:
javascript复制// 错误写法
function toggleTodo(id) {
let target = todos.find(todo => todo.id === id);
target.done = !target.done; // 这确实改了对象,但如果你用了不可变更新模式,这就破坏了规矩
}
如果是在 Vue 或“非严格不可变”的环境中,上面的写法没问题。但在 React 的 state 管理中,你需要返回新数组、新对象:
javascript复制function toggleTodo(id) {
todos = todos.map(todo =>
todo.id === id
? { ...todo, done: !todo.done }
: todo
);
}
这段代码的精髓在于 map 只替换目标对象,其他对象保留原引用。这一步操作同时体现了“改”和“查”——用 map 遍历查询 id,然后在回调里完成替换。
6.5 查:筛选待办
查询待办更常见的场景是“按关键字筛选”和“按状态筛选”。按状态筛选,实际上是先用“查”的方法(filter)产出一个子集,再渲染:
javascript复制function getTodosByStatus(status) {
if (status === 'all') return todos;
if (status === 'active') return todos.filter(todo => !todo.done);
if (status === 'completed') return todos.filter(todo => todo.done);
return todos;
}
这里的 filter 不会修改原数组,你可以放心地用它来生成视图数据,而不用手动复制一份防止污染。这个函数我每个项目基本都会写一遍。
6.6 统计与排序
还有两个高频操作:统计完成数量和按创建时间排序。
javascript复制// 统计未完成数量
let remainingCount = todos.filter(todo => !todo.done).length;
// 按 id 升序排列
let sorted = [...todos].sort((a, b) => a.id - b.id);
这里再次体现了“先复制再排序”的模式。不做复制直接 todos.sort(),会修改原数组顺序;如果你之后要做增量渲染或依赖原顺序,就会出问题。sort 的排序函数 (a, b) => a.id - b.id 是升序,如果希望按 id 降序就用 b.id - a.id。如果排序字段是字符串,直接 a.title.localeCompare(b.title) 可以按字典序排。
7. 花样组合:链式调用才是数组操作的高阶用法
学会单个方法后,下一步就是组合运用。数组方法的一大魅力在于可以链式调用——一个方法返回新数组,下一个方法继续调用。这种写法的可读性非常高,逻辑也清晰。数据处理的“管道”思维,就是从一个输入数组开始,经过一系列变换,最终得到你想要的输出。
7.1 一个真实的链式处理示例
假设后端返回一份订单数据,你需要:过滤出金额大于 100 的订单 → 只取订单号 → 按订单号降序排列 → 把号码拼成一个逗号分隔的字符串。
javascript复制let orders = [
{ orderNo: 'A001', amount: 80 },
{ orderNo: 'A002', amount: 150 },
{ orderNo: 'A003', amount: 120 },
{ orderNo: 'A004', amount: 60 }
];
let result = orders
.filter(item => item.amount > 100) // [A002, A003]
.map(item => item.orderNo) // ['A002', 'A003']
.sort((a, b) => b.localeCompare(a)) // ['A003', 'A002']
.join(', '); // 'A003, A002'
console.log(result);
短短四行代码,完成了四个逻辑步骤。如果不用链式调用,你至少得写十几个变量、好几层循环。这就是链式调用的价值。注意这里的运行顺序:每一步都是在上一步返回的新数组上继续操作,原数组 orders 并没有被修改。
7.2 链式调用的注意点
链式虽然优雅,但有几个坑要注意。
第一,链式调用的中间步骤如果会产生空数组,后续步骤要能接受空数组作为输入。比如 filter 得到空数组后,再 .map 也能正常执行,返回空数组,最终 join 得到空字符串——这是合理的降级行为。但如果你在链式中间用了 find,它有可能会返回 undefined,而 undefined.find 会直接报错,导致整个链条崩溃。所以,方法链中慎用“可能返回 undefined”的方法。
第二,链式调用不要滥用。如果一个链子里有超过四五个方法,可读性反而会下降。这时候拆成两三个变量赋值,配合有意义的命名,维护起来更方便。比如:
javascript复制let activeTodos = todos.filter(todo => !todo.done);
let activeTitles = activeTodos.map(todo => todo.title);
这样在调试时,你可以随时打印中间结果 activeTodos,而一个超长链式调用,调试起来就得打断点或者临时改代码。实际工程中,很多老手更倾向这种“中间变量法”,而不是追求一步到位。
7.3 reduce:从数组到任意值的终极归并
reduce 是数组方法中最强大也最容易被误解的一个。它的核心能力是“把数组归并为任意值”——这个值可以是数字、字符串、对象,甚至是另一个数组。最常见的应用是“分组统计”:
javascript复制let fruits = ['apple', 'banana', 'apple', 'orange', 'banana', 'apple'];
let count = fruits.reduce((acc, fruit) => {
acc[fruit] = (acc[fruit] || 0) + 1;
return acc;
}, {});
// 结果:{ apple: 3, banana: 2, orange: 1 }
这个例子里,初始值参数 {} 是必不可少的一环。没有初始值,第一次回调的 acc 就是数组的第一个元素——它的类型是字符串,无法承载后续的对象属性。所以,reduce 的第一个关键是牢记初始值。第二个关键是回调函数必须有返回值,因为返回值会成为下一次回调的 acc。如果你在回调里忘记了 return acc,下一次得到的 acc 就是 undefined,整个归并就崩了。我在实际开发中,reduce 最常见的三种用途:分组统计、数组转对象、累加计算。你掌握了这三种,就足以应付大多数场景。
8. 多维数组与数组展开技巧
二维数组在前端开发中的使用场景没有后端那么多,但也不是没有——表格控件、矩阵布局、日历组件、图片栅格数据,都会用到。尤其是做数据表格时,你可能会把一个一维数组切成若干行若干列,形成一个二维数组来渲染。
8.1 创建二维数组的正确姿势
前面已经提到过二维数组的坑:Array(3).fill(Array(3).fill(0)) 会让所有行共享同一个引用。正确做法是每一行单独创建:
javascript复制let grid = Array.from({ length: 3 }, () => Array(3).fill(0));
这里 Array.from 的第二个参数是一个映射函数,它对每个空槽执行一次,返回一个独立的新数组。每一行都是不同的数组对象,修改任意一格不会影响其他行。
8.2 数组的扁平化处理
后端返回的数据经常是树形的,比如菜单、评论、分类,它们的层级不定,但你在做搜索、统计时会希望得到一个扁平的列表。这时候就需要扁平化。flat() 方法可以指定层数,flat(Infinity) 能将任何深度的嵌套数组拉平:
javascript复制let nested = [1, [2, 3], [4, [5, 6]]];
nested.flat(1); // [1, 2, 3, 4, [5, 6]]
nested.flat(Infinity); // [1, 2, 3, 4, 5, 6]
如果你要“把树形数据拍平成一维数组,同时保留完整路径”,光用 flat 不够,需要递归或 reduce 配合。这里我分享一个实用函数:
javascript复制function flattenTree(tree, path = []) {
return tree.reduce((acc, node) => {
let currentPath = [...path, node.name];
let current = { ...node, path: currentPath.join(' / ') };
acc.push(current);
if (node.children && node.children.length) {
acc = acc.concat(flattenTree(node.children, currentPath));
}
return acc;
}, []);
}
这个函数把树形目录变成带完整路径的扁平数组,可以直接用来渲染下拉选项。写这种递归函数的时候,关键是先明确“终止条件”和“递推步骤”。终止条件是:没有 children,就停止递归;递推步骤是:把当前节点加入结果,然后递归处理子节点。
9. 常见问题与排查技巧实录
这一节我把实际开发中高频踩坑的问题整理出来,做成一个速查表。每一条都是我见过或者亲手踩过的,不是网上抄的。
| 问题现象 | 原因分析 | 解决方案 |
|---|---|---|
splice(-1, 1) 误删最后一个元素 |
没判断 findIndex 返回 -1 |
先判断 index !== -1,再执行 splice |
| 二维数组修改一格,整列跟着变 | .fill() 填充的是同一个引用 |
每一行用 Array.from 单独创建 |
includes({ id: 3 }) 永远返回 false |
对象比较的是引用,不是内容 | 用 find、some 或比较 id 字段 |
sort() 排序结果不对 |
默认排序是字典序,不是数字序 | 传入比较函数 (a, b) => a - b |
map 遍历稀疏数组,空位被跳过 |
数组存在空洞 | 用 Array.from 或先 fill 填充 |
const 声明的数组无法重新赋值 |
迷惑于 const 的含义 | const 锁引用,不锁内容 |
| React 中修改了 state 的数组,视图不更新 | 直接改变原数组,引用未变 | 用 concat / map / filter 返回新数组 |
reduce 结果不对,第一次就出错 |
忘了传初始值 | 始终显式传初始值,并确保回调返回累计值 |
arr.length = 0 原数组被清空了 |
修改 length 会截断数组 | 如果需要保留原数组,先复制 |
9.1 数组中的对象引用问题
我单独拎出来讲一个最隐蔽的坑:对象引用共享。比如你从服务器拿到一个数组,把它赋值给两个变量,然后通过变量 A 修改了某个元素对象的字段,你发现变量 B 里的数据也变了。这不算 bug,这是引用类型的基本行为。但如果你没有意识到,在写代码时就容易产生神秘的联动。比如:
javascript复制let arr1 = [{ name: 'a' }, { name: 'b' }];
let arr2 = [...arr1]; // 浅拷贝,数组本身是新的,但元素对象是共享的
arr2[0].name = 'changed'; // 修改了对象
console.log(arr1[0].name); // 'changed',arr1 也被改了
[...arr1] 只能复制一层,如果元素是对象,复制的是对象的引用。要实现“深拷贝”,可以 structuredClone(arr1) 或 JSON.parse(JSON.stringify(arr1)),但后者有局限:不能处理函数、undefined 等特殊值。在实际开发中,我不建议为了追求深拷贝而到处使用 JSON 方式,大多数业务并不需要深拷贝,反而是要明确“数据流”和“所有权”——哪段代码拥有并可能修改这批数据,其他代码只能读取。这样思考,比纠结于拷贝方式更重要。
9.2 两个调试小技巧
调试数组相关代码,我通常用两个方法。第一个是快速打印中间状态。如果你没有使用断点调试的习惯,可以在链式调用中插入 .map(item => { console.log(item); return item; }),一次性观察每个元素。调试完再删掉。比在 IDE 里打断点更轻量。第二个是活用 JSON.parse(JSON.stringify(arr)) 做“静态快照”。当你怀疑数据被某个函数修改了,可以先把当前数组快照到另一个变量里,操作完再对比两个快照差在哪:
javascript复制let snapshot = JSON.parse(JSON.stringify(todos));
// ...执行某些操作...
console.log(JSON.stringify(todos) !== JSON.stringify(snapshot)); // 是否被修改
这个方法在排查“谁改了数据”的问题时非常有效。
9.3 面试考点:数组方法中的“副作用”与“纯函数”
最后补充一个面试里的热门维度:哪些方法有副作用,哪些是纯函数。push、pop、splice、sort、reverse 都有副作用,它们修改了调用它们的数组;map、filter、concat、slice 是纯函数式的,不修改原数组。面试官问这个,考察的是你对不可变数据的理解,以及你在工程中写代码时是否考虑到状态管理。除此之外,还会问 for...of 和 forEach 的区别:for...of 可以用 break、continue 中断循环,forEach 不支持中断;for...of 能遍历可迭代对象(包括数组、Set、Map),forEach 是数组专有的方法。理解这些差别,你才算从“会用”迈进到“会用得巧”。
10. 个人经验总结与后续学习建议
写到这里,我再分享一点个人经验。数组在前端中的重要性怎么强调都不过分。我个人在带新人的时候,总会先让他们把数组的增删改查背熟,然后立刻去业务里找真实场景练习——比如做一个搜索筛选功能、做一个购物车加减、做一个列表勾选。因为你自己写一遍,比看十遍文章都管用。学数组不要追求“把所有方法背下来”,那是死记硬背,过两天就忘。更好的路径是:先掌握 push、pop、shift、unshift、splice、map、filter、find、reduce 这九个核心方法,再把其他方法当工具书一样查。用多了,自然就熟了。
最后给大家一个扩展练习建议:试着用数组方法实现一个简易购物车,包含添加商品、删除商品、修改数量、统计总价、按价格排序、按关键字搜索全部功能。这个练习涵盖了增删改查的全部核心场景,能独立完成它,数组这部分你就真正通关了。下一篇文章可以聊数组排序的细节,也可以聊对象与数组的搭配使用,到时候再继续。
