ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

JS数组添加元素性能对比:push、unshift、splice等6种方法实战解析

JS数组添加元素性能对比:push、unshift、splice等6种方法实战解析 省流版如果你只想往数组末尾加元素无脑用push。如果你在循环里往头部插元素赶紧改成push 最后reverse()。今天这篇不打算搞那种“菜鸟教程”式的罗列我会把 6 种添加元素方法的原理、返回值、性能差异和实际场景里的隐藏坑一次讲透尤其是那些写业务代码时最容易踩的雷。先说清楚本文的适用范围纯前端 JavaScript、Node.js 环境数组指的都是原生Array不涉及 TypeScript 的元组约束也不讨论 ArrayBuffer 这类二进制数组。网上讲数组方法的文章很多但大多停留在“怎么用”的层面对“为什么这个方法慢”“什么场景下该用哪个”讲得很少这恰恰是实际开发中最有价值的点。以我自己的经验很多性能问题并不是算法多复杂而是数组操作方式选错了。比如在一个请求处理逻辑里向数组头部循环插入数据数据量一大页面直接就卡住了。这种坑光看 API 文档是看不出来的只有真正做过性能排查才会留意到。下面我按使用频率和性能特征把 6 种方法逐个拆开讲。1. 先说结论6 种方法各有各的脾气在进入细节之前先给一个总览表格。我平时做技术方案时习惯先列结论再讲原理这样大家带着结论去看后面的分析会更有针对性。方法是否改变原数组返回值插入位置时间复杂度典型场景push改变新数组长度末尾O(1) 均摊循环填充、栈操作unshift改变新数组长度开头O(n)极少使用需谨慎splice改变被删除元素数组任意位置O(n)中间插入、替换、删除concat不改变新数组末尾O(n)合并数组、函数式编程展开运算符不改变新数组任意位置O(n)复制数组、React 状态更新索引赋值改变赋值的元素任意位置O(1) 或 O(n)稀疏数组、快速覆盖这个表格是结论不是全部。比如splice的复杂度插入位置越靠前越慢因为后面的元素都要往后挪。concat和展开运算符虽然看起来都是“创建新数组”但底层实现细节差异也会影响实际性能。后面我逐一展开。还有一个容易被忽略的点返回值。push和unshift返回的是新数组的长度不是数组本身。所以那种想链式调用的写法arr.push(1).push(2)是会直接报错的因为push(1)返回的是数字而不是数组。这个错误我在 code review 里见过不只一次。2. 逐个拆解6 种添加元素方法的原理与避坑2.1 push后端追加的性能标杆push可能是 JavaScript 里最常用的数组方法函数签名是arr.push(element1, ..., elementN)可以一次性传多个参数。它会直接修改原数组把新元素追加到末尾然后返回新数组的长度。const arr [1, 2, 3]; const result arr.push(4, 5); console.log(arr); // [1, 2, 3, 4, 5] console.log(result); // 5从性能上讲push在绝大多数 JavaScript 引擎里都是 O(1) 均摊复杂度的操作。原因在于数组在内存里通常是连续存储的往末尾追加元素时引擎只需要在已有的连续内存后面写入新值然后更新一下长度属性就行。如果预分配的内存不够了引擎会做一次扩容一般是按比例扩大容量然后把旧元素复制过去。因为扩容操作不常发生平摊下来每次push的代价很小。push适合什么场景最常见的就是循环里填充数据。比如从接口分批拉取数据然后不断把新数据追加到一个数组里const allData []; for (let page 1; page totalPages; page) { const pageData await fetchData(page); allData.push(...pageData); }这里有个小技巧allData.push(...pageData)把分批数据展开后一次性推入。不过如果pageData非常大展开运算符会导致调用栈溢出因为参数个数是有限制的。更稳妥的做法是直接用concat合并allData allData.concat(pageData);或者干脆写个循环逐个 push。实际开发中接口分页很少会出现单次上万条的情况所以大多数时候push(...pageData)没问题但要知道这个边界存在。push还有一个容易忽视的特性它可以同时接收多个参数这在性能上比多次调用push更好。因为每次函数调用都有开销如果你有 10 个元素要加入arr.push(e1, e2, ..., e10)比 10 次arr.push(e1)更快。2.2 unshift前端插入的“性能杀手”unshift的语法和push完全对称arr.unshift(element1, ..., elementN)向数组开头添加元素返回新数组长度。const arr [3, 4, 5]; const result arr.unshift(1, 2); console.log(arr); // [1, 2, 3, 4, 5] console.log(result); // 5但是这个方法的性能代价非常高。想象一下你排了一条长队现在有个人非要站到队伍最前面去那么原来排队的每一个人都得往后退一步。数组也是一样unshift会把现有的所有元素都往后移动一个位置再把新元素放到索引 0 的位置。这个过程的时间复杂度是 O(n)数组越长性能越差。我在实际项目和性能测试中踩过这个坑。当时是在一个实时日志系统里每条新日志到达时需要插入到列表最前面用unshift处理几百条数据时界面就开始掉帧了。后面改成push插入再整体反转展示或者用链表结构问题才解决。如果你确实需要频繁在头部插入数据有一个常见的优化思路用push把数据倒序插入末端最后再整体reverse()一次。这样每次插入都是 O(1)只花一次 O(n) 的反转成本。举个具体场景你要把用户的操作记录按时间倒序展示新记录显示在最上面const records []; // 假设时间是倒序新记录应该放最前面 for (const record of latestRecords) { records.push(record); // 先正序存 } records.reverse(); // 最后一次反转还有一种更彻底的方案是改用双向链表。不过链表在 JavaScript 里没有原生实现需要自己封装而且随机访问性能差所以只有在“频繁头部插入 很少随机访问”的场景下才值得这么做。2.3 splice指哪插哪的瑞士军刀splice是数组方法中功能最强大的一个它既能插入、又能删除、还能替换。函数签名是arr.splice(start[, deleteCount[, item1, item2, ...]])。start开始修改的位置索引deleteCount要删除的元素个数为 0 时表示只插入不删除item1, item2, ...要添加的新元素关键在于如果deleteCount为 0那么splice就变成了纯粹的插入方法const arr [1, 2, 5]; arr.splice(2, 0, 3, 4); console.log(arr); // [1, 2, 3, 4, 5]这里的splice(2, 0, 3, 4)意思是从索引 2 开始删除 0 个元素然后插入 3 和 4。等效于把 3 和 4 放在索引 2 的位置上原来的 5 被推到后面。splice的返回值是包含被删除元素的数组。如果只插入不删除返回的是空数组。性能方面splice的复杂度受插入位置影响。插入位置越靠前需要移动的元素越多。插入到中间位置时splice和unshift其实是类似的成本都需要把插入点之后的元素整体后移。只有插入到末尾时splice才接近push的效率但因为它还要处理参数解析和返回值实际还是会比push慢一些。deleteCount的语义有一个常见的坑如果你传入的deleteCount大于从start到数组末尾的元素数量它会删除到末尾为止不会报错。另外start支持负数表示从末尾开始算。比如arr.splice(-1, 0, 99)是在倒数第一个元素之前插入而不是在末尾追加。想用splice(-1, 0, item)实现“添加到末尾”是错的因为这时候插入位置是倒数第一和倒数第二之间。2.4 concat返回新数组的“拼接师”concat是另一种添加元素的方式arr.concat(value1, value2, ..., valueN)。它会把参数拼接到数组末尾然后返回一个新数组原数组不变。const arr [1, 2]; const newArr arr.concat(3, [4, 5]); console.log(arr); // [1, 2] console.log(newArr); // [1, 2, 3, 4, 5]这里有一个细节concat会自动把数组参数展开一层。传[4, 5]会展开成 4、5 两个元素加入而不是把整个数组作为一个元素。所以arr.concat([4, [5]])的结果是[1, 2, 3, 4, [5]]因为只有第一层被展开了。实际开发里我常用concat来做“无副作用”的合并操作尤其是在函数式编程风格或者 React/Redux 这种讲究不可变数据的场景中。比如 React 的 state 更新里往数组追加一项的正确姿势是setState(prevState ({ items: prevState.items.concat(newItem) }));这种做法保证原数组不被修改避免引发组件重渲染时的状态不一致问题。性能上concat的时间复杂度是 O(n)因为需要创建新数组并复制所有元素。这里的 n 是原数组长度加上参数的元素数量。所以如果原数组很长concat的性能会明显低于push。但它的优势在于纯粹性不污染原数据。在性能不敏感的函数式逻辑里这是很好的选择。2.5 展开运算符语法糖下的新数组展开运算符...在 ES6 里引入可以作用于任何可迭代对象数组当然没问题。用它添加元素的方式有两种// 末尾添加 const arr1 [1, 2, 3]; const newArr1 [...arr1, 4, 5]; // 开头添加 const newArr2 [0, ...arr1]; // 中间添加 const newArr3 [1, ...arr1, 9]; // 复制数组 const newArr4 [...arr1];和concat一样展开运算符也返回新数组原数组不变。但它比concat更灵活因为你可以把展开的元素放在任意位置而不只是末尾。性能和concat类似也是 O(n)需要遍历原数组并创建新数组。在 React 状态更新中我推荐优先用展开运算符因为它更直观setState(prev ({ items: [...prev.items, newItem] }));展开运算符还有一个优势是处理类数组对象很方便。比如把arguments对象、NodeList转成数组const argsArray [...arguments]; const divs [...document.querySelectorAll(div)];注意展开运算符只能展开一层。[...arr, [1, 2]]的结果是[..., [1, 2]]里面的[1, 2]不会继续展开。这和concat的行为一致。2.6 索引赋值冷门但有用严格来说索引赋值不算数组方法它就是一个语法操作arr[index] value。但因为它能在任意位置添加元素我把它算作第六种方式。const arr [1, 2, 3]; arr[3] 4; console.log(arr); // [1, 2, 3, 4]这种方式的特殊之处在于你可以把元素直接赋到任意索引。如果索引超出当前长度数组会自动扩展中间的空位会变成稀疏位empty slots而不是undefinedconst arr [1, 2, 3]; arr[5] 6; console.log(arr); // [1, 2, 3, empty × 2, 6] console.log(arr[3]); // undefined console.log(3 in arr); // false稀疏位不包含属性注意arr[3]访问结果是undefined但3 in arr是false这和显式赋值为undefined是不同的。这个差异在遍历时会有影响forEach、map、filter会跳过稀疏位而for...of则会把稀疏位当作undefined遍历。性能上给末尾索引直接赋值和push在底层很接近在 V8 里两者都是 O(1) 均摊操作。但push的名称更语义化而且能一次添加多个元素所以实际中直接用arr[arr.length] item的场景并不多。我见过有人用它来刻意制造稀疏数组做测试也有代码优化场景用它来避免函数调用开销但普通业务代码不推荐这么写可读性不好。3. 性能对比为什么 push 最快实测数据说话3.1 性能差异的底层逻辑要真正理解为什么不同方法性能差异大得从 JavaScript 引擎的数组存储机制说起。现代 V8 引擎对数组有两种基本存储方式快数组Fast Elements和字典模式Dictionary Elements。快数组对应连续内存存储。元素索引是连续的引擎可以通过索引直接计算出内存偏移量访问速度非常快。push往末尾追加元素时只要内存容量够就是一次简单的内存写入加长度更新。这就是 O(1) 的来源。当你在数组头部调用unshift时引擎不能简单地“往前写”因为索引 0 的位置已经是内存块的开头了。它必须把所有元素整体往后移动一个单位为新的头部元素腾出空间。数组长度越大需要移动的元素就越多这就是 O(n) 的来源。字典模式则是在数组变得稀疏时V8 会把存储结构降级成类似哈希表的结构。这种模式下元素的增删和访问都通过哈希计算完成每个操作都更慢但可以节省连续内存空间。如果频繁用索引赋值制造稀疏数组性能可能会意外劣化。所以性能差异的本质不是某个 API 的语法好坏而是引擎底层数据结构对操作的适配程度。push是在为“连续存储”设计的结构上做追加操作自然是最优的。3.2 不同场景下的实测对比纸上谈兵没有用直接跑数据。下面是一个简单的性能测试思路你可以在浏览器控制台或 Node.js 里跑const arr []; const start performance.now(); for (let i 0; i 1000000; i) { arr.push(i); } const end performance.now(); console.log(push 一百万次: ${end - start}ms);用同样的方式测unshift你会发现时间可能差一到两个数量级。在一台普通 M 系列芯片的机器上我实测的结果大致如下操作100 万次耗时相对 push 的倍率push 追加约 20ms1x索引赋值末尾约 22ms1.1xconcat 合并约 60ms3x展开运算符约 65ms3.2xsplice 末尾插入约 50ms2.5xsplice 头部插入约 600ms30xunshift 头部插入约 700ms35x需要说明这个数据受机器、引擎版本、数组初始大小、元素类型影响很大不同环境跑出来会有明显差异。但相对趋势是稳定的头部插入远慢于尾部插入创建新数组的操作比原地修改慢push是最稳的。元素类型也有影响。V8 对整数数组有专门的优化路径如果你测试的是字符串数组结果会不一样。所以在评估性能时最好用和你实际业务最接近的数据来做基准测试。3.3 性能优化建议基于上面的分析我总结几条可以落地的优化原则第一条循环里不要用unshift。如果需要头部插入考虑用push收集再reverse()。如果数据量特别大考虑换数据结构。第二条批量添加数据时优先使用多参数push。比如arr.push(1, 2, 3)比三次arr.push(1)、arr.push(2)、arr.push(3)快因为减少了函数调用和上下文切换的开销。第三条在不可变数据场景下如果数组很长且频繁更新concat和展开运算符的 O(n) 成本可能会成为性能瓶颈。这时可以考虑改用持久化数据结构如 Immutable.js或Record/Tuple提案但引入库要权衡项目体积。第四条不要让数组进入字典模式。避免使用超大索引如arr[1000000] 1制造稀疏数组否则后续所有数组方法的性能都会有明显劣化。第五条splice插入位置越靠前性能越差。如果你需要频繁在数组开头附近插入检查一下业务逻辑是否可以用“倒序存储”来规避。4. 常见问题与排查技巧实录4.1 常见问题速查表这里整理我平时看到频率比较高的几个坑对应排查思路也一起列出来。问题现象可能原因解决方案arr.push(1).push(2)报错push返回的是数组长度不是数组改为连续arr.push(1); arr.push(2);或使用concat用splice(-1, 0, item)想末尾追加但位置不对负数索引从末尾开始计-1 指向倒数第一个之前末尾追加直接用push或splice(arr.length, 0, item)展开运算符传入超大数组报 RangeError展开参数数量超过引擎限制改用concat或循环pushconcat后原数组没变但预期被修改concat返回新数组不改原数组接收返回值arr arr.concat(item)索引赋值后遍历出现空位超出长度赋值产生稀疏数组显式填充 undefined 或用push替代数组长度很大时unshift页面卡死头部插入需要移动所有元素改pushreverse或改用链表误以为arr[arr.length] item等效于push单元素场景等效但不够语义化用push更清晰性能也更好4.2 踩坑经验与进阶技巧第一个真实案例我之前处理过一个表格组件的排序功能。后端返回的数据已经排好序但前端需要在第一行插入一条“合计”记录。数据量大概几千行我用splice(0, 0, summaryRow)实现结果每次操作耗时 200ms 以上表格明显卡顿。后来改成把 summaryRow 存到单独的变量里渲染时拼到数组头部而不是真的往原数组里插性能问题就消失了。这个故事说明很多时候性能优化的关键不是更快的插入方法而是避免不必要的插入操作。第二个案例是状态管理库的使用。在项目里大量使用 Redux 时我遇到过一个问题每次往 state 里的数组尾部追加数据都用concat或展开运算符这本身没问题。但当数组长度从几百增长到几万时任何一次往数组头部插入的操作都会让整个应用卡住。排查后发现某处代码用了[newItem, ...state.items]这种写法一次操作就触发了 O(n) 复制。解决方案是把数组改成按最后插入索引倒序存储渲染时再反转这样新增操作就变成了末尾追加。还有一个关于稀疏数组的经典坑。Array.from({length: 10000}, (_, i) i * 2)这种方式创建的数组map、filter都能正常遍历。但直接写const arr new Array(10000)然后arr[5000] 1再用arr.map(...)遍历你会发现大部分位置被跳过了。这个特性在某些场景能用来做“稀疏占位”但更常见的是引发困惑。如果你需要填充默认值用new Array(n).fill(0)。push和concat还有一个微妙差别在函数式编程里很重要。push修改原数组产生了副作用而concat不会。如果你写的是纯函数比如function append(arr, item) { return arr.concat(item); // 纯函数无副作用 }这种写法在调试和测试时更安全因为你不用担心传入的数组被意外修改。同样的逻辑用push写就要小心外部变量的状态被污染。最后讲一个引擎优化的小知识。V8 对push有专门的优化但如果你在函数里混用不同类型的元素——比如先 push 整数再 push 字符串再 push 对象——引擎可能会把存储从 PACKED_SMI_ELEMENTS 降级到 PACKED_ELEMENTS导致所有操作变慢。虽然这是引擎内部实现可能随版本变化但我在实际开发中观察到保持数组元素类型一致对性能有可感知的影响。我在实际写代码时的习惯是默认用push需要不可变操作时用展开运算符或concat遇到任意位置插入时先评估业务能不能调整数据结构尽量避开splice和unshift做高频操作。这也算是踩了几年坑之后慢慢形成的直觉。
返回列表