ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

数组添加元素6种方法详解:从push到扩展运算符的完整对比

数组添加元素6种方法详解:从push到扩展运算符的完整对比 给数组添加元素这个问题听起来像编程入门第一课简单到有点无聊不就一个push吗但真到项目里你会发现加一个元素这个需求有太多变体想在数组开头加、想在中间某个位置加、想一次合并两个数组、想不改变原数组只生成一个新数组……每一种场景都有最顺手的写法。这篇文章就把数组添加元素的6个方法完整拆一遍以 JavaScript/TypeScript 为主顺带对比 C、Java 这些语言的对应操作。无论你是刚上手数组的新人还是写了几年业务代码的老手都建议对照着过一遍——其中至少有一两个操作大概率是你平时忽略掉的。1. 数组添加元素一个比push复杂得多的问题1.1 数组不是固定大小的木箱子很多人刚学编程时会把数组理解成一排整齐的格子好像长度是固定的。这在 C 语言的原生数组里基本成立但在 JavaScript、TypeScript、Python 以及 C 的vector、Java 的ArrayList里数组或数组类容器是动态的你可以不停地往里面塞东西它会自己扩容。拿 JS 数组来类比它更像一个可以随时加桌的火锅店顾客多起来了你就往里加桌子根本不用一开始就数好店里要放几张桌。C 语言的原生数组则像一节已经定好座位的火车车厢你没法在天花板上再加一排座椅只能在下一次造车厢时就规划好。搞清楚这个前提很重要因为给数组添加元素的6个方法本质上都是在回答同一件事我能不能在不引起歧义的前提下把新元素放到我希望它出现的位置1.2 六个方法的全局视图在深入代码之前先把整张地图铺开方法写法示例是否改变原数组返回值典型场景pusharr.push(item)是新数组长度尾部追加unshiftarr.unshift(item)是新数组长度头部插入splicearr.splice(index, 0, item)是被删除项数组任意位置插入concatarr.concat(item)否新数组合并数组/不可变操作扩展运算符[...arr, item]否新数组不可变操作/React 状态更新length赋值arr[arr.length] item是item追求性能/底层优化你发现没有6个方法其实分了两派。一派是就地修改派操作完原数组就变了另一派是返回新数组派原数组像被按了个快照一样原地不动。这个差异决定了你会在什么时候用哪个方法后面第4章我会专门展开。光看这张表你可能觉得这不就是文档里抄来的分类吗其实不是真正重要的是排列逻辑我按使用频率从高到低排也在暗示你日常开发的思考顺序。绝大多数业务需求先想尾插尾插不行再想头插和任意位置从业务语义上它们仨是互斥的然后再考虑能不能接受原数组被改不能接受就切到concat或扩展运算符。最后的length赋值更像一个备用轮胎知道有它存在就行不需要日常用。另外值得一说的是这6个方法并不是 JS 的专利。你带着这几个语义去学 C 的vector、Java 的ArrayList、Python 的list会发现只是换了一层皮底层逻辑完全一致。所以这篇文章表面是在讲数组的6种写法内核其实是在讲动态数组到底怎么往自己身体里塞新元素这件事。2. 六个方法逐个拆解从最常用到最冷门2.1 push尾部追加90%场景的首选push的语义是在数组末尾追加一个或多个元素返回值是追加之后数组的最新长度而不是数组本身。这个返回值经常被忽略但如果你需要拿新长度做判断它就能省一行代码。const visits [1, 2, 3]; const lengthAfterPush visits.push(4, 5); console.log(lengthAfterPush); // 5 console.log(visits); // [1, 2, 3, 4, 5]push最典型的应用场景是日志收集、消息队列、统计计数这类新数据不断产生往末尾追加的地方。它的平均时间复杂度是 O(1)偶尔触发底层扩容会稍慢但日常业务体感几乎没有差别。JS 引擎对push的优化做得很好很多底层的push调用甚至会被 JIT 直接内联所以你不用过于担心它的性能。这里有个容易踩的小坑如果你想一次性把一个数组的所有元素追加到另一个数组直接arr.push(otherArr)会把整个数组塞进去变成嵌套元素正确做法是arr.push(...otherArr)。这个问题我在第5章还会单独拿来说。2.2 unshift头部插入功能对称但注意性能unshift和push是一对镜像在数组开头插入一个或多个元素同样返回新长度。const logs [render, click]; logs.unshift(init); console.log(logs); // [init, render, click]适合的场景很明确最新消息要排在最前面比如聊天记录、更新日志、通知列表。但要知道它的代价数组是连续存储的头部插入意味着所有已有元素都要往后挪一位时间复杂度 O(n)。数据规模小无所谓一旦到了几万条还在频繁unshift会明显卡顿。真需要同时操作头和尾建议考虑双端队列deque而不是直接在上万长度的数组上unshift。实际开发里我见过不少把unshift当push用的代码图的就是最新数据显示在第一行。如果这份数据只在页面上展示渲染前arr [...newArr, ...arr]其实更符合不可变操作的习惯如果这份数据还要被其它模块共享引用unshift就地改掉原数组很可能埋下隐患。2.3 splice任意位置插入功能最强也最容易用错splice是数组的瑞士军刀能删、能插、能替换。给数组添加元素时我们通常这样用const months [Jan, March, April]; months.splice(1, 0, Feb); console.log(months); // [Jan, Feb, March, April]它接收三个关键参数开始下标start、要删除的个数deleteCount、要插入的元素...items。想在 index 位置插入就把deleteCount设成 0。返回值是被删掉的元素组成的数组插入场景下通常是空数组[]所以很多人不接收它。最容易出错的地方是第二个参数。很多人以为arr.splice(1, Feb)是在下标1插入Feb。实际上字符串Feb会被转换为数字 NaNdeleteCount为 NaN 时被当作 0所以数组不会发生任何变化。你期待的是插入结果什么都没变而且代码也不报错这就非常恶心。如果你传的是arr.splice(1, 2, Feb)那就更糟了会被解释成删掉下标1开始的两个元素再插入 Feb数组内容完全偏离预期。此外负数索引在splice里表示从末尾倒数。arr.splice(-1, 0, x)会在倒数第一个位置前插入x等价于在最后一个元素前面插。这个特性很实用但也是排查问题时容易忽略的点。2.4 concat合并数组返回新数组concat的定位是不修改原数组返回一个融合后的新数组。它可以接收普通值也可以接收数组甚至可以一次性传多个数组const base [a, b]; const combined base.concat(c, [d, e]); console.log(combined); // [a, b, c, d, e] console.log(base); // [a, b]多看两遍输出你会发现原数组base没动。所以concat天然适合那些不该有副作用的场景。但正因为它不改变原数组新手用它的第一个 bug 往往是写了arr.concat(item)却没有接收返回值然后一脸疑惑地说数据怎么没加进去concat的返回值是新数组你不用接住它就是一个被遗忘的新对象。concat还有一个容易被忽略的细节如果你传入的是一个数组它默认展开一层再拼接所以不会再出现像push那样的嵌套问题。这个特性让它特别适合做基于原数组合并数据的逻辑。2.5 扩展运算符不可变操作的首选写法[...arr, item]和[item, ...arr]是 ES6 之后最优雅的数组添加元素方式。它们不改变原数组而是把一个新数组表达出来const arr [1, 2, 3]; const addedToEnd [...arr, 4]; // [1, 2, 3, 4] const addedToHead [0, ...arr]; // [0, 1, 2, 3] const merged [...arr, ...[9, 8]]; // [1, 2, 3, 9, 8]为什么我在现代前端项目里特别推荐这个写法因为 React、Vue 这类框架的核心更新思路是用新的引用触发渲染。你写state.list.push(item)再setState拿到的还是同一个数组引用框架很容易认为数据没变写成setState({ list: [...state.list, item] })新引用一换渲染自然触发。它和concat的区别是扩展运算符更简洁语法天然地能放在数组字面量里的任意位置可读性更好。唯一的限制是浅拷贝如果数组里装的是对象或嵌套数组展开后复制的是引用不是深拷贝。改新数组里的对象属性原数组对应对象也会变化。这个特性够用但也别指望它帮你做深拷贝。2.6 直接赋值 arr[arr.length] item接近底层的高效写法最后一个方法严格说起来不算方法而是一种基于数组索引的赋值技巧const arr [1, 2, 3]; arr[arr.length] 4; console.log(arr); // [1, 2, 3, 4]arr.length永远是当前最后一个元素的下一个位置所以arr[arr.length] item和push(item)在这个场景下等价。如果你去看一些基础库的源码会发现有人用它代替push理由是少一次函数调用、避免 push 内部的参数解析在极端性能敏感的循环里也许能省出一星半点。但我不建议你在业务代码里这么写。一方面可读性差后维护的人需要停顿一下才反应过来这是在尾部添加另一方面它和图省事的直接索引赋值只有一线之隔一旦你写成了arr[5] item而当前 length 只有 3数组就会变成稀疏数组中间出现多个 empty 空位。关于这个坑第5章有专门的排查记录。3. 不同语言环境下的添加元素对应关系写完 JS 的6种玩法很多人会拿着这个问题去问其他语言。其实底层规律是相通的尾插、头插、中间插、合并、不可变更新这些语义在哪个语言里都存在只是换了一身马甲。3.1 TypeScript类型约束下的添加元素TypeScript 对数组添加元素的约束是一个好心的看门人。你在number[]上push(abc)编译器会直接报错这能拦下一大批运行时类型错误。但类型更严格的地方在于readonly和元组。ReadonlyArraynumber上不允许调用push、unshift、splice这类变更方法你的添加只能走concat或扩展运算符生成新数组这在设计上就是逼着你走不可变路线const list: ReadonlyArraynumber [1, 2, 3]; // list.push(4); // 报错 const extended [...list, 4]; // 推荐元组tuple的情况更微妙。const t: [number, string] [1, a]你如果还想往里加元素会发现类型系统并不允许原因很合理元组的长度是类型的一部分加一个元素就不是这个类型了。TypeScript 5.0 之后对元组上的push报错也越来越严格这其实是好事逼着你在建模阶段就确定好数据结构。3.2 C 的 vector原生数组的替代者C 语言的原生数组长度固定想添加只能靠我一开始就把数组申请大一点再用一个计数器记住当前用了多少非常原始。真正好用的是 C 标准库里的std::vector#include vector #include string std::vectorstd::string files; files.push_back(a.log); // 尾部添加 files.emplace_back(b.log); // 原地构造效率更高 files.insert(files.begin(), head.log); // 头部插入关于emplace_back和push_back经验之谈是如果添加的是基本类型或者可以拷贝的对象两者差别不大如果对象构造成本高emplace_back直接在容器内存里构造能省一次临时对象的构造和拷贝。这种性能和 JS 里的 push/unshift 之争不完全一样但思路都是少做无用功。需要特别记住的是vector扩容会让迭代器失效尾部添加可能触发重新分配内存原来保存的迭代器、指针、引用统统作废。这在多线程场景下尤其危险两个线程一边push_back一边读同一个大数组不做好同步轻则读到错数据重则直接崩。这也是 C 里给数组添加元素最常见的并发坑。3.3 Java 的 ArrayList 与 Arrays 工具类Java 里最常用的动态数组容器是ArrayListListString list new ArrayList(); list.add(one); list.add(0, zero); // 任意位置插入它的add(E e)对应尾插add(int index, E e)对应任意位置插入。原生的int[]想添加元素比较麻烦只能借助Arrays.copyOf扩容int[] arr {1, 2}; arr Arrays.copyOf(arr, arr.length 1); arr[arr.length - 1] 3; // [1, 2, 3]这种复制再赋值的写法本质上就是 JS 里concat/ 扩展运算符生成新数组的思路只是 Java 原生数组没有现成的语法糖只能靠工具方法。顺便一提Java 面试里常问的数组赋值给另一个数组底层也离不开System.arraycopy或Arrays.copyOf和添加元素是两码事但经常被放到一块问。3.4 Python、PHP 的对应写法Python 里是append尾插、insert任意位置、extend合并一个可迭代对象items [1, 2, 3] items.append(4) items.insert(0, 0) items.extend([5, 6])PHP 里有array_push、array_unshift用法跟 JS 几乎一一对应。你会发现只要理解了尾插 O(1)、头插 O(n)、中间插要挪元素、合并要认清新旧数组这四条基本规律语言差异只是查个文档的问题核心心智模型完全一致。4. 实战选型性能、副作用与代码可读性的权衡方法都介绍完了接下来聊聊怎么选。选错方法不会报错但会在性能、协作、维护性上持续产生隐性成本。4.1 六种方法的性能差异把复杂度摆在桌面上方法时间复杂度是否可能触发扩容/复制适用量级push摊还 O(1)偶尔日常均可unshiftO(n)每次都要后移元素小数组可以用splice中间插入O(n)需要后移插入位之后的元素小数组/低频concatO(n)生成新数组对原数组无副作用时优先扩展运算符O(n)生成新数组不可变场景优先arr[arr.length]摊还 O(1)同 push追求极致性能经验数据是在几万级别的数组上反复unshift耗时会明显比push高一个量级splice往数组头部附近插入也是一样因为插入位置越靠前需要挪动的元素越多。判断方法很简单别背数字记住挪的人越多越慢。4.2 副作用管理你的原数组还要不要用这是我在 code review 里最常指出的问题。项目里很多人写了一年多业务仍然搞不清楚哪些方法会改掉原数组。push、unshift、splice会改原数组concat、扩展运算符不会改。就这么个简单的二分法能挡住大多数状态管理 bug。拿 React 举例最常见的一种写法是const handleAdd (item) { list.push(item); // 直接改了 state 里的原数组 setList(list); // 引用没变视图可能不更新 };改成setList([...list, item])就稳稳的。在 Vue3 里虽然响应式能侦测到数组变化但如果某个组件依赖的是list引用做缓存直接改原数组也可能让缓存失效。所以我的习惯是只要是往状态里加元素一律走不可变操作只有处理临时变量、局部列表时才放心用push。4.3 可读性选择方法其实是在向队友传递意图方法选择不光是性能问题它还在表达你的意图。push在说我要追加unshift在说我要把新元素放到最前面splice在说我要在指定位置处理扩展运算符在说我不动原数据。读到代码的人不需要看上下文就能大概猜到你要干什么。所以团队规范里我们一般这样约定没有特殊理由默认用push追加需要头插先想清楚为什么不用尾部需要任意位置插入必须写注释说明插入的位置和原因追求不可变更新用扩展运算符不到性能瓶颈别用arr[arr.length]这种偏底层的写法。代码评审时看到有人写arr[arr.length] x第一反应一定是问这里为什么不能用push5. 踩坑实录六个方法引发的奇怪问题写了好几年数组操作我从线上 bug 和面试题里捞出来几个典型问题都跟添加元素直接相关。5.1 push 一个数组变成了嵌套数组这个坑出现率极高。你想把b的所有元素加到a后面于是写了const a [1, 2]; const b [3, 4]; a.push(b); console.log(a); // [1, 2, [3, 4]]在 JS 里push只会把传入内容当一个值塞进去不会自动展平数组。你需要的是a.push(...b)或者干脆用a [...a, ...b]。我排查过的很多数组莫名其妙多了一层的问题最后都找到这一行。5.2 splice 少了 deleteCount数据被意外删除有次同事反馈页面上一个排序列表点完某个按钮后选中项后面的数据突然少了两个。我让他在操作后打断点看数组发现问题出在这一行arr.splice(index, newItem);他本来想在第 index 个位置插入newItem但splice的真实签名是splice(start, deleteCount, ...items)。少了中间的 0newItem被当成了要删除的个数——字符串newItem被隐式转成数字变成 NaNNaN 又会被当成 0所以没删但如果是数字类型的变量比如arr.splice(1, 2)那就等于从下标1开始删掉2个元素。排查链路很简单先确认length是不是少了再看splice第二个参数是不是被填成了别的值最后把参数改成arr.splice(index, 0, newItem)。修完就再也没复发过。5.3 直接按索引赋值数组变成了稀疏数组用arr[5] x给一个 length 只有3的数组赋值结果 length 会变成6中间4、5下标的元素是emptyJS 将其视为空位。const arr [1, 2, 3]; arr[5] x; console.log(arr); // [1, 2, 3, empty, empty, x]麻烦在于空位在遍历时的表现非常鬼畜forEach会跳过空位map会保留空位JSON.stringify会把空位序列化成null。同一个数组不同方法看到的内容居然不一样。所以在常规业务代码里我极度不建议靠索引跳跃赋值来添加元素老老实实push。5.4 concat 返回值没接住等于白操作这是个看起来没报错但数据没变的典型。concat不修改原数组它的所有结果都体现在返回值里。如果你写的是let arr [1, 2]; arr.concat(3); console.log(arr); // [1, 2] 第一次见这种 bug 的人会怀疑人生但原因很简单concat生成了新数组[1,2,3]旧arr依然指向[1,2]新数组被丢弃了。改成arr arr.concat(3)才能看到效果。这也是为什么我建议在不可变场景优先用扩展运算符——[...arr, 3]的赋值意图看起来更明确不容易漏掉。5.5 添加前要不要先防重复最后一个经验跟数组添加元素关系紧密又总被忽略很多业务需求并不是无条件添加而是不重复才添加。比如收集用户选择的手机号你每次都push选两次同一个号码列表就多了一条。一次点击一次重复列表越来越臃肿去重成本越来越高。最简单的做法是添加前判断if (!arr.includes(item)) { arr.push(item); }如果数组很大、判断很频繁includes是 O(n) 扫描会有性能压力。这时候建议维护一个Set作为已存在集合用 O(1) 的has判断然后再往展示用的数组里pushconst set new Set(arr); const add (item) { if (!set.has(item)) { set.add(item); arr.push(item); } };这段经验是从一个线上 bug 里总结出来的用户反复提交表单数组数据量飙到上万前端页面卡得没法点。加了Set去重之后数据量立刻回归正常。添加元素从来不只是塞进去那么简单先想清楚该不该加比用哪个方法更重要。这些东西看着基础却会在真实项目里反复出现。我现在写数组添加已经习惯先问自己三个问题改不改原数组插在哪个位置需不需要防重复把这三个问题想清楚你写出来的代码会稳很多别人 review 时也能少挠几次头。
返回列表