js数组的常用方法里,push、pop、shift、unshift 这四个大概是最早被记住、也最早被用错的。我带过的新人里,几乎每个人都写过list.unshift(item)来做消息列表的头部插入,也几乎每个人都在数据量涨到几万条之后,发现页面开始卡得不成样子。更麻烦的是,这类问题不会在控制台报错,不会有任何警告,它只是安静地把每一帧的时间拉长,直到你翻遍渲染逻辑也找不到原因。
这篇内容我想把数组"两头操作"这件事从头捋一遍:四个基础方法的返回值到底怎么记、头部插入删除为什么可能变慢、splice 和 slice 的参数语义差在哪、delete 和 length 赋值会留下什么后患,最后给一个能直接抄走用的环形缓冲区实现。只要你会写for循环、知道数组下标从 0 开始,就能跟上;如果你已经踩过 shift 的坑,中间那几段复杂度分析应该会让你有共鸣。
1. 先搞清"两头":push、pop、shift、unshift 的返回值与副作用
1.1 一张速查表顶十遍文档
这四个方法的记忆负担其实不在"干什么",而在"返回什么"。我见过太多人把返回值记反,然后在几百行之外的地方排查半天。先上表:
| 方法 | 操作位置 | 是否改原数组 | 返回值 | 均摊代价 |
|---|---|---|---|---|
push(...items) | 尾部插入 | 是 | 新长度(number) | O(1) |
pop() | 尾部删除 | 是 | 被删掉的元素,空数组为undefined | O(1) |
shift() | 头部删除 | 是 | 被删掉的元素,空数组为undefined | O(n),引擎可能优化 |
unshift(...items) | 头部插入 | 是 | 新长度(number) | O(n),引擎可能优化 |
splice(start, n, ...items) | 任意位置增删 | 是 | 被删元素组成的数组 | O(n) |
slice(start, end) | 任意位置截取 | 否 | 新数组(浅拷贝) | O(k) |
concat(...arrs) | 拼接 | 否 | 新数组(浅拷贝) | O(n) |
at(i) | 按索引读取 | 否 | 元素或undefined | O(1) |
规律很好记:"插"的返回长度,"删"的返回元素。这个规律一直延伸到 splice——splice 既插又删,它返回的是"删掉的那部分",而不是新数组。
1.2 返回值记错是最贵的低级错误
真实世界里最常见的翻车写法长这样:
// 想实现"新消息插到最前面" this.messages = this.messages.unshift(newMsg);下一行任何对this.messages的数组操作都会直接抛TypeError: this.messages.forEach is not a function,因为此时它已经是个数字了。这个 bug 的阴险之处在于:它不在插入的那一行报错,而是在几十行之后的渲染函数里报错,第一次遇到的人会沿着调用栈往回找一个完全不相关的组件。
同样的坑还有push的链式调用:
const arr = [1, 2]; arr.push(3).push(4); // TypeError,因为 arr.push(3) 返回的是 3而pop和shift在空数组上都返回undefined。如果你用返回值做判空,遇到数组里真的存了一个undefined元素时就会误判。稳妥的写法是先看arr.length === 0,再决定要不要取元素:
function takeFirst(list) { if (list.length === 0) return null; return list.shift(); }1.3 为什么"改变原数组"这五个字值得反复强调
push、pop、shift、unshift、splice都会修改原数组,slice、concat、map、filter则返回新数组。这个区别在两种场景下会直接决定代码能不能跑对。
第一种是把数组当函数参数或组件 props 传的时候。你在子函数里list.push(x),调用方的数组同样被改了,因为这俩指向的是同一块内存。第二种是 React、Vue 这类依赖引用比较的框架里,你push完之后必须手动触发更新,而filter返回的新数组天然就会让引用变化。
我的习惯是:凡是能被外部持有的数组,尽量用不改变原数组的写法,比如用展开运算符[...list, item]代替push。代价是一次浅拷贝的内存,收益是引用变化天然清晰、调试时不容易出现"谁改的"这种问题。只有在性能敏感的热路径上,我才会换回原地修改。
2. 复杂度不是理论问题:unshift 和 shift 在真实列表里怎么拖慢页面
2.1 从一段"每秒插入一条消息"的代码说起
有个朋友做的是监控面板,WebSocket 每秒推几条告警,做法是alerts.unshift(item),并且限制最多保留 2000 条。单条看着没问题,但他同时在渲染层做了全量map。上线后一段时间开始有人反馈"页面点一下要等半秒"。
我让他加了一行计时,只测两件事:
// 只测 unshift 本身 const arr = []; console.time('unshift-50k'); for (let i = 0; i < 50000; i++) arr.unshift(i); console.timeEnd('unshift-50k'); // 只测 push 本身 const arr2 = []; console.time('push-50k'); for (let i = 0; i < 50000; i++) arr2.push(i); console.timeEnd('push-50k');结果在两台机器上一次是 30 多毫秒对 3 毫秒左右,另一次差距更大。问题不完全出在 unshift 本身,而是它把数组搞成了另一种内部形态,后续的map、forEach、JSON.stringify全部跟着变慢。这就是为什么我说复杂度不是纯理论——它会以"渲染一帧多花十几毫秒"的形式让你感知到。
2.2 V8 的 left-trim 优化,别把它当护身符
这里必须补一个容易被忽略的细节:规范只规定了shift的语义,没规定它必须搬移所有元素。V8 在 fast elements 模式下对shift做了优化,通常只移动底层存储的起始指针,而不是真的把每个元素往前挪一格,unshift在有空余空间时也会做类似处理。所以你在某些 Chrome 版本上跑基准,会看到shift快得不像 O(n)。
但我不建议把这个当依据,理由有三点:
第一,触发条件比较苛刻。一旦数组变成 holey(有空洞)、或者掉进 dictionary elements 模式,优化路径就失效,退回逐个搬移。第二,unshift一次塞多个参数时走的是另一条路径,和单参数不一样。第三,也是最现实的——这段代码将来可能跑在别的引擎、别的运行时、甚至别的语言翻译层里(比如某些小程序容器),你在 Chrome 上测出来的结论不成立。
所以我的判断标准很简单:如果这个列表的长度会稳定超过几千,并且你要在头部增删,那就不要用 shift/unshift,换成下面几种方案之一:
- 反转存储:新数据
push到尾部,读取时从后往前取,把"头部操作"转成"尾部操作"。 - 下标指针:数组只
push不shift,用一个headIndex记录当前逻辑队首位置,消费时递增下标,积攒到一定量再批量splice一次。 - 环形缓冲区:容量固定,读写都是 O(1),这是第 5 节要展开的实现。
2.3 一个能落地的测量方法
我不太信任网上的性能对比表,因为数组方法的表现跟数据类型、数组是否 holey、JIT 是否预热都有关系。你自己测的时候注意三件事:
- 先预热:把测试循环跑三遍,只取后两遍的数据,让 JIT 有机会优化。第一遍的数字通常会夸张好几倍。
- 分开测:先测纯插入,再测插入加读取,你会发现后者差距更大,因为读取会暴露数组内部形态的变化。
- 看真实数据规模:如果你线上的列表最多 200 条,纠结 shift 的复杂度就是浪费时间。先量数据量,再决定要不要优化。
提示:
console.time/console.timeEnd的标签必须一一对应,且不要在循环内部调用,否则日志本身的开销会污染结果。要更准的话用performance.now()手动记录。
3. 中间位置的增删:splice、slice、concat 最容易混的三兄弟
3.1 splice 的参数语义与"删除数量"陷阱
splice(start, deleteCount, ...items)的三个参数各有各的坑。
start支持负数,-1表示从最后一个元素开始。deleteCount省略时表示"从 start 删到末尾",这一点经常被误用:
const a = [1, 2, 3, 4, 5]; a.splice(2); // 返回 [3,4,5],a 变成 [1,2] a.splice(1, 0, 9); // 返回 [],a 变成 [1,9,2],纯插入 a.splice(1, 1, 9); // 返回 [9],把下标 1 换成 9最容易出问题的是在正向循环里 splice 删除:
// 想删掉所有偶数,结果是错的 const nums = [1, 2, 4, 5, 6, 7]; for (let i = 0; i < nums.length; i++) { if (nums[i] % 2 === 0) nums.splice(i, 1); } // 结果 [1, 5, 7],4 和 6 被跳过了原因很直白:删掉下标 1 之后,原来的下标 2 变成了下标 1,但i已经自增到 2,中间那个元素就被跳过了。两个正确写法:倒着遍历,或者干脆用filter:
const result = nums.filter(n => n % 2 !== 0);倒序遍历的版本是for (let i = nums.length - 1; i >= 0; i--)。倒着走之所以安全,是因为删除后面的元素不会影响前面元素的下标。这个技巧在做原地修改、又不想额外分配数组时非常有用。
3.2 slice 与 concat 返回新数组,注意浅拷贝
slice(start, end)的end是不包含的,这是它和splice最直观的区别。负数同样支持:arr.slice(-3)取最后三个。
concat会把数组参数"摊平一层":
[1, 2].concat([3, 4], 5); // [1, 2, 3, 4, 5] [1, 2].concat([[3, 4]]); // [1, 2, [3, 4]]两者都只做浅拷贝。所谓浅拷贝,就是只复制"第一层"引用。如果数组元素是对象,新旧数组里的对象还是同一个:
const origin = [{ id: 1 }]; const copied = origin.slice(); copied[0].id = 999; console.log(origin[0].id); // 999,原数组也被改了这个特性在"我要复制一份数据再改"的场景里是高频事故源。要真正隔离,用structuredClone(origin)(现代运行时都支持),或者针对自己的数据结构写明确的复制逻辑。
3.3 原地去重:快慢指针加 length 截断
热词里出现了"快慢指针有序数组原地去重",这个恰好是数组原地操作的一个经典例子,我把它和 splice 对比着说。给定一个已排序数组,要求原地去重并返回新长度,快慢指针的写法是:
function dedupeSorted(arr) { if (arr.length < 2) return arr.length; let slow = 0; for (let fast = 1; fast < arr.length; fast++) { if (arr[fast] !== arr[slow]) { slow += 1; arr[slow] = arr[fast]; } } arr.length = slow + 1; // 直接截断,不用 splice return arr.length; }slow指向"已确认不重复"的最后一个位置,fast负责扫描。关键在于收尾用arr.length = slow + 1而不是splice。如果用 splice 去删中间的重复项,每次删除都要搬移后面所有元素,整体退化成 O(n²);而快慢指针是纯粹的覆盖写,O(n) 一趟扫完,末尾一次性截断。
3.4 场景到方法的对照
| 我要做的事 | 首选 | 避免 |
|---|---|---|
| 末尾追加、末尾取走 | push/pop | 无 |
| 头部追加、头部取走(短列表) | unshift/shift | 无 |
| 头部操作(长列表、热路径) | 环形缓冲区 / 下标指针 | shift/unshift |
| 按下标删除一个 | splice(i, 1) | delete arr[i] |
| 按条件过滤 | filter | 循环里splice |
| 取子集不改原数组 | slice | 循环push拼 |
| 取最后一个元素 | at(-1) | 手写arr[arr.length - 1] |
4. 那些"看起来删了其实没删干净"的写法:delete、length 赋值、重新赋值
4.1 delete arr[i] 留下的空洞
delete是操作对象属性的运算符,而数组的索引本质上就是属性名。所以delete arr[1]确实删掉了一个属性,但它不会改变 length,也不会把后面的元素往前挪。结果是数组里留了个洞:
const a = [1, 2, 3]; delete a[1]; console.log(a.length); // 3,不是 2 console.log(a); // [1, empty, 3] console.log(a[1]); // undefined console.log(JSON.stringify(a)); // [1,null,3]更麻烦的是遍历行为不一致:forEach和map会跳过空洞,for...of不会跳(它读到的就是undefined),Object.keys又读不到这个下标。同一份数据在不同遍历方式下结果不一样,这种 bug 极难排查。
性能上也有代价。带有空洞的数组在 V8 里会从 packed 变成 holey,严重的还会退化成 dictionary elements(类似哈希表),读写都比紧凑数组慢。所以数组元素删除只有两个正规出口:splice或filter。delete留给对象属性。
4.2 arr.length = 0 与 arr = [] 的引用差异
清空数组有两种写法,区别全在引用上:
function clearA(arr) { arr.length = 0; } // 清空原数组,所有引用都看到空数组 function clearB(arr) { arr = []; } // 只改了局部变量,外面看到的还是老数组 const data = [1, 2, 3]; clearA(data); console.log(data); // [] const data2 = [1, 2, 3]; clearB(data2); console.log(data2); // [1, 2, 3],没清掉arr.length = 0是真正意义上的清空,它还会把数组的容量保留下来,后续再 push 不用重新扩容。arr = []只是把当前作用域里的变量指向了一个新数组,原来那个数组如果还被别处引用,就完全没被影响。
顺带一提,arr.length = n在n小于当前长度时是截断,大于当前长度时会在尾部补空洞。后者非常危险,会让数组变成 holey,我基本只在确定要预分配并且紧接着就会fill的场景里才用。
4.3 类数组与 JSON 结构转成数组的正确姿势
实际项目里,"把不是数组的东西变成数组"是高频需求。几种情况要分开处理:
| 来源 | 推荐写法 | 说明 |
|---|---|---|
| 类数组(有 length、有下标) | Array.from(obj) | 比如arguments、DOM 查询结果 |
| 可迭代对象(Set、Map 的键) | [...set]或Array.from(set) | 两者等价,Array.from还能传映射函数 |
| 普通对象的值/键值对 | Object.values(o)/Object.entries(o) | 不要用for...in手动拼,顺序和继承属性都是坑 |
| JSON 字符串 | JSON.parse(str) | 解析结果本来就是数组,不需要再转 |
容易混的是[...obj]用在类数组上。如果对象没有实现Symbol.iterator,展开运算符会直接报 "obj is not iterable",而Array.from能兜住。所以处理不确定来源的数据时,我默认用Array.from。
5. 手写一个扛得住的双端队列:环形缓冲区的实现
5.1 为什么不用链表
提到"头部插入快",很多人的第一反应是链表。理论复杂度确实漂亮,但落到 JS 里有两个现实阻碍:一是每个节点是一个独立对象,插入要新建对象,删除后等着 GC 回收,高频场景下 GC 压力很明显;二是节点在内存里是散落的,访问时缓存命中率低,实测起来经常跑不过紧凑数组。所以对"定长、读写都在两端"的场景,环形缓冲区通常是更划算的选择。
环形缓冲区的思路很简单:底层开一块固定大小的数组,用head记录逻辑队首所在的下标,用size记录当前元素个数。插入和删除都只改这两个数字,不做任何搬移。
5.2 核心字段与边界处理
三个字段:_buf(底层存储)、_head(队首下标)、_size(元素个数)。所有逻辑下标到物理下标的映射靠取模:
_index(i) { return (this._head + i) % this._cap; }最需要小心的是head往左移的取模。JS 的%对负数返回负值,(-1) % 10是-1而不是9,所以要写成(this._head - 1 + this._cap) % this._cap,先加一个容量保证是正数。这个细节我在初版实现里漏过一次,表现是偶尔读到undefined,而且只在绕圈之后才复现。
5.3 完整实现
class RingBuffer { constructor(capacity = 1024) { if (!Number.isInteger(capacity) || capacity <= 0) { throw new TypeError('capacity must be a positive integer'); } // 用 fill 预填充,避免生成 holey 数组 this._buf = new Array(capacity).fill(undefined); this._cap = capacity; this._head = 0; this._size = 0; } get size() { return this._size; } get capacity() { return this._cap; } get isEmpty() { return this._size === 0; } _index(i) { return (this._head + i) % this._cap; } // 尾部插入,返回新长度 pushBack(v) { if (this._size === this._cap) this._grow(); this._buf[this._index(this._size)] = v; this._size += 1; return this._size; } // 尾部删除,返回被删元素 popBack() { if (this._size === 0) return undefined; const i = this._index(this._size - 1); const v = this._buf[i]; this._buf[i] = undefined; this._size -= 1; return v; } // 头部插入,返回新长度 pushFront(v) { if (this._size === this._cap) this._grow(); this._head = (this._head - 1 + this._cap) % this._cap; this._buf[this._head] = v; this._size += 1; return this._size; } // 头部删除,返回被删元素 popFront() { if (this._size === 0) return undefined; const v = this._buf[this._head]; this._buf[this._head] = undefined; this._head = (this._head + 1) % this._cap; this._size -= 1; return v; } at(i) { if (i < 0) i += this._size; if (i < 0 || i >= this._size) return undefined; return this._buf[this._index(i)]; } toArray() { const out = new Array(this._size); for (let i = 0; i < this._size; i++) out[i] = this._buf[this._index(i)]; return out; } clear() { this._buf = new Array(this._cap).fill(undefined); this._head = 0; this._size = 0; } _grow() { const next = new Array(this._cap * 2).fill(undefined); for (let i = 0; i < this._size; i++) next[i] = this._buf[this._index(i)]; this._buf = next; this._cap = next.length; this._head = 0; } }用起来和上面那四个方法的语义完全对齐,可以把unshift/shift直接替换掉:
const q = new RingBuffer(8); q.pushBack('a'); q.pushFront('b'); q.pushBack('c'); console.log(q.toArray()); // ['b', 'a', 'c'] console.log(q.popFront()); // 'b' console.log(q.popBack()); // 'c'5.4 实测差异与扩展方向
同样插入 5 万条,普通数组unshift与这个实现的pushFront,在我本机上的差距通常在一个数量级以上,而且这个差距在数据量继续增大时会继续拉开——因为环形缓冲区每次操作都是常数时间,没有累积效应。
几个可以按需补上的扩展:加[Symbol.iterator]让它支持for...of和展开运算符;加forEach便于直接渲染;如果你的业务是"保留最近 N 条",可以加一个pushBackDrop,满了就自动丢掉最老的一条,代码上就是满了先popFront()再pushBack()。
注意:
_grow里的复制顺序必须按逻辑顺序来,也就是_buf[this._index(i)],不能直接遍历_buf。因为底层数组的物理顺序和逻辑顺序在绕圈后是不一样的,这一点我在第一次写的时候栽过,表现是扩容之后元素顺序错乱,而且只在缓冲区写满一轮之后才出现。
6. 排查实录:三个真实报错的定位链路
6.1 "插到头部没生效":返回值被当成了数组
现象是消息列表永远是空的,控制台没有报错。排查链路是这样的:先看渲染层,发现它遍历的是空数组;往回追数组从哪来,发现是this.list = this.list.unshift(msg);打印this.list,类型是number,值是 1、2、3 递增。到此定位完成。
修复方式有两种,取决于你想不想要返回值:
// 只关心副作用 this.list.unshift(msg); // 想要"新数组"的语义,用展开运算符 this.list = [msg, ...this.list];我更推荐后者,原因是它不修改原数组,在依赖引用比较的框架里更省心。但要注意展开运算符每次都新建数组,如果这个列表很长、更新很频繁,还是要评估一下开销。
6.2 "长度对不上":稀疏数组加 delete
现象是列表渲染出来条目数比预期多,多出来的位置显示为空。排查时先打印list.length和list.length与Object.keys(list).length的差值,发现两者不一致,说明存在空洞。再全局搜索delete,找到delete list[index]。
修复就是换成splice,并且把正向循环改成倒序或者用filter。顺便建议团队里加一条 lint 规则,禁止对数组使用delete运算符——这类问题一旦出现在多人协作的项目里,光靠 code review 很容易漏掉。
6.3 "改了一个数组另一个也变了":引用与浅拷贝
现象是修改副本之后原数据也跟着变了。排查方法很直接:定义俩变量之后,先console.log(a === b)看是不是同一个引用,再看a[0] === b[0],如果是true就说明元素级也是共享的。
修复也要看层级:如果只是想切断最外层的引用关系,slice()、[...arr]、Array.from(arr)都够用;如果元素是对象且需要完全独立,用structuredClone。但要提醒一点,structuredClone不能处理函数、DOM 节点、以及某些类实例,遇到这些类型要么换方案,要么提前把数据整理成纯数据结构再复制。
最后分享一个我自己一直在用的判断习惯:动手在数组头部增删之前,先问自己三个问题——这个列表会不会超过一千条?除了头部,我还会不会在中间位置插删?这个数组有没有被别的地方引用着?三个问题的答案基本就决定了该直接用shift、该自己写环形缓冲区、还是该走不可变更新那条路。