JavaScript数组API全梳理:分类、返回值与避坑指南 在JavaScript里数组几乎是每天打交道最多的数据结构没有之一。接口返回的列表要处理、前端要筛选要排序、表格要做统计聚合各个环节绕不开的一个问题就是到底该调用哪个数组API更合适。我见过不少刚入门的同学学会一个forEach就走天下了循环里套if、套临时变量代码又臭又长也有经验丰富的同事偶尔把map和forEach记混或者把splice和slice写反排查半天才发现是自己动了原数组。这篇内容我打算把JS数组常用API从头到尾梳理一遍按操作意图分类讲清楚每个API的返回值、副作用和适用场景再配上几个我实际踩过的坑希望能帮你把这块基础能力真正焊牢。1. 数组API很多先从分类和总览下手1.1 为什么值得专门花时间梳理数组API很多人写JS的时候数组操作用得最多的就是push和forEach。但实际业务场景里数据处理往往是一连串的管道操作先筛选掉无效数据再映射成显示格式最后做分组统计。如果只会用for循环加一堆临时变量逻辑一复杂就很容易出错而且代码可读性很差。我自己的体会是把数组API按“操作意图”梳理清楚之后大部分数据处理场景其实都能用一行链式调用解决调试起来也直观得多。这不是炫技而是让代码意图变得明确的必要手段。1.2 我习惯按“操作意图”而不是“API名字”分类学习数组API最忌讳挨个背名字。你会发现名字相近的API行为差异极大比如slice和splice就差一个字母语义完全反过来而有些行为相似的API比如map和forEach返回值又完全不同。所以我更推荐把API按照“我想对数组做什么”来分类大致可以分成六组增删改push、pop、shift、unshift、splice主要改变原数组查询判断indexOf、includes、find、findIndex、some、every遍历映射forEach、map、filter、flatMap聚合统计reduce、reduceRight顺序调整sort、reverse转换生成slice、concat、join、Array.from、Array.of、fill、flat分类之后你会发现记忆负担小了很多因为同一类API的返回值和使用场景往往是强相关的。比如查询判断类API要么返回数字下标要么返回布尔值要么返回第一个命中的元素而遍历映射类API中forEach返回undefinedmap返回新数组filter返回筛选后的新数组。带着这种框架去看文档效率会高很多。1.3 常用数组API总览表一屏分清返回值和副作用我把最常见的数组API列成一张总览表包含返回值、是否改变原数组以及一句话说明。这张表我建议你保存下来写代码拿不准的时候扫一眼远比翻完整篇MDN文档快。API返回值是否改变原数组核心用途push新数组长度是尾部追加一个或多个元素pop被删除的元素是删除尾部一个元素shift被删除的元素是删除头部一个元素unshift新数组长度是头部插入一个或多个元素splice被删除的元素数组是插入、删除、替换任意位置slice截取出的新数组否获取子数组concat合并后的新数组否连接多个数组indexOf下标找不到为-1否查询元素位置不能识别NaNincludes布尔值否判断是否包含某个元素可识别NaNfind第一个命中的元素找不到为undefined否按条件取第一个元素findIndex第一个命中元素的下标找不到为-1否按条件取下标filter筛选后的新数组否按条件筛选全部元素map等长新数组否一对一的映射转换forEachundefined否单纯的遍历执行some布尔值否是否存在一个满足条件的元素every布尔值否是否所有元素都满足条件reduce累加后的结果否聚合统计、分组、展平sort原数组本身是原地排序reverse原数组本身是原地反转顺序flat展平后的新数组否降低嵌套维度fill原数组本身是用固定值填充join字符串否把数组拼成字符串这张表里最需要留意的是“是否改变原数组”这一列。很多线上问题都出在大家默认某个API会返回新数组结果它把原数组改了或者反过来以为改了原数组结果没改。弄清这一列基本能避开一半的坑。2. 遍历与筛选forEach、map、filter、find这么选2.1 forEach老老实实执行回调但拿不到返回值forEach是最普通的遍历API它对数组的每个元素执行一次回调函数回调参数依次是当前元素、当前下标、数组本身。日常最常见的用法是用来打印日志、发送埋点或者逐个操作DOM节点。const userList [ { name: 用户A, age: 24 }, { name: 用户B, age: 18 }, { name: 用户C, age: 30 } ]; userList.forEach((user, index) { console.log(第${index 1}位${user.name}${user.age}岁); });这里要注意一个关键点forEach的返回值永远是undefined。这意味着你没法用return把它处理后的结果带出去也没办法直接链式调用比如forEach(...).map(...)。如果你发现自己需要把遍历结果收集成一个新数组那大概率该用map而不是forEach。还有一个老生常谈但很实用的提醒forEach不支持break、continue和return提前退出。即使你在回调里写了return也只是结束当前这一轮循环本身不会停。早期我写前端需求时想在遍历中找到符合条件的第一项就跳出用forEach写了半天发现没用最后才明白它压根没有终止机制。如果你有这个诉求考虑用for循环、find或some来替代。2.2 map一对一映射返回等长新数组map做的事情很纯粹把原数组的每个元素映射成一个新值最后返回一个长度相同的新数组。它不会改动原数组。很多人容易和forEach混淆但判断标准很简单你到底需不需要拿到处理后的结果。const scores [88, 45, 72, 96]; const doubleScores scores.map(score score * 2); // doubleScores: [176, 90, 144, 192] // scores 原数组不变map的回调函数必须写成有返回值的函数。最常见的低级错误是回调里用了大括号却忘了写returnarr.map(item { item.id })这样每个元素都返回undefined结果就是得到一整个undefined数组。我排查过不少同事的值发现哪里多了个空数组基本就是这个问题。另一个容易忽略的细节如果数组里的元素是对象map返回的新数组里元素对象本身还是原数组里那个对象的引用。比如const users [{ name: 用户A, age: 20 }]; const newUsers users.map(user { user.age 1; return user; });这段代码虽然返回了新数组但newUsers和users里的对象其实是同一个引用。你改newUsers里的user.age原数组users里的对应对象也会变。如果不想影响原数组务必在map回调里先做浅拷贝比如return { ...user, age: user.age 1 }。2.3 filter与find一个筛选全部一个只要第一个filter的核心逻辑是把回调返回值为真truthy的元素保留下来组成新数组返回。它非常适合做筛选比如从订单列表里找出金额大于100的记录从字典表里清除无效状态。const orders [ { id: 1, amount: 80 }, { id: 2, amount: 150 }, { id: 3, amount: 260 } ]; const bigOrders orders.filter(order order.amount 100);filter返回的永远是一个新数组即使没有任何元素通过筛选返回的也是空数组而不是null或undefined。这个特性让它很适合链式调用例如list.filter(condition).map(...)。find的定位和filter完全不同。它按条件查找但只返回第一个命中的元素本身如果没有任何元素满足条件返回undefined。它和filter最大的区别就是返回值的“粒度”filter给你一堆find只给你一个。const taskList [ { id: 1, done: false }, { id: 2, done: true }, { id: 3, done: false } ]; const firstDoneTask taskList.find(task task.done); // { id: 2, done: true }看代码的时候你会发现find的使用场景非常明确比如按id查某条记录、找第一个生效的配置项。如果只需要下标还有一个findIndex用法类似但返回的是下标找不到返回-1。我个人的习惯是只要条件判断命中而且只要一个结果优先用find或findIndex不要用filter再取[0]这样性能更好语义也更清楚。3. 聚合与判断reduce、some、every、includes的使用细节3.1 reduce万能聚合函数但必须理解初始值reduce是数组API里最强大也最容易劝退的一个。它的回调接收四个参数累加器、当前元素、当前下标、原数组。同时它还可以接受一个初始值。接触reduce时我最建议大家先吃透两点回调函数必须返回下一次累加器要用到的值第二个参数初始值决定了第一轮累加器到底是什么。最常见的求和示例长这样const nums [10, 20, 30]; const total nums.reduce((acc, cur) acc cur, 0); // 0 10 10 // 10 20 30 // 30 30 60 // total 60注意初始值0在这里很关键。如果省略初始值reduce会把数组的第一个元素当成初始累加器从第二个元素开始遍历。对于求和来说结果通常一样但对于像对象分组这种场景省略初始值会直接抛错因为第一轮acc是数组首元素而不是对象。我实际用过最多的reduce场景是分组统计。比如要把一组数字按奇偶分成两组const data [1, 2, 3, 4, 5, 6]; const grouped data.reduce((acc, num) { const key num % 2 0 ? even : odd; acc[key] acc[key] || []; acc[key].push(num); return acc; }, {}); // { odd: [1, 3, 5], even: [2, 4, 6] }这里每一轮都要记得返回acc否则下一轮拿到undefined直接报错。可以看到reduce本质上就是把“循环临时变量”的过程抽象成纯函数式的累积。只要你能把每次迭代要维护的状态整理清楚它几乎能做任何聚合操作。最后提一下reduce的空数组风险。如果数组为空且没有提供初始值reduce会抛TypeError: Reduce of empty array with no initial value。所以不管数组是否可能为空都强烈建议提供初始值。这个坑我踩过一次之后就再也没有省略过第二个参数。3.2 some与every别小看空数组的返回值some用来判断数组里是否存在至少一个满足条件的元素every则要求所有元素都满足条件。它们都返回布尔值都支持短路的逻辑即一旦确定结果就会停止遍历。const nums [2, 4, 6, 8]; const hasOdd nums.some(num num % 2 ! 0); // false const allEven nums.every(num num % 2 0); // true这两个API的语义清楚问题不大但有一个边界情况很容易写错空数组。空数组调用some永远返回false因为不存在任何元素空数组调用every永远返回true因为“所有元素都满足”在数理逻辑上对空集默认成立。这个行为在代码审查时经常被质疑但是规范就是这么定义的你只需要记住它别在空数组场景下过度依赖。实际开发中some很适合用来做权限判断或存在性校验。比如判断某组选项里是否有任意一项被选中用some比filter().length 0语义更清晰性能上也更好因为some可以在命中第一个时就停止。3.3 includes与indexOf判断“存在”的两个选择includes和indexOf都能用来判断数组里是否存在某个元素。区别最明显的地方在于NaN和语义清晰度。indexOf用严格相等比较它无法识别NaN因为NaN ! NaN。如果你这样写const arr [1, 2, NaN]; console.log(arr.indexOf(NaN)); // -1 console.log(arr.includes(NaN)); // trueindexOf返回-1看起来像是NaN不存在于数组中实际上它是存在的只是indexOf识别不了。includes则能正确返回true因为它内部使用了更合适的等价算法。另一个区别是返回值。indexOf返回下标如果你真的需要知道元素位置就选它如果只关心“存不存在”选includes读起来更自然。这两个API还都支持第二个参数fromIndex用来指定开始查找的位置。需要注意的是fromIndex为负数时会从数组末尾往前数偏移量再开始查找这跟很多人的直觉不太一样具体表现我建议你在控制台里试一次。3.4 用reduce做更复杂的分组统计前面提到过基本的奇偶分组这里再展开一点。我在处理某个模拟项目的数据报表时需要把一批交易记录按日期分组并统计每天的金额总和。当时直接用reduce写了一行链式逻辑非常流畅const records [ { date: 2025-01-01, amount: 100 }, { date: 2025-01-01, amount: 200 }, { date: 2025-01-02, amount: 150 } ]; const summary records.reduce((acc, record) { const { date, amount } record; acc[date] acc[date] || { totalAmount: 0, count: 0 }; acc[date].totalAmount amount; acc[date].count 1; return acc; }, {});这段代码把同一天的交易聚合在一起同时统计了笔数和总额。相比用普通for循环加三个临时变量reduce版本的状态更集中后续维护也好理解。但要强调的是reduce最大的陷阱在于回调函数一旦忘记return结果就会立刻变形。我建议在编写时先把初始值写出来然后每一步都在脑子里过一遍下一次进入回调时acc到底是什么。4. 增删改与顺序调整push、pop、splice、slice、sort的取舍4.1 为什么尽量不要用shift和unshift从数组末尾增删元素用的是push和pop从数组头部增删元素用的是unshift和shift。这两个操作虽然也能用但底层实现差异很大。数组在内存中是一段连续的存储空间push只是在末尾追加一个元素非常轻量unshift则要把所有已有元素整体向后挪一位也就是所有索引都要重新计算。这个操作的时间复杂度是O(n)数据量一上来性能差异就会非常明显。举个我自己做过的性能测试例子往一个有十万个元素的数组头部循环插入元素用unshift至少比用push慢一个数量级而且过程中还可能触发多次内存扩容。如果你的业务需要频繁在头部插入、在尾部删除那么原生数组并不是特别合适的结构。简单场景下可以先用push后reverse或者干脆维护两个数组更极端的场景可以考虑链表结构。总之shfit和unshift不是不能用而是要有意识地在数据量较大的时候回避。4.2 splice与slice改动原数组和纯截取的边界splice和slice这对名字可能是JavaScript里最容易被搞混的组合。先说splice它用于插入、删除或替换数组元素会直接修改原数组返回值是被删除的元素组成的新数组。它的参数是(起始位置, 删除个数, 要插入的元素...)。const arr [a, b, c, d, e]; const removed arr.splice(1, 2); // removed: [b, c] // arr: [a, d, e]如果只是想替换可以这样写const arr [a, b, c]; arr.splice(1, 1, x, y); // arr: [a, x, y, c]slice则是纯读取操作它返回从start到end不含end之间的元素组成的新数组原数组完全不变。它的参数和splice不同slice只有两个参数start和endend省略时表示到数组末尾。它还支持负数参数-1表示最后一个元素。const arr [a, b, c, d, e]; const sub arr.slice(1, 3); // sub: [b, c] // arr: [a, b, c, d, e]我的建议是在代码里看到slice就当作“复制一部分”看到splice就当作“动原数组”这俩永远别混用。既然slice不会改变原数组它很适合用来做数组浅拷贝const copy arr.slice()效果和[...arr]类似。4.3 sort排序不传比较器等于白排sort可能是实际开发中“惊喜”最多的API原因是它默认不按数字大小排序而是把每个元素转成字符串然后按Unicode编码顺序排序。于是[30, 1, 8, 21, 45].sort()的结果很反直觉const nums [30, 1, 8, 21, 45]; nums.sort(); // [1, 21, 30, 45, 8]看到这个结果千万别慌这是默认行为。要按数字从小到大排必须传入比较器函数nums.sort((a, b) a - b); // [1, 8, 21, 30, 45]比较器的规则是如果返回值小于0a排在b前面返回值大于0a排在b后面返回0位置保持不变。理解了这个规则复杂排序也很好写。比如对对象数组按某个字段从大到小排就是arr.sort((a, b) b.age - a.age)如果还想加第二个排序条件可以再加一次比较逻辑。还要记住sort是原地排序它直接修改原数组同时返回修改后的原数组引用。如果你不想改变原数组请先复制一份const sorted [...arr].sort(...)。另一个值得了解的点是现代V8引擎里的sort是稳定排序也就是说如果两个元素比较结果相等它们原来的相对顺序不会乱。这在做多字段排序时很有用你可以先按次要字段排再按主要字段排顺序就能保持住。4.4 reverse与join做简单顺序反转和拼接reverse用来反转数组元素的顺序同样是在原数组上操作返回值也是原数组本身。它的行为很直观第一个变最后一个最后一个变第一个。需要注意的就是它也是原地修改要保留原数组就先拷贝一份。const arr [1, 2, 3, 4]; arr.reverse(); // arr: [4, 3, 2, 1]join则是把数组的所有元素拼接成一个字符串默认用英文逗号分隔也可以传入自定义分隔符。它不改动原数组非常安全。配合split可以完成字符串和数组之间的互转这是前端处理CSV、URL参数时经常用到的组合技const tags [前端, JavaScript, 数组]; const tagStr tags.join( | ); // 前端 | JavaScript | 数组5. 进阶API与转换技巧flat、flatMap、Array.from、fill5.1 flat与flatMap一键展平嵌套数组flat用于把嵌套数组展平默认只展开一层传入Infinity可以完全展平。实际业务中接口返回的数据偶尔会出现多层嵌套结构比如分类树、评论回复楼层展平后再做统计就方便很多。const nested [1, [2, 3], [4, [5, 6]]]; console.log(nested.flat()); // [1, 2, 3, 4, [5, 6]] console.log(nested.flat(2)); // [1, 2, 3, 4, 5, 6] console.log(nested.flat(Infinity)); // [1, 2, 3, 4, 5, 6]flatMap则是在flat之前先执行一次map但只展平一层。它的语义可以理解成“先映射再拍平一层”。常见场景是处理那种一个元素会对应生成多个结果的映射比如把一句话拆成单词再收集起来const sentences [Hello world, JS arrays]; const words sentences.flatMap(sentence sentence.split( )); // [Hello, world, JS, arrays]flatMap和map加flat的区别在于flatMap只会展平一层而且它比分开调用更省事也不会产生不必要的中间数组。5.2 Array.from与Array.of生成数组的两种冷门手段Array.from能把类数组对象或可迭代对象转换成真数组。类数组对象最常见的就是arguments还有DOM查询结果NodeList。在没有Array.from之前大家得写Array.prototype.slice.call(arguments)来转换有了Array.from之后直接一行搞定。function collectArgs() { return Array.from(arguments).map(num num * 2); } collectArgs(1, 2, 3); // [2, 4, 6]Array.from的第二个参数是一个映射函数它会在转换的同时对每个元素做处理。这可以用来快速生成一段连续的数字数组const range Array.from({ length: 5 }, (_, index) index * 2); // [0, 2, 4, 6, 8]Array.of则是用来避免new Array(number)的歧义的。new Array(3)会创建一个长度为3的空数组而new Array(3, 4)才会创建包含3和4的数组。Array.of不管传几个参数都返回由这些参数组成的新数组行为保持一致const a Array.of(3); // [3] const b Array.of(3, 4); // [3, 4]5.3 fill与keys/entries快速占位与索引遍历fill用固定值填充数组的指定区间它会修改原数组也返回原数组。最常用的场景是初始化一个定长数组new Array(5).fill(0)在做动态规划的表格初始化或者渲染占位数据时都比较实用。const arr new Array(4).fill(false); // [false, false, false, false]但fill也有一个隐蔽的坑如果填充的值是对象那么所有位置都会引用同一个对象。比如const items new Array(3).fill({ name: 暂无 }); items[0].name 已填写; console.log(items[1].name); // 已填写 console.log(items[2].name); // 已填写原因就是fill不会帮你深拷贝它只是把同一个对象引用填进了每个位置。需要每个位置都独立的话应该用Array.from加映射函数Array.from({ length: 3 }, () ({ name: 暂无 }))。entries、keys、values则是数组的迭代器方法。entries返回一个包含[key, value]的迭代器最典型的用法是配合for...of遍历所有索引和值const letters [a, b, c]; for (const [index, letter] of letters.entries()) { console.log(index, letter); }这三个API虽然日常用得不算频繁但当你需要对数组做逐项索引遍历或者需要把数组转成Map时它们会非常趁手。6. 常见问题与排查技巧实录6.1 forEach和map为什么会出现“没生效”和“都是undefined”排查经验里这两个问题加起来能占数组相关bug的四成。先说“forEach没生效”。很多人以为在forEach回调里给数组元素重新赋值就能改变原数组里的元素const nums [1, 2, 3]; nums.forEach(num { num num * 2; // 没用num只是当前元素的副本 }); console.log(nums); // [1, 2, 3]基本类型的值在回调里修改完全不会影响原数组。如果你真想改要么用map生成新数组后赋值回去要么直接用下标改nums.forEach((num, index) { nums[index] num * 2; }); console.log(nums); // [2, 4, 6]再说“map出来全是undefined”。我在前面提到过这通常是因为map回调里用了大括号但漏写了return。JS里x x * 2是隐式返回但x { x * 2 }没有任何返回值。排查时先看回调是否用了一对大括号再看里面有没有return这能解决绝大多数问题。6.2 数组去重到底有几种写法去重是我在面试和实际开发里都经常遇到的问题。最简单的是Set去重const arr [1, 2, 2, 3, 4, 4]; const unique [...new Set(arr)]; // [1, 2, 3, 4]Set去重用的是SameValueZero比较算法它能识别NaN所以[1, NaN, NaN]去重后是[1, NaN]。但要注意Set去重对对象是引用比较两个内容完全相同但引用不同的对象不会被去重。如果需要按某种字段去重我会用reduce或Map手动实现const items [ { id: 1, name: A }, { id: 2, name: B }, { id: 1, name: A-2 } ]; const uniqueById Array.from( new Map(items.map(item [item.id, item])).values() ); // 保留后出现的 { id: 1, name: A-2 }这个写法的核心是利用Map的key唯一性把id作为key整个对象作为value然后用Array.from取values。思路很简单但很有用。6.3 稀疏数组forEach、map、Array.from的行为差异JS数组里可以有“空位”比如const arr [1, , 3]中间那个位置就是空位。空位和undefined是不同的概念这导致各个API对空位的处理方式完全不同const sparse [1, , 3]; sparse.forEach(item console.log(item)); // 只输出 1 和 3空位被跳过 const mapped sparse.map(item item * 2); // [2, , 6]空位仍然保留 const from Array.from(sparse); // [1, undefined, 3]空位被转成 undefined这个差异最坑的地方在于你可能会觉得map结果里有个undefined元素是bug其实那是空位的保留行为。如果希望空位变成正常的undefined值用Array.from或者扩展运算符处理一下即可。这个问题在线上的表现通常是无缘无故的“undefined值”但从数据源头去排查时却很有可能找不到因为它压根不是真实存在的元素。6.4 大数据量时的性能经验数组API用起来方便但性能上还是有一些规律。简单说读取类API比如map、filter、slice的时间复杂度是O(n)只要不是嵌套调用两三层以内基本都能接受。真正担心的是O(n)操作被放在循环里变成O(n^2)。典型例子是循环里套shift或unshift前面已经说过头部操作本身是O(n)再套一层循环就直接爆炸。另一个经验是不要过度使用reduce去替代所有循环。reduce效率并不差但可读性在某些场景下确实不如普通的for循环。如果是几万条数据reduce完全没问题如果是几十万条需要精细控制性能的数据普通for循环通常比forEach和map更稳因为少了一层回调函数调用开销。不过话说回来绝大多数业务场景数据量都远没到需要手动优化循环的地步写清楚、写对比“显得性能好”更重要。还有一点关于链式调用arr.filter(...).map(...).reduce(...)看着很酷但实际上会创建多个中间数组。数据量小时无所谓数据量大时如果性能敏感可以适当合并成一次循环。判断标准很简单——先测再优化不要凭感觉改代码结构。最后的私人建议数组API这块内容单纯背熟列表并不难真正的价值在于踩过坑之后的总结。我个人的习惯是把上面那张总览表放在一个随手能打开的备忘文档里写代码前先判断题的类型我是要改原数组还是生成新数组是整个遍历还是只取一个是判断存在还是需要下标把这几个问题想清楚选择自然就冒出来了。这个内容如果后续要扩展我建议继续往迭代器、解构、性能优化方向深入因为数组操作真正复杂的地方还在后面。希望这篇梳理对你上手JavaScript数组能有一点实际的帮助。