写代码这么多年,数组大概是前端工程师每天打交道最多的数据结构了。列表要渲染、接口要处理、表单要收集,所有业务逻辑最后基本都落到了数组的增删改查上。这篇文章是“JS 入门通关手册”系列的第 9 篇,我会直接把数组这个主题拆开揉碎,从创建初始化讲到增删改查,再到遍历排序和日常开发里那些防不胜防的坑。不管你是刚入门的前端新人,还是准备面试想快速查漏补缺的同学,花半小时把这篇过一遍,数组相关的题目基本就难不住你了。
1. 重新认识数组:它和别的数据结构差的不是一点点
1.1 在 JS 里数组到底是什么
很多从 C 或 Java 转过来的同学,一开始会拿“连续内存空间”“固定类型”“固定长度”那套思路理解 JS 数组,结果一上手就懵了:js 的数组居然可以混着存字符串、数字、对象,还能随便扩容。这不是错觉,JS 数组本质上就不是传统意义上的数组,它更像是一个“带数字索引的对象”。引擎(V8 等)在底层会做优化——对于“像数组”的连续元素,会退化成真正的连续内存存储;一旦你中间捅出洞来,或者存了不同类型,它就会降级成字典模式。
理解这一点特别重要,因为它解释了很多“诡异”现象。比如 arr[100] = 1,中间那 99 个位置是全空的,遍历时这些空位会被跳过,表现和处理完全不同。再比如 arr.length 不是只读的,你甚至可以直接 arr.length = 3 来截断数组。这些都是 JS 数组“对象本质”带来的特性。真正想要彻底搞懂,先把这个底层认知摆正,后面所有操作理解起来都会顺很多。
1.2 前端业务中数组的典型场景
数组在前端业务里到底长什么样?拿最常见的后台管理系统举例:
- 表格数据就是二维数组或对象数组,排序、筛选、分页全是数组操作。
- 表单里的多选、标签、图片上传列表,本质就是数组的增删改查。
- 接口返回的列表数据,需要 map 转换格式、filter 过滤状态、reduce 汇总统计。
- 图表组件的 data 字段,也基本是从数组衍生出来的。
说白了,数组就是前端数据流转的“集装箱”。你从接口拿到数据,第一件事往往是把它放进数组;处理完,再把它渲染到页面上。所以增删改查这四个字不是面试题里的概念,而是每天敲在编辑器里的真实操作。这篇后面所有内容我都尽量用业务场景来讲,不带虚的。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 数组创建与初始化:三种方式和两个高频坑
2.1 字面量、构造函数与静态方法的选择
创建数组最常见的方式是字面量:
javascript复制const arr = [1, 2, 3];
这种方式简单直观,也是我推荐大家日常开发无脑用的方式。但如果要创建“有长度但暂时没有元素”的数组,很多人会顺手写 new Array(3),这里就埋了一个大坑。new Array(3) 创建的其实是一个长度为 3 的稀疏数组,它有三个空位(empty),不是 [undefined, undefined, undefined]。你用 map 一处理,会发现回调压根不执行:
javascript复制const arr = new Array(3);
const result = arr.map((item, index) => index);
console.log(result); // 输出 [empty × 3],而不是 [0, 1, 2]
这一点在面试里也是高频考点。解决办法是使用 Array.from:
javascript复制const arr = Array.from({ length: 3 }, (_, index) => index);
// 输出 [0, 1, 2]
Array.from 不光是创建“填满”的数组好用,它还能接收一个类数组对象,比如 arguments、NodeList、Set 等,统一转成真正的数组。类似地,Array.of(3) 创建的是 [3] 而不是稀疏数组,用来弥补 new Array(3) 的歧义。这三个静态方法(Array.from、Array.of、Array.isArray)现在基本是我写数组代码的默认工具包了。
2.2 初始化常见场景与实用技巧
实际开发中,我经常需要初始化一个固定长度、固定默认值的数组。最朴素的写法是 for 循环 push,但更效率的做法是:
javascript复制// 生成长度为 5、全部填充 0 的数组
const zeros = new Array(5).fill(0);
// 生成 1 到 10 的连续数字序列
const range = Array.from({ length: 10 }, (_, i) => i + 1);
fill 这个方法很适合“批量填充”场景,比如初始化一个图表数据的占位数组。但要注意 fill 传入对象时,填的是同一个引用,不是深拷贝:
javascript复制const arr = new Array(3).fill({ count: 0 });
arr[0].count = 1;
console.log(arr); // [{ count: 1 }, { count: 1 }, { count: 1 }]
这个坑很多人踩过。如果希望每个元素是独立对象,还是要走 map 或 Array.from 的方案。
另外,字符串转数组、数组转字符串也是高频操作。前端经常要处理“1,2,3”这种由后端拼好的字符串,用 split 切分:
javascript复制const list = '1,2,3'.split(','); // ['1', '2', '3']
反过来,数组转字符串用 join:
javascript复制const str = [1, 2, 3].join('-'); // '1-2-3'
这两个方法简单,但处理“逗号分隔值”数据时非常实用。
3. 增删改查四件套:数组操作的核心基本功
3.1 增:push、unshift、splice 怎么选
往数组里加元素,最常见的无非三种方式:尾部追加、头部插入、任意位置插入。
javascript复制const arr = [2, 3];
// 尾部追加
arr.push(4); // arr: [2, 3, 4]
// 头部插入
arr.unshift(1); // arr: [1, 2, 3, 4]
// 在索引 2 处插入 'x'
arr.splice(2, 0, 'x'); // arr: [1, 2, 'x', 3, 4]
push 和 unshift 都会返回新数组的长度,并且会原地修改原数组。它们的性能差异通常可以忽略,但如果数组非常大,unshift 会触发所有元素移动,效率上会略差一些。splice 则是万能的“任意位置插入”,第二参数传 0 表示不删除任何元素。
从业务角度来说,我最常用的其实是 push。比如购物车里添加一件商品、消息列表加载更多时往尾部追加数据。要往中间插的情况相对少,但比如给一个按时间排序的日志数组插入一条指定位置的记录,splice 就派上用场了。
顺便提一句,push 其实可以接收多个参数,一次性追加多个元素:
javascript复制arr.push(5, 6, 7);
有人会把 push 和 concat 搞混。concat 不修改原数组,而是返回合并后的新数组:
javascript复制const newArr = arr.concat([8, 9]);
如果不想污染原数组,就选 concat 或展开运算符。这里的选择逻辑,本质是“原地修改 vs 返回新数组”的取舍,后面第五章会再展开讲。
3.2 删:pop、shift、splice 与 filter 的边界问题
删除操作也是四个方向直接对应:尾部弹出用 pop,头部弹出用 shift,任意位置删除用 splice 指定删除数量。
javascript复制const arr = [1, 2, 3, 4];
arr.pop(); // 删除尾部 4,返回 4
arr.shift(); // 删除头部 1,返回 1
arr.splice(1, 2); // 从索引 1 开始删 2 个元素,arr 变为 [1, 4]
pop 和 shift 都返回被删除的那个元素,splice 返回被删除元素组成的数组。这里有一个面试里经常被问的点:如何删除数组中“指定值”的元素而不是指定位置?比如删除所有值为 2 的元素。
初学者最容易犯的错误是用 indexOf 配合 splice 只删一次,漏掉了重复元素。我一般推荐用 filter 来做这种“按条件删除”:
javascript复制const arr = [1, 2, 3, 2, 4];
const newArr = arr.filter((item) => item !== 2);
// newArr: [1, 3, 4]
filter 不会修改原数组,它生成一份新数组,这在“不污染原数据”的场景下反而是优点。但如果你确实需要原地修改,并且数组里可能有多个匹配项,那就得用 while 循环配合 indexOf 了:
javascript复制const arr = [1, 2, 3, 2, 4];
let index;
while ((index = arr.indexOf(2)) !== -1) {
arr.splice(index, 1);
}
这个写法理解起来不难,核心是 indexOf 返回 -1 就说明删干净了。另外要特别注意:如果循环遍历数组的同时删除元素,索引会错乱,这个问题我放到第五章详细讲。
3.3 改:索引赋值与批量替换的正确姿势
修改某个位置的元素的最直接方式是索引赋值:
javascript复制const arr = ['a', 'b', 'c'];
arr[1] = 'x'; // 现在 arr 为 ['a', 'x', 'c']
这种修改极其常见,比如表格里修改某一行数据的某个字段。但注意 js 数组是动态的,给一个超过当前长度的索引赋值,不会报错,而是会把中间的空位补出来:
javascript复制const arr = ['a', 'b'];
arr[4] = 'e'; // arr 变为 ['a', 'b', empty × 2, 'e']
这就是动态数组的“双刃剑”特性。大多数情况下,业务上我们会先用 findIndex 找到目标位置,再通过索引修改对应对象属性:
javascript复制const cart = [
{ id: 1, name: '苹果', count: 1 },
{ id: 2, name: '香蕉', count: 2 },
];
const targetIndex = cart.findIndex((item) => item.id === 2);
if (targetIndex !== -1) {
cart[targetIndex].count = 5;
}
如果要做批量替换,比如把所有价格低于 10 的商品价格翻倍,用 map 生成新数组更清晰:
javascript复制const newCart = cart.map((item) =>
item.price < 10 ? { ...item, price: item.price * 2 } : item
);
这种“不改原数组,返回新数组”的做法在 React 这类数据驱动视图的框架里尤其重要,因为直接改原数组的引用可能导致视图不更新。
3.4 查:indexOf、includes、find 的分工
查询是数组操作里频率最高的动作之一,但不同查询需求用的方法完全不同,很多人混着用导致代码很啰嗦。
- 只想判断“有没有”某个值,用 includes,返回布尔值。
- 想知道某个值的具体位置,用 indexOf 或 lastIndexOf,返回索引,查不到返回 -1。
- 想按照条件查找“第一个符合条件的元素”,用 find,返回元素本身。
- 想找到“第一个符合条件元素的索引”,用 findIndex。
- 想拿到“所有符合条件的元素”,用 filter,返回新数组。
写个例子:
javascript复制const fruits = ['苹果', '香蕉', '橘子'];
fruits.includes('香蕉'); // true
fruits.indexOf('橘子'); // 2
fruits.find((f) => f.length === 2); // '香蕉'(长度正好为 2)
fruits.findIndex((f) => f.startsWith('苹')); // 0
fruits.filter((f) => f.includes('子')); // ['橘子'(还有别的如果有橙色子的话)]
这里有两个值得注意的细节。
第一,indexOf 用的是严格相等(===)比较,所以查找对象类型的数据时几乎永远找不到:
javascript复制const list = [{ id: 1 }];
list.indexOf({ id: 1 }); // -1,因为这是两个不同的对象引用
这种场景必须用 find 或 findIndex。
第二,includes 在判断 NaN 时比 indexOf 靠谱:
javascript复制const arr = [NaN];
arr.indexOf(NaN); // -1
arr.includes(NaN); // true
这个冷知识偶尔会在面试题里出现。理解它背后的原因也不难,indexOf 内部用的严格相等逻辑,NaN 不等于自身,而 includes 走的是 SameValueZero 比较规则。
4. 遍历与高阶操作:从 for 循环到 reduce
4.1 几种遍历方式的差异
数组遍历的方法多到让人眼花缭乱,但归类下来就这么几类:命令式遍历和函数式遍历。
命令式指的是传统 for 循环:
javascript复制for (let i = 0; i < arr.length; i++) {
console.log(arr[i]);
}
它能精确控制索引、条件、步长,适合在遍历过程中同时做多个操作。缺点是代码不够简洁。
for...of 是 ES6 时代的“语法糖”遍历:
javascript复制for (const item of arr) {
console.log(item);
}
它拿到的直接是元素值,想拿索引要配合 entries():
javascript复制for (const [index, item] of arr.entries()) {
console.log(index, item);
}
forEach 是最常用的高阶遍历方法:
javascript复制arr.forEach((item, index) => {
console.log(index, item);
});
注意 forEach 有两个限制:不能用 break 中断,也不能用 return 跳过当前循环(return 只相当于 continue)。想中断循环怎么办?用 for...of 最舒服:
javascript复制for (const item of arr) {
if (item === 3) break;
}
或者“曲线救国”用 some,在回调里 return true 也会提前终止遍历:
javascript复制arr.some((item) => {
if (item === 3) return true; // 相当于 break
});
这个技巧在一些追求“只用数组方法”的代码风格里很常见,但可读性略差,我更推荐 for...of 直接明了。
4.2 排序:sort 方法详解与踩坑记录
js 数组排序是热搜词里的高频问题,因为太容易踩坑了。最经典的坑是:sort() 默认按字符编码排序,不是按数值大小排序:
javascript复制const arr = [10, 9, 100, 2];
arr.sort();
// 结果是 [10, 100, 2, 9],因为字符编码 '10' < '100' < '2' < '9'
所以对数字排序必须传比较函数:
javascript复制arr.sort((a, b) => a - b); // 升序
arr.sort((a, b) => b - a); // 降序
比较函数的返回值约定:返回负数说明 a 应该排在 b 前面,返回正数说明 b 排在前面,返回 0 表示两者相等。这个规则自己去背容易忘,记住“a - b 升序,b - a 降序”就够了。
对对象数组按某个字段排序也是同样的套路:
javascript复制const products = [
{ name: '键盘', price: 200 },
{ name: '鼠标', price: 80 },
{ name: '显示器', price: 1500 },
];
products.sort((a, b) => a.price - b.price);
// 按价格升序:鼠标、键盘、显示器
另外,sort 是原地排序,它修改了原数组。如果不想污染原数据,先展开一份再排:
javascript复制const sorted = [...products].sort((a, b) => a.price - b.price);
ES2019 之后 js 引擎保证了 sort 的稳定性,也就是说相同 key 的元素会保持原有顺序,这一点在分页排序、多列排序场景中很重要。
说到排序,顺便提一下数组反转 reverse,它也是原地修改。字符串排序时如果需要忽略大小写,可以先把字符串统一转成小写再比较:
javascript复制const names = ['Banana', 'apple', 'Cherry'];
names.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
4.3 去重、扁平化等实用技巧
数组去重在面试和业务里都特别常出现。最简洁的写法是利用 Set 的特性:
javascript复制const arr = [1, 2, 2, 3, 3, 3];
const unique = [...new Set(arr)];
// [1, 2, 3]
这个写法背后的原理是 Set 天然不存重复值,再通过展开运算符把它变回数组。如果要对对象数组去重,就需要指定去重的 key:
javascript复制const list = [
{ id: 1, name: '苹果' },
{ id: 2, name: '香蕉' },
{ id: 1, name: '苹果' },
];
const uniqueById = [...new Map(list.map((item) => [item.id, item])).values()];
这个写法的思路是把数组按 id 转成 Map 的键值对,相同 id 会自动覆盖,最后取 values 并展开成数组。我第一次看到这个写法觉得有点绕,但确实是一行搞定,而且效率不错。
数组扁平化在多层嵌套数据结构里也经常用到:
javascript复制const nested = [1, [2, [3, [4]]]];
nested.flat(); // [1, 2, [3, [4]]]
nested.flat(2); // [1, 2, 3, [4]]
nested.flat(Infinity); // [1, 2, 3, 4]
flat 接收层数参数,传 Infinity 就表示不限制层数,全部展开。配合 map 的话,还有 flatMap 可以直接对每个元素处理后展平一层。
还有一类高频操作是“求和”“分组”“汇总”,这种场景 reduce 最合适:
javascript复制const numbers = [1, 2, 3, 4];
const sum = numbers.reduce((acc, cur) => acc + cur, 0); // 10
// 按属性分组
const items = [
{ type: 'a', value: 1 },
{ type: 'b', value: 2 },
{ type: 'a', value: 3 },
];
const grouped = items.reduce((acc, item) => {
(acc[item.type] = acc[item.type] || []).push(item);
return acc;
}, {});
// { a: [{ type: 'a', value: 1 }, { type: 'a', value: 3 }], b: [{ type: 'b', value: 2 }] }
reduce 的核心理解点在于 accumulator(累积器)的用法。第一次看觉得难,多写几遍就会发现它其实是一个很通用的“数组聚合”工具。
5. 常用问题排查:网上很少说清楚的那些坑
5.1 原数组修改还是新数组返回,一张表看清
这是前端面试题里几乎所有数组 API 都会问的方向:哪些方法会改变原数组,哪些不会。我直接给你一张速查表:
| 方法 | 是否修改原数组 | 返回值 |
|---|---|---|
| push | 是 | 新长度 |
| pop | 是 | 被删除的元素 |
| unshift | 是 | 新长度 |
| shift | 是 | 被删除的元素 |
| splice | 是 | 被删除元素组成的数组 |
| sort | 是 | 原数组(排序后) |
| reverse | 是 | 原数组(反转后) |
| fill | 是 | 原数组 |
| concat | 否 | 新数组 |
| slice | 否 | 新数组 |
| map | 否 | 新数组 |
| filter | 否 | 新数组 |
| reduce | 否 | 累积结果 |
| flat | 否 | 新数组 |
这张表特别实用。写代码的时候你心里要有一个默认判断:如果数据是从接口拿来的、还要给其他地方复用,尽量用“不修改原数组”的方式;如果是组件内部临时状态,原地修改也问题不大。比如 Vue 的响应式系统可以侦测到 push、splice 这些变更方法,所以原地修改是安全的;但 React 强调不可变数据,就更推荐 map 生成新数组的方式。
5.2 稀疏数组、引用比较与循环删除的坑
第一个坑是稀疏数组。通过 new Array(n) 或者给超出 length 的索引赋值,会产生空位。空位在 forEach、map、filter 这些方法里会被跳过,但 for...of 和数组展开运算符会把空位当作 undefined:
javascript复制const sparse = [1, , 3];
sparse.map((item) => item * 2); // [2, empty, 6]
[...sparse]; // [1, undefined, 3]
这个差异偶尔会让人头大。所以我的建议很明确:创建数组时尽量避免产生稀疏数组,必要的时候用 Array.from 或 fill 主动填充。
第二个坑是引用比较。两个数组内容完全一样,但 === 比较结果是 false:
javascript复制const a = [1, 2, 3];
const b = [1, 2, 3];
console.log(a === b); // false
因为数组是引用类型,比较的是“引用地址”而不是内容。想要比较内容,可以转成字符串或者 JSON 序列化来对比,但也要注意对象属性顺序等细节。如果只是判断两个数组是否完全相等,简单方案是:
javascript复制const equal = a.length === b.length && a.every((item, index) => item === b[index]);
第三个坑是循环里删除元素。这是很多业务 bug 的根源。直接看反例:
javascript复制const arr = [1, 2, 3, 4, 5];
for (let i = 0; i < arr.length; i++) {
if (arr[i] === 3) {
arr.splice(i, 1);
}
}
这个例子看着没问题,但如果数组里有两个 3,比如 [1, 3, 3, 4],用同一个逻辑删除,就会漏删。因为删掉第一个 3 后,第二个 3 的索引从 2 变成了 1,而循环的 i 已经在下一轮变成了 2,直接跳过了它。
解决办法有三种:倒序遍历、用 while + indexOf、或者干脆用 filter 生成新数组。其中倒序遍历最稳:
javascript复制for (let i = arr.length - 1; i >= 0; i--) {
if (arr[i] === 3) {
arr.splice(i, 1);
}
}
因为删除索引不会影响更小的索引,倒序就天然规避了移位问题。
5.3 多学两手:二维数组与动态数组的实战思路
二维数组在前端也很常见,比如表格数据、矩阵计算、Canvas 像素操作。构建二维数组时有一个经典坑:
javascript复制const matrix = new Array(3).fill(new Array(3).fill(0));
matrix[0][0] = 1;
// 输出发现 matrix[1][0] 和 matrix[2][0] 也变成了 1
原因还是 fill 传递对象引用的问题。正确的做法是:
javascript复制const matrix = Array.from({ length: 3 }, () => new Array(3).fill(0));
处理二维数组的遍历一般用双层循环:
javascript复制for (let i = 0; i < matrix.length; i++) {
for (let j = 0; j < matrix[i].length; j++) {
console.log(matrix[i][j]);
}
}
如果想把二维数组“拉平”成一维,直接用 flat() 就行。动态数组这个概念在很多语言里是“可扩容数组”的统称,JS 数组本身就是动态的,不需要手动管理容量,这也算是一种“自带”的便利。
最后再分享一个我自己的体感:很多前端面试题,什么数组方法原理、手写 map、手写 reduce、深度扁平化,本质上都是考察你对数组基础 API 和特性的理解深不深。把增删改查和遍历这套基本功吃透,面试题和真实业务写起来其实是同一件事。我最近在写代码时还养成了一个习惯:凡是涉及数组的复杂逻辑,先用注释把输入输出写清楚,再选 API。这样做下来,bug 明显少了很多。希望这篇文章能帮你把数组这块拼图完整补上,下次写数组代码时更自信。
