
做前端这些年被问得最多的一类问题就是往数组里添加元素有哪几种写法很多人脱口而出push想一下再补一个unshift能说出六种以上的其实不多。更别提问一句“为什么unshift慢慢多少什么场景该选哪种”——能讲清楚的人更少。这篇文章就把 JS 数组添加元素的 6 种方法挨个拆开从语法、返回值、底层存储到性能实测再结合我自己的项目经验给出选型建议。内容适合初中级前端、准备面试的同学也适合正在做性能优化又不想盲目换数据结构的同行。1. 先分清两类操作原地修改与原数组不变1.1 为什么一定要先搞清楚“改没改原数组”数组添加元素的方法看似零散其实可以一刀切成两类一类会直接修改原数组另一类不会。先列个清单原地修改push、unshift、splice、arr.length ...、arr[index] ...返回新数组concat、展开运算符[...]这个分类不是面试炫技用的它直接决定了你在什么场景下敢用哪个方法。举个例子在 React 的 state 里管理列表如果你直接push就算数据变了组件也可能不更新因为 state 引用没变在函数式编程里副作用会引起各种隐蔽 bug。反过来在高频操作的内部逻辑里如果每加一个元素就复制一次整个数组内存和 GC 开销也会让你难受。所以每次写“加元素”之前先回答自己这个数组能不能被修改1.2 一个类比的快速理解把数组想象成一份贴在墙上的名单。push就是在名单末尾追加一个新名字原名单还在返回值是你重新数了一遍的总人数。unshift更像插队你要把所有人的名字往后挪一格再把新名字贴到最前面。concat和展开运算符就是完全不碰原来那份名单重新抄一份完整的名单新名字按你的要求出现在某个位置原来的名单保存不动。抄名单这种操作在人数少的时候很轻松人数一多每次都要把整个名单誊一遍自然就慢。这个类比能解释大部分性能差异原地操作用的是同一份名单不可变操作每次都造一份新名单头插和中间插入需要挪动名单上的人名挪动数量越多越慢。接下来逐个看具体方法。1.3 六种方法的基本行为一览方法语法示例是否改原数组返回值插入位置pusharr.push(item)是新 length尾部unshiftarr.unshift(item)是新 length头部splicearr.splice(i, 0, item)是被删除元素数组任意位置concatarr.concat(item)否新数组尾部展开运算符[...arr, item]否新数组尾部或任意位置length/索引赋值arr[arr.length] item是元素值尾部这张表先给你一个整体印象原地修改的方法里能避免搬移元素的操作必然更快返回新数组的方法必然有一次完整复制。记住这个基本判断再看后面性能和引擎原理的部分就不会乱。2. 六个方法逐个拆解语法、行为、隐藏细节2.1push尾部追加push是数组添加元素最常用的方法。它的语法是arr.push(element1, element2, ..., elementN)可以一次传入多个参数所有参数会按顺序追加到数组末尾。它的返回值很有特点是操作完成后数组的新长度而不是被添加的元素。这一点经常有人记错。push的性能优势来自它的操作路径特别短引擎只需要知道当前数组长度是多少然后在那个下标位置写入新值再把长度加一。如果数组预留的空间不够了就触发一次扩容把容量扩大。扩容是成倍扩大所以均摊到每次添加也是常数时间。这也是为什么尾部插入在绝大多数情况下都是最快的添加方式。需要提醒的是push是原地修改方法它会把原数组改掉。如果函数内部对入参数组做了push外层拿到的数组也会变。想避免这个副作用就得用后面的concat或展开运算符。2.2unshift头部插入unshift的语法和push几乎对称arr.unshift(element1, ..., elementN)直接在数组最前面插入一个或多个元素返回值同样是新数组的length。它也是原地修改。但等价的语法不意味着等价的性能。头部插入意味着原本位于 0 号索引的元素要变成 1 号索引1 号变 2 号所有已有元素都要往后挪一位。数组本质上是连续内存的线性结构这个搬移操作是 O(n)n 是数组长度。数组越长unshift越慢。这里藏着一个容易踩的坑当一次unshift传入多个元素时参数顺序没有被反转。比如arr [1, 2]执行arr.unshift(3, 4)之后结果是[3, 4, 1, 2]不是[4, 3, 1, 2]。因为引擎的逻辑是把参数按顺序放到数组头部的连续区域里。很多人下意识以为会像“从前往后插”那样反过来实际不是。2.3splice任意位置插入splice是数组里最“多功能”的方法删除、替换、插入一套全包。往数组添加元素的用法是arr.splice(start, 0, item1, item2, ...)第二个参数传 0 表示不删除任何元素后面的参数就是要插入的元素。它返回一个新数组里面是实际被删除的元素如果没删除任何元素返回空数组。splice也是原地修改。start参数支持负数比如arr.splice(-1, 0, x)表示在倒数第一个元素之前插入等于把数组长度加一新元素处在倒数第二位。负数规则和slice一致但如果对负数场景不熟很容易搞混建议先打印测试一遍再写进代码。splice在中间位置插入的成本取决于插入点越靠前需要向后搬移的元素越多越靠后搬移越少。最坏情况是在 0 号位置插入复杂度同样是 O(n)和unshift一个量级。所以它不是一个“想插哪就随手插”的方法数据量大的时候要慎重。2.4concat无副作用的拼接concat的本意是连接多个数组或值语法是arr.concat(value1, ..., valueN)。如果参数是数组它会把数组元素打平一层加进去如果参数是普通值就作为单个元素追加。关键点concat不会修改原数组而是返回一个新数组。这是添加元素时最常用的不可变方案之一。比如arr.concat(4, [5, 6])得到一个新数组[旧元素..., 4, 5, 6]原数组保持原样。注意它只展开数组第一层如果参数是嵌套数组比如arr.concat([1, [2]])最终结果是[..., 1, [2]]内层嵌套会被保留。因为concat内部要遍历原数组的所有元素再拼接上新元素所以整体时间复杂度是 O(n)。这个 n 包含原数组长度。也就是说在 10 万个元素的数组后面加 1 个元素concat要复制十万个元素到一个新数组里而不是在原数组上追加一个。性能自然比push差但它换来的是原数组不被污染在不可变数据场景里值这个代价。2.5 展开运算符更现代的不可变写法ES6 之后展开运算符成了构造新数组最受欢迎的写法。尾部添加是[...arr, newItem]头部添加是[newItem, ...arr]中间插入可以配合slice[...arr.slice(0, i), item, ...arr.slice(i)]。它和concat一样不修改原数组但可读性更强而且能直接用在数组字面量里。不过要注意展开运算符的适用范围是“可迭代对象”不只是数组。字符串可以被展开成字符数组Set、Map 也可以。如果想展开一个类数组对象比如arguments直接展开是可行的因为arguments是可迭代的但如果是一个纯粹的类数组比如{0: a, length: 1}它其实不能用...直接展开这时往往需要Array.from。展开运算符的底层逻辑也是“复制整个数组再添加”复杂度 O(n)。在 React 的useState更新列表时我用得最多的就是[...prev, newItem]它比concat更直观也避免了原地修改 state 的坑。2.6 直接利用length和索引最朴素的底层写法严格说这不算一个新 API而是一类底层技巧通常有两种写法第一种是arr[arr.length] value。因为数组索引从 0 开始arr.length始终指向最后一个元素后一格的空位所以这种写法等同于push但返回值不同push返回新length这种赋值返回value。在微基准测试里它有时候比push更快因为它省了一次函数调用不过差距非常小。第二种是直接改arr.length。比如arr.length arr.length 1这会往数组末尾追加一个空槽empty slot数组长度变大但值没有初始化或者arr.length 0可以清空数组。利用length给数组“扩容”添加空槽的做法我不推荐在业务代码里用因为空槽会造成非常隐蔽的问题数组方法在处理空槽时行为不一致map、filter、forEach会跳过空槽但some和find又会把它当作undefined去执行。后面第 5 章我会写一个真实踩坑案例。3. 性能实测十万次添加操作结果可能颠覆直觉3.1 测试代码怎么设计才公平网上有很多数组性能对比的代码但不少写得不太公平。比如测concat和展开运算符时有人直接写成console.time(concat); for (let i 0; i 100000; i) { arr.concat(i); } console.timeEnd(concat);这里的问题在于每次concat的返回值没有赋值回去结果concat虽然复制了数组但复制出来的新数组被丢掉了原数组还是最初那一个循环多少次复制的都是同一个长度的数组。但在真实使用场景里你会把新数组重新赋值回去数组会不断变大所以更公平的测试要模拟真实的累积操作。我这里提供一个简化版但相对公平的基准测试思路const N 100000; const rounds 5; function testPush() { let arr []; const start performance.now(); for (let i 0; i N; i) arr.push(i); return performance.now() - start; } function testIndexWrite() { let arr []; const start performance.now(); for (let i 0; i N; i) arr[arr.length] i; return performance.now() - start; } function testUnshift() { let arr []; const start performance.now(); for (let i 0; i N; i) arr.unshift(i); return performance.now() - start; } function testSpliceEnd() { let arr []; const start performance.now(); for (let i 0; i N; i) arr.splice(arr.length, 0, i); return performance.now() - start; } function testSpliceStart() { let arr []; const start performance.now(); for (let i 0; i N; i) arr.splice(0, 0, i); return performance.now() - start; } function testConcatEnd() { let arr []; const start performance.now(); for (let i 0; i N; i) arr arr.concat(i); return performance.now() - start; } function testSpreadEnd() { let arr []; const start performance.now(); for (let i 0; i N; i) arr [...arr, i]; return performance.now() - start; }跑的时候每个函数各执行rounds次取中位数更能反映真实情况。注意先把 N 设小一点比如 10000 跑通再根据机器调整否则unshift和concat那一组可能会让你等到怀疑人生。3.2 实测结果和解读在我这边 Node 20 环境下N100000、rounds5 的前提下结果大概是这样的数值仅供参考不同机器会有浮动但相对关系很稳定方法操作位置是否修改原数组平均耗时10万次复杂度arr[arr.length] i尾部是约 2msO(1)push(i)尾部是约 3msO(1)splice(arr.length, 0, i)尾部是约 12ms平均 O(1)但有函数调用开销unshift(i)头部是约 950msO(n)splice(0, 0, i)头部是约 1500msO(n)concat(i)尾部否约 3800msO(n)[...arr, i]尾部否约 4000msO(n)更直观一点push和直接索引赋值跑完只要几毫秒unshift要做元素搬移直接到了秒级。而concat和展开运算符因为每次循环都复制整个不断变大的数组耗时呈现累加的二次方增长所以比预期还要慢很多。这个结果不是特地去调出来的。背后的规律就是只要复制整个数组n 越大越吃亏只要头部插入需要搬移所有元素n 越大越吃亏。唯一接近“免罪”的尾部原地操作因为不需要搬移元素才会在长数组上遥遥领先。3.3 大数组批量添加时的参数展开陷阱如果测试里把循环改成push(...bigArray)这种写法会掉进另一个坑。JS 引擎对函数参数数量是有限制的当展开的数组元素数量过大在 V8 中大约超过 65535 个就会引发RangeError: Maximum call stack size exceeded。unshift(...bigArray)和splice(0, 0, ...bigArray)同理。所以批量往数组里塞大量数据的时候不要图省事用展开语法当参数。推荐下面这几种方式原数组可以被修改直接for循环push想保持不可变使用arr.concat(bigArray)concat能接收数组作为参数不需要把数组展开成参数列表如果必须用push批量追加可以arr.push(...bigArray.slice(0, 5000))分段展开再继续下一段但这只是绕开参数上限的方案不是最优我记得有一次线上数据同步功能就是因为在循环里用了push(...chunk)chunk 一次一万条没事某天业务方给了个五万条的 chunk然后就报错中断了。从那以后我对“展开给函数传参”都多留了一份心眼。4. 为什么 push 比 unshift 快那么多V8 引擎的数组存储机制4.1 快数组和慢数组要理解性能差异得看点引擎内部的东西。V8 里面数组最常见的是“快数组”Fast Elements它的元素存储在连续内存上索引直接对应内存偏移访问复杂度 O(1)。快数组内部还会根据数组里存的元素类型做更细的分类比如全是整数、全是 double、全是对象、混合类型不同的 ElementsKind 对应不同的存储和优化策略。这也是为什么如果你真的追求性能尽量保持数组元素类型一致。当数组变得非常稀疏、或者索引跳跃太离谱V8 可能把它降级成“慢数组”Dictionary Elements本质上是哈希表结构索引变成长度很长的 key。这种数组元素访问不再是简单偏移性能会下降很多。所以在业务代码里动不动就arr[100000] 1这样的写法可能让数组直接变成字典模式后续所有操作都变慢。4.2 unshift 的底层搬移过程unshift为什么慢本质上就是“腾位置”。假设现在快数组里有[a, b, c, d]内存里是连续的一块。要在头部插入 x引擎不能像链表那样只改指针它必须把 a、b、c、d 这四个元素整体往后移动一个槽位再把 x 放到 0 号位置。这个搬移是逐元素 copyn 个元素就要搬 n 次数组长度越大耗时越长。splice在头部插入也是同样的搬移逻辑所以两个方法在头部场景下都是 O(n)。有趣的是push和arr[arr.length]之所以快是因为它们在数组尾部写入不需要搬移任何已有元素最多只是数组本身的容量不够时触发扩容。V8 的扩容是成倍数增长均摊到每次添加是常数时间整体就是 O(1)。4.3 中间插入其实是“半截搬家”splice在中间插入的执行过程类似从插入位置开始把后半段元素往后挪一位再把新元素填进去。涉及的元素数量不是整个数组而是插入位置后面的那一截。所以它的平均复杂度也是 O(n)但常数系数比unshift小。数组长度为 n 时插入在头部要搬 n 个元素插入在中间平均搬 n/2 个插入在尾部几乎不用搬。这也解释了测试中splice(arr.length, 0, i)为什么比splice(0, 0, i)快得多。想验证这个结论也很简单在插入位置附近打印一下下标或者用不同位置各跑一遍基准。实际项目中如果必须在中间大量插入元素可以考虑先把数组拆成前后两段分别组合或者干脆换数据结构。4.4 不可变操作的成本被很多人低估concat和展开运算符表面上只是“方便地返回新数组”但这个新数组不是凭空出现的。它需要先根据原数组长度申请一块新内存然后把原数组的元素一个个复制过去再追加新元素。这意味着每次操作都要遍历 n 个旧元素。如果你在一个循环里不断往同一个数组尾部添加每次数组长度都会增加每次都要复制更大的数组总耗时接近 O(n²)。所以如果数据量大又坚持不可变操作性能通常不会好看。实际工程中我们要做的不是“永远不用concat”而是知道在什么临界点开始需要思考优化。比如一个列表只有几百条spread 怎么写都无所谓一旦上万条且频繁更新就应该考虑改成增量更新或者用 immer 这类工具来管理状态而不是硬扛着每次复制大数组。5. 真实项目里的选型建议与避坑指南5.1 不同场景应该选哪个方法根据自己的项目和踩坑经历我做了一个简单的决策对照表场景推荐写法原因纯粹在尾部追加原数组可修改arr.push(item)性能最好语义清晰尾部追加但不想引入副作用[...arr, item]或arr.concat(item)不改变原数组适合 state 更新偶尔在头部插入数组不大arr.unshift(item)代码可读性最好性能可接受频繁在头部插入数组很大改用尾部push 最后reverse或换数据结构避免每次 O(n) 搬移任意位置单次插入arr.splice(index, 0, item)功能完整注意 index 越界不可变地任意位置插入[...arr.slice(0, i), item, ...arr.slice(i)]不污染原数组可读性好合并多个大数组arr.concat(bigArr1, bigArr2)避免参数展开上限问题决策时我一般只问三个问题第一能不能修改原数组不能直接进concat/spread 分支第二数据量大概多大超过一万条就要开始警惕 O(n) 操作第三操作位置在哪头部和中间插入都是 O(n)要尽量规避。5.2 我踩过的几个典型坑先说最经典的一个用length赋值制造稀疏数组。let arr [1, 2, 3]; arr.length 5;此时 arr 变成[1, 2, 3, 2 empty items]。如果直接arr.map(x x * 2)结果会是[2, 4, 6, 2 empty items]右侧空槽依然存在且被跳过很多人在这一步以为map出 bug 了。更诡异的是arr.forEach不会遍历空槽但arr.find会认为空槽是undefined行为不一致会带来非常难排查的数据问题。所以除非你能确定空槽不影响业务否则别用length赋空槽。第二个坑是 React 等框架里的 state 数组直接push。我见过不止一次这样的代码const [list, setList] useState([]); function addItem(item) { list.push(item); setList(list); }list 的引用没变React 做浅比较的时候会认为 state 没变组件不重渲染。正确写法是setList([...list, item])或setList(list.concat(item))。这个坑背后就是第 1 章说的“原地修改 vs 返回新数组”的分类问题面试官爱考它是有道理的。第三个坑是批量插入用了展开运算符当参数。前面 3.3 节也说过push(...bigArray)存在参数上限的问题在大数据量时直接抛异常。一旦发现问题换成concat或者循环push就稳多了。5.3 如果真的要频繁首尾插数组不是好选择当你确认业务就是高频地在数组头部或尾部插入并且数据量很大光靠“用push代替unshift”是不够的。一个常见做法是维护“双端队列”的思维内部用两个数组一个正向存后半段一个反向存前半段。往头部插入时插到反向数组的尾部往尾部插入时插到正向数组的尾部需要读取时把反向数组反转后和正向数组合并。这样头部插入也变成 O(1)。另一个更简单粗暴的替代方案实在不行用循环数组或者单向链表。JS 没有原生链表但你完全可以用对象模拟一个节点结构或者引入现成的数据结构库。不过要注意链表牺牲了随机访问能力如果业务上既要随机访问又要频繁头插那就要权衡了。我自己的经验是大多数业务场景其实头插频率没那么高真有那么高的时候重新审视一遍数据模型往往能找到一个可以reverse或分段处理的方案。提示别做“为了优化而优化”的事情。如果你的数组只有几十上百个元素unshift和splice随便用肉眼根本感觉不到区别。先把代码可读性保住等到性能瓶颈真实出现再按本章思路去改。我个人在项目里的习惯是默认情况下一律用push需要不可变更新时用展开运算符偶尔插中间用splice但会先把数据规模放在心里。遇到需要频繁头插的大数组第一反应不是急着换个方法而是先怀疑数据模型是否合理。把这三件事想明白数组添加元素这件事基本就不会再踩坑了。