开头
做前端这几年,我不敢说把 JavaScript 精通到什么程度,但有一点体会特别深:数组操作水平,基本决定了你写业务代码的速度和质量。后端接口一返回,前端拿到的基本都是数组;表单提交前要组装数据,也是数组;状态管理里要筛选、去重、排序、分组,还是数组。面试题里十道至少有五道跟数组有关,工作中更是每天都在跟它打交道。
这篇文章我打算把自己这些年在前端实战里用数组的整套心得整理出来。不是把 MDN 文档抄一遍,而是结合真实业务场景,讲讲哪些方法最常用、哪些写法有坑、什么数据结构在什么场合更快,以及面试里考到的数组题该怎么思考和回答。无论你是刚入行的新人,还是写了几年业务想系统巩固一下,这篇内容都可以当一份“带注释的实战笔记”来用。
1. 创建、初始化与复制:先把地基打牢
1.1 字面量、构造函数和静态方法,到底用哪个
数组创建的写法很基础,但我见过不少团队代码里混着各种风格,出问题的还不少。
最推荐的一定是字面量:
const list = []; const nums = [1, 2, 3];简单、直观、没有歧义。构造函数new Array()看起来也能用,但有一个非常容易踩的坑:new Array(5)并不是创建一个包含数字 5 的数组,而是创建一个长度为 5 的空数组。这个行为容易让人懵,尤其是从其他语言转过来的同学。你如果真想初始化一个“长度为 5 但每个元素都是 0”的数组,得写:
const arr = new Array(5).fill(0); // 结果是 [0, 0, 0, 0, 0]还有两个静态方法在实战里很常用:Array.from和Array.of。
Array.from最大的价值是把类数组对象转成真数组。比如 DOM 操作里经常拿到NodeList,它不是数组,不能直接用map、filter,以前都是写Array.prototype.slice.call(nodeList),现在直接Array.from(nodeList)就行了。它还能接收一个映射函数:
const arr = Array.from({ length: 5 }, (_, i) => i * 2); // 结果是 [0, 2, 4, 6, 8]这个用法在生成测试数据、初始化表格行时非常有用。
Array.of则是为了解决new Array(5)的坑而存在的:Array.of(5)返回[5],而不是空数组。业务里它出现频率不高,但一旦写工具函数需要“把参数变成数组”时,它比Array.from(arguments)更直接。
1.2 浅拷贝和深拷贝,别再在这个环节丢数据
数组拷贝是业务里最容易出问题的点。先说浅拷贝,JavaScript 里常见的三种方式:
const copy1 = original.slice(); const copy2 = [...original]; const copy3 = Array.from(original);这三种都是浅拷贝,对于一维数组、元素为基本类型的场景,完全够用,复制后修改新数组不会影响原数组。但注意,这里说的是“不会影响原数组”指的是最外层。如果数组里面放的是对象,浅拷贝只是把对象的引用复制了一份:
const original = [{ name: '张三' }, { name: '李四' }]; const copy = [...original]; copy[0].name = '王五'; console.log(original[0].name); // 王五原数组也被改了。这种情况在列表编辑功能里非常常见:弹窗里改了表单数据,保存时发现列表中的原数据也被改了,排查半天,问题就出在浅拷贝上。
真正的深拷贝,在业务代码里如果对象结构不复杂,可以用JSON.parse(JSON.stringify(arr)),但它有两个明显的局限:会丢失undefined、函数、Symbol类型的属性,而且遇到循环引用会直接报错。所以更稳妥的方式是用structuredClone,这是现代浏览器原生支持的深拷贝 API:
const deepCopy = structuredClone(original);如果项目里有 Lodash,_.cloneDeep也是常规选择。我的建议是:简单结构用展开运算符浅拷贝,复杂结构直接用structuredClone或cloneDeep,不要为了省事把浅拷贝当深拷贝用。
2. 实战中最高频的五类数组操作,一次讲透
2.1 map、filter、forEach 不要混用
这三个方法是我看代码时最常看到用错的。“遍历数组修改元素”用forEach还是map?“筛选出满足条件的元素”用for循环还是filter?
记住一个判断标准:你要不要拿到一个新数组?
- 要基于原数组生成一个个数相同、内容变换后的新数组,用
map。 - 要挑出部分元素组成新数组,用
filter。 - 只是想遍历一遍、执行副作用(比如打印、写入某个变量),用
forEach。
业务里最常见的错误是在forEach里收集结果:
const result = []; const list = [1, 2, 3, 4]; list.forEach((item) => { if (item % 2 === 0) { result.push(item); } });这样写功能没问题,但不够“声明式”。同样的需求用filter一行搞定:
const result = list.filter((item) => item % 2 === 0);另一个更隐蔽的坑是:在map或forEach里修改原数组元素,尤其是forEach中直接对对象元素做变更。如果你确实要修改原数组,直接用for...of反而更清晰。
2.2 reduce 不只是用来求和
很多前端同学一看到reduce就发怵,觉得它的回调参数太多、返回值不好理解。实际上它的核心思想很简单:把一个数组“归并”成一个结果。这个结果可以是数字、字符串、对象,甚至一个新数组。
最常见的求和:
const total = orders.reduce((sum, order) => sum + order.amount, 0);第二个参数0是初始值。如果不传初始值,第一次迭代时sum会取数组第一个元素,这在数组为空时会直接报错,所以使用reduce永远要传初始值。
业务里reduce更强大的场景是把数组转成对象,也就是“按某个字段做索引”:
const userMap = userList.reduce((map, user) => { map[user.id] = user; return map; }, {});这样后面就不用每次find遍历整个数组,直接userMap[id]取对象就行,速度是O(1)。在做表格行操作、下拉框选项联动时,这个模式能省很多代码。
2.3 排序:sort()不传比较函数,坑你没商量
sort()是 JS 里最容易翻车的数组方法之一。原因在于:不传参数时,它会先把元素转成字符串,再按 Unicode 编码排序。所以数字排序会出现这样的结果:
const nums = [10, 9, 80, 3]; nums.sort(); // 结果是 [10, 3, 80, 9],而不是 [3, 9, 10, 80]数字排序一定要传比较函数:
nums.sort((a, b) => a - b); // 升序 nums.sort((a, b) => b - a); // 降序对象数组按某个字段排序也很常见:
list.sort((a, b) => a.price - b.price);多条件排序也不难,只要比较函数里做“优先级”判断就行。比如先按销量降序,销量相同再按价格升序:
list.sort((a, b) => { if (a.sales !== b.sales) return b.sales - a.sales; return a.price - b.price; });如果比较字段是字符串,需要用localeCompare,特别是中文排序:
list.sort((a, b) => a.name.localeCompare(b.name, 'zh-Hans-CN'));2.4 数组去重:不同场景用不同方案
数组去重是前端面试题里的“常青树”,业务里也经常遇到。最简单的是用Set:
const unique = [...new Set([1, 2, 2, 3, 3, 4])]; // 结果是 [1, 2, 3, 4]注意,Set对 NaN 的处理和===不太一样。Set内部使用SameValueZero算法,会把 NaN 视为相等,所以[NaN, NaN]用Set去重后只剩一个NaN,这反而是我们期望的行为。
真正麻烦的是对象数组去重。如果只是对象引用完全相同,Set也能用;但业务里通常是要按某个字段去重。比如后端接口返回了一批数据,里面有重复的id,我们要保留第一次出现的记录,这时候用Map:
const seen = new Map(); const uniqueList = list.filter((item) => { if (seen.has(item.id)) return false; seen.set(item.id, true); return true; });如果要按多个字段判断“同一条记录”,可以把字段组合成 key:
const seen = new Map(); const uniqueList = list.filter((item) => { const key = `${item.id}-${item.type}`; if (seen.has(key)) return false; seen.set(key, true); return true; });去重思路要灵活,先搞清楚“重复”的定义是什么,再选合适的数据结构。
3. 真实业务场景:从接口数据到页面渲染的常见链路
3.1 场景一:按条件筛选数组并展示
一个非常典型的业务场景是搜索筛选。比如页面上有一个输入框,用户输入关键字,前端从接口返回的完整列表里实时过滤出匹配项。
假设数据是这样一个结构:
const vehicleList = [ { plateNumber: '京A12345', owner: '张三', type: '小型车' }, { plateNumber: '沪B67890', owner: '李四', type: '大型车' }, // ... ];输入车牌关键字时,需要筛选出plateNumber中包含该字符的数据。这里的核心方法是filter+includes:
const keyword = '京A'; const filtered = vehicleList.filter((item) => item.plateNumber.includes(keyword));很多同学会觉得这很简单,但实际业务里会延伸出几个细节:
大小写问题。车牌里字母大小写不敏感,includes是区分大小写的,所以更稳妥的写法是统一转成大写或小写再比较:
const normalizedKeyword = keyword.trim().toUpperCase(); const filtered = vehicleList.filter((item) => item.plateNumber.toUpperCase().includes(normalizedKeyword) );多字段搜索。有时候用户输入的可能是车主姓名的一部分,也可能是车牌的一部分。这时候可以用一个“组合关键字”的方式,把所有可搜索字段拼起来:
const filtered = vehicleList.filter((item) => [item.plateNumber, item.owner, item.type].some((field) => field.toUpperCase().includes(normalizedKeyword) ) );我把这个逻辑封装成了一个工具函数,项目中所有类似的搜索过滤都复用它:
function filterByKeyword(list, keyword, fields) { const normalized = keyword.trim().toLowerCase(); if (!normalized) return list; return list.filter((item) => fields.some((field) => String(item[field]).toLowerCase().includes(normalized)) ); }3.2 场景二:数组分割、截取与显示包含某一字符的元素
另外一个常见需求是“把数组分割成几段”或者“从数组里提取所有包含某个字符的元素”。
比如聊天记录里,我们需要把所有包含某个标签的消息挑出来:
const messages = ['订单已发货', '退款申请中', '客服已回复', '订单已完成']; const orderRelated = messages.filter((msg) => msg.includes('订单'));再比如分页效果,前端一次性拿到全部数据,需要按每页 10 条分割:
function paginate(list, pageSize = 10) { const result = []; for (let i = 0; i < list.length; i += pageSize) { result.push(list.slice(i, i + pageSize)); } return result; }此时slice的区间是[, ),也就是包含起始位置、不包含结束位置。很多人记混了slice和splice:slice不会修改原数组,splice会修改原数组。业务里大多数时候用slice就够了。
如果你需要按某个字符分割字符串变成数组,那是split的活,注意别和splice混了。
3.3 场景三:数组与算法——树状数组、前缀和在前端的用武之地
看到“树状数组”这个热词,很多做业务的前端可能会觉得这是算法竞赛才用的东西。但我可以负责任地说,如果你的项目里涉及大量区间求和、频繁更新某一位数据,用普通数组每次遍历求和的代价是 O(n),数据量一大页面就会卡。比如后端返回了一份连续多天的报表数据,前端需要频繁展示某一段日期的累计值,这时候树状数组就能派上用场。
树状数组的核心是维护一个tree数组,支持两个操作:
add(index, delta):把第 index 个位置增加 deltasum(index):求前 index 个元素的和
以长度为 16 的序列为例,查询sum(11)时,不是从 1 加到 11,而是通过二进制拆分成若干段统计,复杂度降为 O(log n);单点修改add(3, x)也只需要更新包含第 3 个位置的树节点,同样 O(log n)。
class FenwickTree { constructor(n) { this.tree = new Array(n + 1).fill(0); } add(index, delta) { while (index <= this.tree.length - 1) { this.tree[index] += delta; index += index & -index; } } sum(index) { let result = 0; while (index > 0) { result += this.tree[index]; index -= index & -index; } return result; } }实际项目中如果数据量只有几百上千条,老老实实用reduce就行,没有必要为了炫技引入树状数组。但如果你在处理大型表格、实时统计仪表盘这类高频更新的场景,这个结构值得放进工具箱。
3.4 场景四:动态数组与海量数据渲染
JavaScript 的数组本身就是动态的,push、pop在数组末尾操作性能最好,unshift、shift在头部操作会让所有元素索引移动,性能较差。在数据量大的循环里,尽量不要在数组头部插入数据,要保证顺序的话可以先push完再reverse。
还有一个容易被忽略的点:前端拿到大量数据时,在主线程里做复杂的数组遍历和渲染,页面会掉帧甚至白屏。这时候可以用Web Worker把数据处理任务扔到后台线程。
比如上传大文件时,前端要把文件切片,然后对每个切片做 hash 计算,再推进上传队列。这个过程如果全在主线程做,用户会明显感觉到页面“卡住了”。把切片和 hash 计算的逻辑放进 Worker 后,主线程只负责接收结果和更新 UI:
const worker = new Worker(new URL('./upload.worker.js', import.meta.url)); worker.postMessage({ file, chunkSize: 1024 * 1024 }); worker.onmessage = (e) => { // 拿到分片数组,开始逐片上传 };数组本身不是性能瓶颈,瓶颈在于你在什么线程、用什么复杂度在操作它。这句话记住了,处理大数据量时能少踩很多坑。
4. 前端数组的引用陷阱与跨端传参
4.1 组件间传参,数组被误改的教训
Vue 和 React 项目里都有一个经典问题:父组件把一个数组传给子组件,子组件内部做了修改,结果父组件的数据也变了。
根本原因是数组是引用类型,子组件拿到的是同一个引用。比如 React 中:
// 子组件内部 props.list.push(newItem); // 直接改原数组在 React 18 严格模式下的开发环境,这个问题尤其难排查,因为push不会触发视图更新,但数据已经悄悄被改了。正确的做法永远是先拷贝再修改:
const newList = [...props.list, newItem];Vue 里面 “数组响应式” 的坑也属于这一类:直接通过索引修改数组元素(this.list[0] = xxx)在某些版本里不会触发视图更新,所以 Vue 提供了Vue.set或者要求用splice方法。现在 Vue 3 用 Proxy 重写了响应式,但很多旧代码里还留着这些写法,理解原理才能兼容各种老项目。
4.2 数组转字符串,常用姿势和深坑
数组转字符串看似简单,业务场景却非常多:接口需要逗号分隔的 id 列表、把数组作为 URL 参数传递、存储到localStorage里。
三种常见方式:
const arr = [1, 2, 3]; arr.toString(); // "1,2,3" arr.join(','); // "1,2,3" JSON.stringify(arr); // "[1,2,3]"toString本质上是join(',')的别名。如果你的元素里本身含有逗号,比如['a,b', 'c'],toString()会得到"a,b,c",看起来没问题但解析回去就乱了。这种场景建议用JSON.stringify或者约定一个不常用的分隔符。
从字符串转回数组时,split是最常用的。但如果你是用JSON.stringify存进localStorage,取出来时要记得JSON.parse。
有一回我在存一组 id 时用了toString(),数据变成了"1,2,3",取出后用split(',')转回来,本来没问题。结果产品改需求,id 变成了类似"A-1"这样的字符串,整个解析就崩了。后来我就统一了:凡是跨接口、跨存储的数组传递,一律用 JSON 序列化,格式清晰,不怕特殊字符。
4.3 类数组、二维数组与“看起来像数组但不是数组”的家伙
前端实际执行环境里有一种非常常见的“类数组”:arguments、NodeList、字符串。
它们的共性是:有length属性,可以通过下标访问,但没有数组的方法。所以想对它们用map、filter,得先转成真数组:
const args = Array.from(arguments); const nodes = Array.from(document.querySelectorAll('.item'));不转的后果是,直接在 NodeList 上调用forEach在某些旧环境里也能跑,但map就不行,报错信息还很难看懂。
二维数组在前端里的场景多为矩阵或表格数据的扁平化处理。热词里的 C++ 多维数组和指针,在 JavaScript 里对应的是数组的数组。比如一个 3 行 4 列的表格:
const matrix = [ [1, 2, 3, 4], [5, 6, 7, 8], [9, 10, 11, 12], ];要遍历所有元素可以双重循环,也可以用flat()把二维数组变成一维数组。默认flat()只展开一层,多层嵌套要传深度flat(Infinity)。
flatMap是map后再flat(1)的合成操作,比如把一组订单里的商品明细拉平:
const orderProductList = orders.flatMap((order) => order.products);这类“看起来绕一下”的写法,在数据聚合时能省不少中间变量。
5. 面试题视角:2026 年数组考点到底在考什么
5.1 手写实现:考的是对底层细节的理解
这两年前端面试题的趋势很明显:直接问 API 怎么用的少了,让人手写实现核心方法、或者“基于某个 API 说原理”的多了。数组这一块的高频手写题有:
- 手写
reduce - 手写
flat/ 扁平化数组 - 手写深拷贝
- 手写去重到“既能去重又能统计次数”
- 手写数组乱序(洗牌)
以reduce为例,手写实现的关键点是回调参数顺序、初始值缺省时的行为、空数组报错逻辑:
function myReduce(arr, callback, initialValue) { let accumulator = initialValue; let startIndex = 0; if (initialValue === undefined) { if (arr.length === 0) { throw new TypeError('Reduce of empty array with no initial value'); } accumulator = arr[0]; startIndex = 1; } for (let i = startIndex; i < arr.length; i++) { accumulator = callback(accumulator, arr[i], i, arr); } return accumulator; }写清楚这些细节,说明你不仅用过reduce,还理解它的实现思路,面试评价会好很多。
5.2 经典算法题:三个数组最大乘积、两数之和与背包思想
热词里有一条“三个数组最大的乘积”,这个题目看起来是数学题,其实考察的是分类讨论和数组遍历。
给定一个数组,找三个数乘积最大。常规思路是先排序,比较两个最大正数和最大负数的情况:
function maxProduct(nums) { nums.sort((a, b) => a - b); const n = nums.length; return Math.max( nums[n - 1] * nums[n - 2] * nums[n - 3], nums[0] * nums[1] * nums[n - 1] ); }为什么第二种情况是nums[0] * nums[1] * nums[n - 1]?因为如果数组里有两个很大的负数,它们相乘会得到正数,再乘上最大的正数,可能比三个正数的乘积还大。这类题目面试官不是真的想让你背答案,而是想看你能不能把边界情况列全。
热词里还有一条“已知固定数值,确定数组中的哪些数据和等于固定值”。这其实是一个子集求和问题。如果数据量小,可以用回溯枚举所有组合;如果数据量大,就是动态规划或背包思想。前端面试里通常不会要求最优解,但要求你至少能写出递归 + 剪枝的版本,并且说清楚时间复杂度。
这些题刷一遍的意义不在于真的在工作中如此用,而在于锻炼“把业务需求抽象成数组问题”的能力。我这几年写业务,最复杂的场景往往就是把一个模糊的产品需求翻译成数组变换。
5.3 会写代码也要会表达:技术选型阶段怎么讲
面试题后面其实还有一层隐藏考察:你能不能讲清楚“为什么选这个方案”。
比如面试官问:“要对一个数组去重,你会怎么做?”如果你能这样答,面试官体感会完全不同:
先用Set做基本类型去重,因为写法最简洁、性能也够;如果数组很大、类型复杂,再考虑用Map按字段去重;如果去重的判断条件是深层次属性组合,就需要写一个通用的uniqueBy(list, keyFn)工具函数。同时补充一句:去重前要明确“重复”的定义,是按引用、按内容还是按某个字段,这个前提比去重方法本身更重要。
这种回答方式在工作中同样适用。写代码不是把功能做出来就完了,你总得面对 code review,也得面对同事问你“这段逻辑什么意思”。能把选择理由讲清楚,说明你真的理解它。
结束语(个人体会)
我把这些数组操作的思路整理成了一个小工具函数库,里面有uniqBy、filterByKeyword、paginate、sumBy、groupBy这些每天都在用的函数。回头翻看时发现,绝大部分复杂业务逻辑,拆到最后就是几个数组方法的组合。所以我对新人的建议从来都是:别急着上框架,先把数组、对象这些基础数据结构玩透。你在业务里看到的所有“高级技巧”,底层都是这些基础能力的组合。
最后再分享一个小技巧:调试数组操作时,别只盯着最终结果,多用console.log把每次map、filter、reduce之后的中间状态打出来看。很多看起来很玄乎的问题,其实只是中间某一步产生了一个空数组或undefined。把每一步都想清楚,数组这个东西,真没那么可怕。