尧图网络 高端网站定制 · 原创设计
免费咨询热线
400-888-6620
免费获取方案
JS数组插入删除:push、pop、shift、unshift与splice
js数组的常用方法里插入和删除这两类动作被问到的频率最高尤其是头部插入、头部删除、尾部插入、尾部删除这四个基础操作几乎是每个刚接手真实项目的人都会先去翻一遍文档的东西。我刚开始写业务代码的时候也觉得这几个方法没什么可讲的看一眼就会直到在一段日志缓冲逻辑里用 shift 处理十几万条数据的队列接口响应时间从几十毫秒涨到两秒多回头排查才发现问题就出在一个看起来人畜无害的 shift 上。这篇内容不打算把文档抄一遍我想聊的是这几个方法各自的真实成本在哪里什么场景该用谁以及那些文档里不会写、但踩过一次就忘不掉的细节。不管你是刚学数组、还在纠结 push 和 unshift 到底差在哪的新手还是写了几年业务、习惯性用展开运算符的熟手下面这些应该都能挑出点能直接用的东西。1. 数组两端的操作为什么值得单独拎出来讲数组的插入删除看起来是最没有技术含量的操作但恰恰因为太基础很多人从来没想过它的成本结构。等到数据量上来了或者这段代码被放进一个每秒执行几千次的热路径里原本无所谓的选择就会变成性能瓶颈。我见过一个真实的例子某段轮询逻辑里每来一条消息就 unshift 进数组数组长度稳定在几千单次调用看不出问题但放在高频定时器里累积的消耗非常可观。所以这一章我想先把底层逻辑说清楚后面再看具体方法就不会只停留在知道有这个方法的层面。1.1 从内存布局理解头部和尾部的差别你可以把数组想成一排编号连续的储物柜编号从 0 开始每个柜子里放一个元素。这个编号连续是关键。当你要在尾部追加一个元素时只需要在最后一个柜子后面接一个新的编号顺延即可前面的柜子完全不用动。但如果要往头部插一个元素那就相当于要在 0 号柜前面硬塞一个柜子为了保证编号依然从 0 开始连续原来 0 号柜的元素得挪到 1 号1 号挪到 2 号一路挪到最后。头部删除反过来所有元素往前挪一格。这就是尾部操作通常很快、头部操作通常较慢的根本原因。在常见的 JavaScript 引擎实现里这个差异会被进一步放大或者缓解。引擎通常会为数组分配一块比当前长度更大的连续空间并在末尾留出一点余量这样连续几次 push 都能直接在预留空间里写不需要每次都重新申请内存平均下来单次追加的代价就被摊薄了。但当预留空间用尽时引擎需要申请一块更大的内存并把所有元素搬过去这一次操作会明显变慢。至于头部操作因为涉及整体位移基本没有办法靠预留空间来优化长度越大位移的元素越多。理解了这一层你就能明白为什么某些号称数组很快的说法在头部操作上完全不成立。1.2 四个基础方法与一个万能方法的定位尾部和头部各自有一对方法配对得非常整齐push 负责尾部追加pop 负责尾部弹出unshift 负责头部插入shift 负责头部弹出。这四位的共同特点是都直接修改原数组我们称之为原地操作。它们各自还有一个容易记混的返回值push 和 unshift 返回的是修改之后的新长度pop 和 shift 返回的是被移除的那个元素本身。这个区别看着不起眼但在链式调用里是实打实的陷阱后面我会单独说。除了这四位还有一个 splice它在语义上是从某个位置开始删掉若干个再插入若干个位置可以取 0这时它就等价于头部操作位置可以取数组长度这时它就等价于尾部操作。所以严格来说splice 是这四个基础方法的超集。那为什么还要用 push 和 pop因为可读性和性能。语义越窄的方法读代码的人越容易一眼看懂你在干什么引擎在做优化判断时也更容易识别出这就是一个纯尾部追加这种模式。反过来如果你为了统一风格把所有插入删除都写成 splice代码会变得难以阅读也失去了引擎优化的一部分空间。1.3 一张表说清楚该选谁我把常见的操作意图和方法对应关系整理成一张表日常写代码时可以直接对照。这张表的逻辑是先想清楚你是要改原数组还是要拿到一个新数组再想清楚操作位置在头还是尾最后再考虑是否需要批量处理。操作意图推荐方法是否改原数组返回值单次复杂度尾部追加一个或多个push是新长度摊还 O(1)尾部弹出一个pop是被删元素O(1)头部插入一个或多个unshift是新长度O(n)头部弹出一个shift是被删元素O(n)任意位置删除加插入splice是被删元素组成的数组O(n)不改原数组的追加concat / 展开运算符否新数组O(n)不改原数组的删除slice 拼接 / toSpliced否新数组O(n)注意表里的摊还 O(1)意思是长期平均下来是常数级但单次可能因为扩容而变慢。如果你在写对单次延迟极度敏感的逻辑比如音频处理里的实时缓冲这一点需要单独考虑。2. 逐个拆解五个方法的行为细节与写法知道方法名和参数只是第一步真正决定代码质量的是对返回值和边界情况的掌握。这一章我把五个方法逐个过一遍重点放在那些容易被忽略的行为上比如参数为负、参数超出范围、传入非数字时会发生什么。这些细节平时不一定用得到但一旦触发往往就是那种查半天查不出来的问题。2.1 尾部双人组push 和 pop 的返回值陷阱push 可以一次追加多个元素参数按顺序依次放到数组末尾返回值是新数组的长度。这个设计本身没问题问题在于很多人写顺手了就喜欢链式调用const list []; const result list.push(1, 2, 3); console.log(result); // 3不是 [1, 2, 3] // 下面这行会直接报错因为 3 是数字没有 map 方法 list.push(4).map(x x * 2);这类错误在新手里出现得特别频繁因为直觉上会觉得push 进去什么返回的就是什么。实测下来最省事的规避方式就是养成习惯push 只当作语句来用不要把它嵌进表达式里再继续调用别的方法。如果确实需要新数组用 concat 或者展开运算符语义和行为都对得上。pop 相对简单它删除并返回最后一个元素数组为空时返回 undefined长度不会变成负数而是保持 0。这里有一个容易被忽视的点pop 返回的是元素的引用如果数组里存的是对象你拿到的是同一个对象改动它会影响数组里的那一份。这一点对 shift 同样适用后面讲引用类型时会再展开。2.2 头部双人组unshift 和 shift 的真实代价unshift 和 shift 的语义和尾部那对完全对称但代价不对称。我做过一个很粗的对比测试思路是用 performance.now() 包住循环分别测在十万长度的数组上连续执行一万次 push 和一万次 shift 的耗时两者差了两个数量级以上。这个测试不需要写得很严谨只要让你直观感受到量级差异就够了function bench(name, times, fn) { const start performance.now(); for (let i 0; i times; i) fn(); const cost performance.now() - start; console.log(${name}: ${cost.toFixed(2)}ms); } const big Array.from({ length: 100000 }, (_, i) i); bench(push, 10000, () big.push(0)); bench(pop, 10000, () big.pop()); bench(shift, 10000, () big.shift());跑出来你会看到 push 和 pop 几乎是瞬间完成因为这段循环里数组长度基本没变引擎的预留空间足够用而 shift 会把整个数组往前搬数据量越大越明显。所以只要你的数据结构设计里出现了从头部频繁取就应该警惕了它往往意味着这段逻辑需要换一种实现方式而不是硬着头皮用 shift。那什么时候可以放心用 shift当数组很短的时候比如十几二十个元素位移的成本完全可以忽略代码可读性反而更重要。我个人的判断标准是如果这个数组的长度不会有明显增长或者这段代码不在高频调用的路径上那就直接用 shift没必要为了一点理论性能把代码写得复杂。2.3 splice一把能切能缝的手术刀splice 的参数是起始位置、删除个数、以及要插入的元素。它的返回值是被删除的元素组成的数组如果没有删除任何元素就返回空数组。这里有几个容易出问题的地方值得单独说。第一删除个数省略时表示从起始位置删到结尾。很多人以为省略就是不删结果把数组后半段清空了。const a [1, 2, 3, 4, 5]; a.splice(2); // 返回 [3, 4, 5]a 变成 [1, 2] a.splice(0, 0, 9); // 返回 []a 变成 [9, 1, 2]这才是纯插入第二起始位置可以是负数表示从末尾往前数。传 -1 表示从最后一个元素开始传 -2 表示从倒数第二个开始。对于超出范围的正数会被截断到数组长度对于绝对值超过长度的负数会被截断到 0。第三删除个数传负数或者非数字时会被当作 0 处理也就是不删除只插入。这个行为比较反直觉因为传负数在别的语言里可能会报错但这里静默变成 0如果不注意就会写出以为在删其实在插的代码。2.4 批量处理和参数速查实际项目里高频出现的是批量删除和批量插入这时候用循环调用 shift 或者 pop 就不合适了应该一次性用 splice 处理。下面这几种写法我几乎每周都会用到。const buffer [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]; // 从头部一次性取走 3 个 const taken buffer.splice(0, 3); // taken [0,1,2]buffer 变成 [3..9] // 在指定位置替换 1 个元素 buffer.splice(1, 1, x); // 一次性在头部插入多个 buffer.splice(0, 0, a, b);方法参数形式返回值关键边界行为pushpush(...items)新长度不传参数时长度不变仍返回当前长度poppop()被删元素空数组返回 undefinedunshiftunshift(...items)新长度参数按顺序插入顺序与书写一致shiftshift()被删元素空数组返回 undefined长度保持 0splicesplice(start, deleteCount, ...items)被删元素数组省略 deleteCount 时删到结尾提示unshift 传入多个参数时是最容易写错顺序的地方。unshift(1, 2) 得到的是 [1, 2, ...原数组]而不是 [2, 1, ...]因为它们是按顺序依次插入到同一个位置的。3. 实际项目里的落地方案与代码结构光记住方法本身遇到真实需求还是容易卡住。这一章我把几种典型场景拆开从数据结构选择到代码实现完整走一遍你可以直接拿去改改就用。这些方案都是我在实际项目里沉淀下来的不是教科书上的示例。3.1 用数组实现栈和队列栈是后进先出队列是先进先出这是最基础的两个结构。用数组实现栈非常简单push 配合 pop 就够了两个都是尾部操作性能没问题const stack []; stack.push(a); stack.push(b); const top stack.pop(); // b队列就麻烦一点。如果用 push 入队、shift 出队入是尾部操作很快出是头部操作很慢。这个组合在队列长度不大的时候没问题代码也最直观。但如果队列会变长比如消息积压、日志缓冲、任务调度就得换思路。常见的做法是引入一个读指针出队的时候不真正删除元素只把指针往后移等到积压到一定程度再统一清理class FastQueue { constructor() { this.items []; this.head 0; } enqueue(v) { this.items.push(v); } dequeue() { if (this.head this.items.length) return undefined; const v this.items[this.head]; this.items[this.head] undefined; // 断开引用便于回收 this.head 1; if (this.head 1000 this.head * 2 this.items.length) { this.items this.items.slice(this.head); this.head 0; } return v; } get size() { return this.items.length - this.head; } }这个实现里有两个细节值得说明。一是把已经出队的位置置为 undefined这样如果元素是对象垃圾回收可以及时释放不然数组会一直持有引用。二是清理策略用了两个条件同时判断只有当 head 足够大且已经超过数组长度一半时才做一次 slice这样清理本身也是摊还成本低的操作不会让某一次 dequeue 突然变慢。3.2 批处理缓冲从尾部塞、从头部批量取我在做一个埋点上报模块时用过这套结构。业务代码不断产生事件塞进缓冲区到达阈值或者定时器触发时一次性把缓冲区的数据整批取走发给后端。这里的取走如果用循环 shift一百条就是一百次数组位移用一次 splice(0, n) 就只位移一次差别非常明显。class BatchBuffer { constructor(limit 50) { this.items []; this.limit limit; } add(item) { this.items.push(item); if (this.items.length this.limit) return this.flush(); return []; } flush() { if (this.items.length 0) return []; const batch this.items.splice(0, this.items.length); return batch; } }flush 里用 splice(0, length) 而不是直接赋值空数组是因为赋值新数组会让原来的数组引用被替换掉如果外部有别的代码持有这个引用就会出问题更重要的是splice 保留了数组本身行为更可预期。当然如果你确定没有别处引用直接重新赋值更快这一点取决于你的代码结构。3.3 环形缓冲长度固定场景的另一种解法如果缓冲区的长度是固定的比如只保留最近 100 条日志那环形缓冲会非常合适。它的核心思想是预先分配一块固定长度的数组用两个指针记录读和写的位置写满之后从头覆盖全程没有元素位移也没有扩容开销。class RingBuffer { constructor(capacity) { this.capacity capacity; this.buf new Array(capacity); this.size 0; this.writeIndex 0; } push(v) { this.buf[this.writeIndex] v; this.writeIndex (this.writeIndex 1) % this.capacity; if (this.size this.capacity) this.size 1; } toArray() { if (this.size this.capacity) { return this.buf.slice(0, this.size); } return this.buf.slice(this.writeIndex).concat(this.buf.slice(0, this.writeIndex)); } }这个结构里没有用到任何头部插入删除的方法因为头部操作的本质是位移而环形缓冲用取模的方式把位移这件事绕过去了。我在做实时曲线数据缓存的时候就用的这套容量定死之后内存占用完全稳定不会因为数据量波动引起垃圾回收抖动。3.4 不改原数组的几种写法函数式风格越来越常见尤其是用在状态管理里因为框架需要靠引用变化来判断是否更新。这时候就不能用 push 和 splice 了需要用不改原数组的写法。常见的有这么几种各有适用场景。const base [1, 2, 3]; // 尾部追加 const a1 [...base, 4]; const a2 base.concat(4); // 头部插入 const a3 [0, ...base]; // 删除某个位置 const idx 1; const a4 [...base.slice(0, idx), ...base.slice(idx 1)]; // 新版本里的专用方法 const a5 base.toSpliced(1, 1); // 不修改 base const a6 base.with(1, 99); // 替换某个位置的值展开运算符写起来最舒服但要注意它是浅拷贝嵌套的对象还是共享引用。如果数据结构有两层改动内层对象依然会影响到原数组。另外在数据量很大的时候每次操作都创建一个完整的新数组内存压力会明显上升这时候可以考虑只对变化的部分做结构共享或者用专门的不可变数据结构库。4. 高频坑点与真实排查记录这一章的内容基本都是从实际问题里抠出来的有些坑我踩过不止一次。它们的共同特点是代码不会报错行为看起来也差不多对但结果就是不对而且要查很久。4.1 length 的赋值与稀疏数组数组的 length 属性是可写的这一点和其他语言的数组很不一样。把它改小会直接截断数组改大会产生空洞。空洞和 undefined 是两回事空洞意味着那个位置根本没有元素遍历的时候会被跳过。const a [1, 2, 3]; a.length 1; console.log(a); // [1] a.length 4; console.log(a); // [1, 3 empty items] console.log(1 in a); // false位置 1 是空洞 console.log(a[1]); // undefined但和真的存了 undefined 不一样 a.forEach(x console.log(x)); // 只输出 1空洞被跳过 a.map(x x); // 返回的数组里空洞仍然保留这种差异性导致同一个数组用不同方法遍历会有不同结果排查时非常费劲。我的建议很简单除非有明确目的不要手动改 length清空数组就用arr.length 0这一种写法其余情况一律用 splice 或者重新赋值。4.2 delete 删除数组元素带来的问题delete 是操作对象属性的语法数组也是一种对象所以delete arr[1]语法上是合法的但它不会改变数组长度只会在那个位置留下一个空洞。这和 splice 的语义完全不同。我见过有人用 delete 删除列表项结果列表长度不对渲染时多出一个空行查了半天才发现问题。const a [1, 2, 3]; delete a[1]; console.log(a); // [1, 1 empty item, 3] console.log(a.length); // 仍然是 3正确的做法是a.splice(1, 1)它会删除元素并把后面的往前挪长度也会同步减一。记住一条原则数组的删除永远不用 delete。4.3 引用类型、浅拷贝与返回值pop、shift、splice 返回的都是元素的引用不是复制出来的副本。这个特性在存对象的时候会带来麻烦你以为从数组里取出来了改一改没影响实际上数组里那份也跟着变了如果数组还留着就是个隐患。const list [{ id: 1, name: a }]; const item list.shift(); item.name changed; console.log(list[0]); // 可能已经被清掉了但如果你没有删除而是读取同样地[...arr]和arr.slice()都是浅拷贝只复制第一层的引用。如果元素是对象新旧数组里指向的是同一个对象改一个另一个也变。要真正隔离得用深拷贝比如结构化的深拷贝方法或者自己递归处理但这两种都有各自的限制比如函数、特殊对象类型处理不了。实际项目里我的习惯是如果数据要跨模块传递并且可能被修改就在边界处显式深拷贝一次而不是到处依赖应该没人改吧。4.4 和其他常用方法的配合数组和字符串的转换是绕不开的一环。join可以把数组按指定分隔符拼成字符串不传参数默认用逗号toString等价于用逗号连接。这里有个小坑如果元素是对象或者数组会先调用它们各自的字符串转换结果往往是[object Object]所以格式化输出前最好先 map 处理一遍。const nums [1, 2, 3]; console.log(nums.join(-)); // 1-2-3 console.log(nums.join()); // 123 const objs [{ id: 1 }, { id: 2 }]; console.log(objs.join(,)); // [object Object],[object Object] console.log(objs.map(o o.id).join(,)); // 1,2判断数组里包不包含某个值时includes 比 indexOf 更好用因为 indexOf 用严格相等比较找不到 NaN而 includes 可以正确识别 NaN。如果你只是需要一个布尔结果用 includes如果需要知道位置再用 indexOf。字符串上的判断也是同样的思路用字符串的 includes 方法比手写循环或者正则更直观只有在需要复杂匹配模式的时候才上正则。5. 常见问题速查表与调试习惯最后这部分是我自己整理的一份速查表遇到类似现象的时候可以直接对着看。表格后面还加了几个调试习惯都是被坑出来的经验成本很低但很管用。5.1 问题速查表现象可能原因处理方式push 之后链式调用报错push 返回的是数字长度把 push 单独作为语句调用头部插入几万条后明显卡顿unshift 每次都要位移全部元素改用尾部追加加反转或用环形缓冲删除后数组长度没变用了 delete 而不是 splice换成 splice 删除遍历时数量对不上数组里有空洞避免手动改 length用遍历前过滤改了取出的对象影响原数组取出的是引用需要隔离时做深拷贝用 indexOf 找不到某个值该值是 NaN改用 includes数组转字符串得到一串 object元素是对象先 map 提取字段再 join拼出来的新数组和原数组联动只做了浅拷贝对嵌套层级做深拷贝5.2 几个低成本的调试习惯第一个习惯是打印数组时不要直接 console.log 那一行而是用 console.table 或者带缩进的序列化这样能看清每个位置到底有没有值。因为浏览器控制台在某些情况下展示的是对象的实时引用你点开箭头看到的是打印之后的最新状态而不是打印那一刻的状态这会让排查方向完全跑偏。我一般会这么处理console.log(JSON.stringify(arr, null, 2)); console.table(arr);第二个习惯是在怀疑有空洞的时候用Object.keys(arr)看实际存在的位置。如果是连续数组会看到 0 到 n-1 全部存在如果有空洞某些索引就不会出现在结果里。这个方法比逐个打印快得多而且能一次性看全。const weird [1, , 3]; // 中间是空洞 console.log(Object.keys(weird)); // [0, 2]第三个习惯是在性能相关的地方先量再改。很多性能优化的直觉是错的你觉得慢的地方可能根本不是瓶颈你忽略的那行看起来无害的 shift 才是。写一个十行的计时函数把可疑的操作各跑一万次成本不过几分钟但能省下大量瞎猜的时间。我自己在给队列逻辑做优化的时候就是靠这个办法定位到头部操作上的。我在实际项目里的体会是数组的插入删除这些方法本身没有任何难度真正的门槛在于你是否清楚每一次调用背后会发生什么以及你的数据结构是否和访问模式匹配。最典型的错配就是用数组当队列但频繁从头部取一旦识别出这个模式方案自然就变了。另外分享一个小技巧如果你遇到需要同时从两端操作的场景与其自己纠结用哪几个方法不如先把两端的操作频率写下来取的那一端如果是头部并且长度可能上千那就该考虑换结构了如果两端都很少取那就怎么直观怎么写完全没必要提前优化。
RELATED

相关推荐

JS数组方法全解析:从增删改查到性能优化与避坑指南

JS数组方法全解析:从增删改查到性能优化与避坑指南

1. 数组操作看着简单,为什么很多人还是写不顺手前阵子帮同事排查一个聊天列表的卡顿问题,代码逻辑本身不复杂:每来一条新消息,就往列表头部插入一条。他写的是list [newMsg].concat(list)。单条消息看不出问题,可一旦…

📅 2026/10/1 20:58:35
本地AI前置预处理:文档分片与L0规则调度实战

本地AI前置预处理:文档分片与L0规则调度实战

做本地AI应用的大概都被同一个问题卡过:你辛辛苦苦在本机跑起一个大模型,结果发现真正推进业务效率的不是模型本身,而是模型前面那堆脏活累活——把几百份办公文档转成模型能读的文本,再按需切片、过滤、分类,最后才轮…

📅 2026/10/1 20:58:35
PyTorch nn.GRU 输入输出形状与 h_n/output 区别详解

PyTorch nn.GRU 输入输出形状与 h_n/output 区别详解

写循环神经网络这块代码时,torch.nn.GRU的输入输出形状是最容易反复回查的地方。我自己的习惯是把 shape 注释直接写在每行 forward 代码后面,原因很简单:这个模块的输入有两个张量,输出也有两个张量,四个形状里任何一…

📅 2026/10/1 20:53:34
MORE NEWS

更多资讯

📰

C++初阶(长期更新)第4讲:类和对象(中)

C初阶(长期更新)第4讲: 类和对象(中) 跟着潼心走,轻松拿捏C,困惑通通走,一去不回头~欢迎开始今天的学习内容,你的支持就是博主最大的动力。博主主页:潼心141…

📰

解决单点后端高并发问题技术:Nginx

nginx是什么 Nginx(读作 “engine-x”)是一款开源、高性能的 Web 服务器和反向代理服务器。它由 Igor Sysoev 开发,2004 年发布,最早是为了解决高并发场景下的性能和稳定性问题。 除了 HTTP 服务,Nginx 也支持 IMAP/PO…

📰

同一套算法,换个参数差 15%:我打算让智能体自己去找这组参数

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

📰

reinstall:VPS 一键重装系统脚本,256MB 内存也能在 Linux 与 Windows 间切换

reinstall:VPS 一键重装系统脚本,256MB 内存也能在 Linux 与 Windows 间切换 【免费下载链接】reinstall 一键DD/重装脚本 (One-click reinstall OS on VPS) 项目地址: https://gitcode.com/GitHub_Trending/re/reinstall reinstall 是一个 VPS 一…

📰

C++ 第 10 课:函数 Function

上一课标准答案:输出:0 1 2因为当 i 3 时执行:break;整个循环直接结束。输出:0 1 2 4因为当 i 3 时:continue;只跳过这一轮,所以后面的 4 还会继续执行。最大区别:break整个循环直接结束conti…

📰

DeepSeek桌面版Agent实战:从API接入到插件加载的完整踩坑记录

1. 抢跑一个还没官宣的桌面客户端,我到底在折腾什么前几天刷社区的时候看到有人在讨论 DeepSeek 可能要出桌面版,官方渠道一点动静都没有,但热词里已经冒出了 "deepseek hermes 桌面版"、"deepseek harness 桌面版"、&qu…

TODAY

今日更新

THIS WEEK

本周精选

THIS MONTH

本月热门

读完文章,想聊聊您的网站?

告诉我们您的行业与需求,资深顾问一对一梳理方案与报价,全程免费。

📞 💬