ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

JS数组指定位置插入元素全攻略:从splice到不可变更新

JS数组指定位置插入元素全攻略:从splice到不可变更新 做前端或者Node.js开发这几年我几乎每周都会遇到一个看着简单、实际总有幺蛾子的需求往数组指定位置塞一个新元素。任务列表要插队、表格中间要加列、后端返回的数据要在渲染前调个顺序——这些场景本质上都是同一个操作。今天我把“在JS数组指定位置插入元素”这个题目聊透从最常用的splice方法到不修改原数组的现代写法再到对象数组、类数组这些容易踩坑的情况一次性说清楚。文章里我会附上大量可以直接抄的代码和实际踩坑记录适合刚接触数组操作的新手也适合想系统检查自己有没有漏掉坑的中级开发者。1. 从需求场景说起先认清“插入”的本质1.1 哪些业务代码里会高频用到指定位置插入我在实际项目里遇到最多的场景是这几类有序列表的手动调整。比如后台管理系统里的菜单排序用户把“订单管理”拖到第二个位置前端拿到拖拽结果后需要在原数组里把对应项插到指定索引处。数据预处理。后端接口返回的报表数据缺了某个维度前端需要在固定位置补一个“合计”或者“平均值”的数据项再交给表格组件渲染。中间态数据的推进。一个审批流程步骤是数组形式新增了一个会签节点必须插在某个环节之后而不是简单追加到末尾。优先队列的模拟。比如前端维护一个自动执行的任务队列新任务权重高就插到队列靠前的位置。这些场景的共同点是数组本身已经有序插入位置由业务逻辑决定不能简单 push 或者 unshift。可以说splice是这类需求的官方标准解法但只了解splice还不够——实际开发中你还会遇到要保留原数组、要插入多个元素、操作的是对象数组等更复杂的情况。1.2 直接赋值 arr[index] value 为什么不行新手最容易写出的代码是const list [a, b, d, e]; list[2] c; console.log(list); // [a, b, c, e]你原本想把c插到索引2的位置结果发现d被覆盖了。这不是“插入”这是“替换”。插入的含义是原位置的元素及其后面的所有元素都要往后挪一位数组长度也要加1。还有一种更隐蔽的写法特别是往空数组的指定位置赋值const arr []; arr[5] hello; console.log(arr.length); // 6 console.log(arr); // [ 5 empty items, hello ]这里数组长度变成了6但索引0到4的位置全是“空槽”empty slot不是undefined也不是null。这种稀疏数组在后续遍历的时候坑就来了arr.forEach(item console.log(item)); // 只输出 hello arr.map(item item !); // [ 5 empty items, hello! ]forEach会直接跳过这些空槽你表面上看到的length和实际元素数量对不上排查起来相当费劲。所以直接赋值的本质问题是它只改了一个位置的“值”没有动索引结构更不会补全中间的空槽。2. 正统解法splice 方法完整拆解2.1 语法、参数和返回值一次看明白splice是数组的原生方法它的完整语法是array.splice(start, deleteCount, item1, item2, ...)三个关键部分start开始操作的索引位置。deleteCount要从 start 位置删除多少个元素。插入场景下这个值必须填0。item1, item2, ...要插入的新元素可以是零个、一个或多个。在指定位置插入元素的经典写法就是const arr [a, b, d, e]; arr.splice(2, 0, c); console.log(arr); // [a, b, c, d, e]这一步干了三件事把索引2及之后的元素往后挪一位、腾出空位、把c放进去、数组长度从4变成5。需要注意splice是原地修改数组的方法它操作的是原数组本身而不是返回一个新数组。它的返回值是被删除的元素组成的数组。因为插入场景删除了0个元素所以返回值永远是一个空数组[]。2.2 负数索引和越界索引的实际行为splice的start参数支持负数从数组末尾往前数const arr [a, b, c, f]; arr.splice(-1, 0, d, e); // 从倒数第1个位置开始也就是索引3的位置之前插入 console.log(arr); // [a, b, c, d, e, f]这里的-1等价于arr.length - 1也就是在最后一个元素前面插入。这个特性在“在末尾追加/在末尾之前插入”这种需求中非常方便。如果传入的索引超过了数组长度splice不会报错而是自动把插入位置设置为数组末尾const arr [a, b]; arr.splice(10, 0, c); console.log(arr); // [a, b, c]反过来如果传入的start是负数且绝对值大于数组长度则会被当作0处理const arr [a, b]; arr.splice(-10, 0, c); console.log(arr); // [c, a, b]还有一种情况是传入NaN或者非数字会先被转成数字通常变成0const arr [a, b]; arr.splice(0, 0, c); // 0 会被转成数字 0 console.log(arr); // [c, a, b]这些边界行为平时用不到但如果你在做工具函数封装就必须考虑到否则用户传一个奇怪的索引进来结果不和预期你还要背锅。2.3 插入多个元素和“查找后插入”的组合用法splice的第三个参数开始可以传任意多个元素意味着一次插入多个值const arr [1, 5]; arr.splice(1, 0, 2, 3, 4); console.log(arr); // [1, 2, 3, 4, 5]这在批量插入场景非常实用不需要循环多次调用splice。实际业务中更多时候我们是“在某个指定元素后面插入”而不是在指定索引插入。这时候要先用indexOf找到目标的位置const animals [cat, dog, tiger]; const index animals.indexOf(dog); if (index ! -1) { animals.splice(index 1, 0, rabbit); } console.log(animals); // [cat, dog, rabbit, tiger]indexOf找不到目标时返回-1如果不做判断就插入等于splice(0, 0, rabbit)会把元素插到数组头部这个 bug 我见过不止一次。另外indexOf对对象数组是按照引用比较的如果你要找的是某个对象必须先找到它的索引或者用findIndex搭配条件判断。3. 不修改原数组的现代写法展开运算符与 slice3.1 为什么有时候必须用不可变方式我在写 React 组件时经常会遇到一个限制不能直接修改 state 数组。React 的 state 更新机制要求每次返回一个新数组否则组件不会重新渲染。不止 ReactVue 3 的响应式虽然能感知到原地修改但为了数据流清晰、避免隐式变更团队规范里也会要求尽量用不可变更新。这种需求下splice就不能直接用了需要一种“不修改原数组返回新数组”的插入方案。3.2 展开运算符方案最推荐的日常写法ES6 的展开运算符配合slice可以非常优雅地实现不可变插入const arr [a, b, d, e]; const index 2; const newArr [...arr.slice(0, index), c, ...arr.slice(index)]; console.log(newArr); // [a, b, c, d, e] console.log(arr); // [a, b, d, e] 原数组没变原理也很好理解arr.slice(0, 2)取出索引0和1的元素arr.slice(2)取出索引2及之后的元素。中间塞一个c再用展开运算符拼成一个新数组。整个过程是纯函数式的不会污染原数据。这段代码可读性很高几乎不需要注释别人就能看懂。唯一的缺点是如果数组很长、插入位置很靠前中间的数组拷贝会多一点开销但绝大多数业务场景规模下完全不用纠结。如果还想更通用一点可以封装成工具函数function insertAt(arr, index, insertItems) { return [ ...arr.slice(0, index), ...insertItems, ...arr.slice(index) ]; }我这里把insertItems设计成数组而不是用剩余参数是为了调用方可以更灵活地传入一个数组变量尤其是从接口拿回来的数据。3.3 slice 加 concat 的经典组合性能差异到底大不大在 ES6 之前比较常见的不可变插入写法是slice加concatconst arr [a, b, d, e]; const index 2; const newArr arr.slice(0, index).concat([c], arr.slice(index)); console.log(newArr); // [a, b, c, d, e]concat会自动展开一层数组参数所以中间那层[c]不是多余的它保证了插入的是元素c而不是一个嵌套子数组。展开运算符方案和concat方案在结果上完全一致差别主要在展开运算符更直观和数组字面量配合自然也是目前社区的主流写法。concat是 ES5 时代的老写法在老旧浏览器兼容性上有一点优势但今天基本可以忽略。性能上两者都是O(n)复杂度因为都要复制数组。在几十万条数据的极端场景下splice原地修改会快一些因为它只需要移动元素而不必复制整个数组。我用一个10万元素的数组做过简单计时对比splice插入一次大概零点几毫秒展开运算符方案大概几毫秒。说实话对绝大多数前端场景来说这点差异体感为零。但如果你在一个高频触发的方法里反复做这种操作比如拖拽排序过程中每帧都重排数组那建议考虑用splice原地修改或者直接用专门的数据结构比如链表。4. 进阶场景对象数组、类数组、Map 与 Set4.1 对象数组插入时引用语义要注意实际业务里数组里通常不是字符串而是一堆对象。插入操作语法上和基本类型没区别const users [ { id: 1, name: 张三 }, { id: 3, name: 王五 } ]; const newUser { id: 2, name: 李四 }; users.splice(1, 0, newUser); console.log(users); // [{ id: 1, name: 张三 }, { id: 2, name: 李四 }, { id: 3, name: 王五 }]但这里有一个很容易被忽略的点插入的是对象的引用不是深拷贝。也就是说插入后如果你改了newUser.name数组里对应的元素也会变反过来如果你通过数组改了对象的属性原来的newUser变量也会同步变化。这不是 bug是 JS 对象赋值天然的行为但如果你是从接口拿到数据再塞进表格又在表格里编辑了这行数据就要注意别把原对象意外改了。另外如果插入的数据还需要在多个地方共享并且后续会被修改建议插入前先做一次浅拷贝const newUser { id: 2, name: 李四 }; users.splice(1, 0, { ...newUser });4.2 类数组对象arguments、NodeList 不能直接 splice函数内部的arguments对象长得像数组也有length属性但它不是真正的数组没有splice方法。直接调用会报错function demo() { arguments.splice(1, 0, x); // TypeError: arguments.splice is not a function }同样的道理document.querySelectorAll返回的NodeList也不是数组。处理这类数据前需要先转成真正的数组function demo() { const args Array.from(arguments); args.splice(1, 0, x); console.log(args); } demo(a, c); // [a, x, c]Array.from和展开运算符都能完成转换const nodeList document.querySelectorAll(li); const arr [...nodeList];转成真数组后再执行splice就没有任何限制了。这个小问题我见过不少次特别在写公共方法时处理“可变参数”的场景一不注意就会掉进这个坑。4.3 在 Vue、React 中插入元素要注意响应式规则如果你在 Vue 2 项目里维护一个数组并且要在指定位置插入元素直接用splice反而是正确姿势this.items.splice(this.index, 0, newItem);因为 Vue 2 对数组做了拦截重写了splice、push等方法用它们修改数组能触发视图更新。但如果你用this.items[0] xx这种方式直接改索引Vue 2 是感知不到的。所以从响应式角度来说splice在 Vue 2 里是必须优先选择的。Vue 3 基于 Proxy 重写了响应式直接按索引赋值也能被追踪。但在 React 中无论哪个版本都要求不修改原数组// React 中不能这样写 const [items, setItems] useState([a, c]); items.splice(1, 0, b); setItems(items); // 不会触发渲染 // 应该这样写 const newItems [...items.slice(0, 1), b, ...items.slice(1)]; setItems(newItems);所以 React 项目里几乎都是用展开运算符那一套很少直接用splice。如果要在Map结构中按顺序插入那就更绕一层Map本身没有splice方法需要先转成数组、插入、再重新构造const map new Map([[a, 1], [c, 3]]); const entries Array.from(map.entries()); entries.splice(1, 0, [b, 2]); const newMap new Map(entries); console.log([...newMap]); // [[a, 1], [b, 2], [c, 3]]Set也一样它只保证元素唯一不保证稳定的索引插入规则我一般直接转数组处理。5. 常见问题与避坑速查5.1 索引越界的静默成功最容易被忽略splice的索引越界不会报错这是它“友善”的一面也是它“坑人”的一面。很多时候你插错了位置程序不报错数据看起来也正常等到后续逻辑对顺序有严格要求时问题才暴露出来。我踩过一个典型例子分页器组件里用户在当前页中间插入一条新数据我计算出的目标索引超过了当前页数组的长度splice就把新数据扔到了末尾导致新数据没有出现在用户看到的中间位置而是出现在了列表底部。查了很久才发现是索引计算逻辑有边界问题。所以建议在关键业务里加一个防御性判断function safeInsertAt(arr, index, item) { if (index 0 || index arr.length) { throw new RangeError(index ${index} 超出数组范围); } arr.splice(index, 0, item); }或者你更希望静默处理越界那就显式把索引收敛到合法范围function insertAt(arr, index, item) { const target Math.min(Math.max(index, 0), arr.length); arr.splice(target, 0, item); }无论用哪种策略都比你什么都不管要好至少行为是可预期的。5.2 splice 返回值不是插入后的数组新手最大的误解之一是以为splice会返回新数组结果用的时候拿返回值继续操作发现得到的是空数组。再来强调一下splice的返回值是被删除的元素组成的数组。插入场景返回值是[]。修改的是原数组原数组就是插入后的结果。如果你写成const arr [1, 2, 4]; const result arr.splice(2, 0, 3); console.log(result); // [] console.log(arr); // [1, 2, 3, 4]很多人误以为result会是[1, 2, 3, 4]。这种混淆在写链式调用的时候尤其危险比如arr.splice(...).map(...)会直接报错。记住它返回的是被删元素数组而不是新数组很多问题就不会发生了。5.3 顺手整理的“指定位置插入”高频踩坑速查表场景错误示范正确做法原因往中间插一个元素arr[2] itemarr.splice(2, 0, item)直接赋值是替换不是插入在空数组指定高位索引插入arr[5] item要么补全空位要么明确使用splice并处理索引直接赋值会产生稀疏数组把splice返回值当新数组const copy arr.splice(2,0,x)使用slice 展开运算符或直接使用原数组splice返回的是被删元素数组在arguments上调用splicearguments.splice(1,0,x)先Array.from(arguments)再操作arguments是类数组不是真数组React state 数组插入直接state.splice()[...state.slice(0,i), item, ...state.slice(i)]React 要求不可变更新在指定对象后插入users.splice(users.indexOf(obj)1,0,item)不判 -1判断index ! -1后再插入indexOf找不到返回 -1会插到开头这张表里的前几行基本覆盖了我这几年在 code review 时反复看到的问题。你可以直接拿它当自检清单。5.4 关于稀疏数组的后置隐患再多说几句前面提到直接给高位索引赋值会产生稀疏数组。插入操作本身不会制造稀疏数组但如果你在稀疏数组上执行splice行为也会比较微妙const sparse []; sparse[3] d; sparse.splice(1, 0, x); console.log(sparse); console.log(sparse.length); // 5这时候索引会被重新排列空槽依然存在遍历时forEach等数组方法仍然会跳过这些空槽导致“数组有 length 个元素却只遍历出 2 个”的诡异现象。遇到这类问题先检查是不是哪个环节制造了稀疏数组是最快的排查思路。6. 一点个人实战经验先说明一下最后这一段是纯经验之谈。我平时写数组插入相关代码时有一个习惯先确认这个数组将来会不会被多个函数共享、会不会被放进状态管理工具里。如果答案是“会”我基本不会用splice而是优先写不可变方式即使多一次数组复制的开销。原因很简单共享数组一旦被原地修改排查难度是指数级上升的你根本不知道是哪个写入了这个数组、为什么顺序变了。不可变更新虽然改动多几个字符但数据流清晰出了问题也好定位。另一个小技巧是在团队工具函数库里封装一个insertAt方法把越界处理、类型校验、浅拷贝这些细节全部收敛在里面业务代码只关心“往哪个位置插什么”。这个方法我用下来不仅是减少重复代码的问题更是把容易出错的边界逻辑集中隔离了。最后再推荐一个小习惯写完插入逻辑后顺手写一个断言测试验证插入前后数组长度变化、插入元素位置对不对、原数组是否被修改。别觉得小题大做数组插入这种操作太基础了基础到大家都觉得不会出错结果真出错的时候往往是藏在很不起眼的索引计算里。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进