做 JS 开发这么多年,要说哪个操作最不起眼,我第一个想到的就是"往列表里加数据"。不管是接口返回的数据要拼到表格里,还是把用户勾选的项塞进一个数组准备提交,甚至你在写类似影视网站那种循环拉取列表的逻辑,第一步几乎都是同一个动作:把新数据放进已有的列表中。可就是这么个基础操作,我在带新人的时候看过的写法五花八门,有直接 push 的,有 unshift 的,有拿 concat 重新赋值的,还有用扩展运算符把所有数据展开再塞进新数组的。每种写法在特定场景下都没错,但放到另一个场景里就可能埋雷。
这篇内容主要围绕"JS 如何把数据添加到列表中"这件事展开,把数组和类数组对象的增删改查、批量合并、去重判断、框架响应式更新这些场景全部串一遍。适合刚入门的前端新手,也适合写了两年业务代码但一直没系统梳理过数组 API 的开发者。老实说,很多线上 bug 不是你不会写 push,而是没想清楚"往哪个列表加、加之前要不要查重、加完之后视图会不会更新"这三件事。
1. 先把需求看清楚:列表到底是什么,往哪加
1.1 数组与类数组对象的区别
在 JS 里,"列表"这个概念最直接的落地就是数组。数组是一种有序的数据集合,通过索引访问,长度是动态的。这是大多数人脑子里的第一反应,但实际项目里我们还会遇到很多"长得像数组但不是数组"的东西。
比如 document.querySelectorAll 返回的 NodeList,还有函数内部的 arguments 对象。这些都是类数组对象,它们有 length 属性,也可以按下标访问,但问题是你直接对它调用 push 方法会报错,因为原型链上根本没有 push 这个方法。
我记得有个真实问题就出在这。某个页面里用 document.getElementsByClassName 拿了一组 DOM 节点,然后想往里面追加一个元素,直接调 push 就报了 "list.push is not a function"。这种情况就得先把类数组转成真正的数组,最常用的就是 Array.prototype.slice.call(list),或者更现代的 Array.from(list)。转完之后 push、splice 这些方法就都能用了。
所以,添加数据之前先搞清楚你的"列表"是真正的数组,还是类数组,还是某些框架封装过的响应式数组对象。这是个非常基础但非常关键的判断。判断错了,后面所有的操作都会跑偏。
1.2 添加数据的几种核心场景
日常开发中"往列表添加数据"一般逃不开这几类场景。
第一类,采集类追加。比如你做一个下拉加载更多的功能,接口每次返回一页数据,你得把新页的数据追加到列表尾部。这是 push 最典型的场景,也是性能上最优的选择,因为数组尾部的插入不需要移动其他元素。
第二类,头部插入。比如一个消息列表,新消息来了要显示在最上面,那就得往头部插,unshift 就是干这个的。但这里有个性能隐患,unshift 会导致所有已有元素的索引往后挪,如果列表很大、操作又频繁,性能就会差一些。
第三类,指定位置插。比如排序列表中某个节点需要精确插到某个位置,splice 派上用场。第四类,批量合并。比如你从两个接口分别拿到了两个列表,要合成一个完整的列表展示,这时候 concat 或者扩展运算符就是首选。
还有一类是业务上很常见的"有则更新,无则添加",这需要先查找再决定要不要加。说实话,这些问题单独看都不难,但它们在业务中组合出现的时候,很多人就开始混乱了。下面的内容我按 API 逐个展开,把每个方法的语义、边界和坑都讲清楚。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 数组添加数据的基础 API:push、unshift、splice 怎么选
2.1 push:最常用也最安全
push 方法用于在数组末尾添加一个或多个元素,返回新数组的长度。它修改的是原数组本身,属于原地操作。
js复制const list = ['a', 'b'];
const newLength = list.push('c', 'd');
console.log(list); // ['a', 'b', 'c', 'd']
console.log(newLength); // 4
这里有两个容易忽略的细节。第一个是 push 可以一次添加多个值,用逗号分隔就行,不需要写循环。第二个是它的返回值是新的长度,而不是新加的元素。很多人初学的时候会误以为 push 返回的是被添加的那个值,然后拿返回值去继续操作,结果拿到一个数字,直接懵了。
我在实际项目中还遇到过一种情况:有人为了性能,用 list[list.length] = value 来替代 push。这个写法在功能上等价,但可读性差,而且没有任何实质性的性能提升。现代 JS 引擎对 push 的优化已经很到位了,除非你在做极致的数据处理,否则没必要这么写。
2.2 unshift:头部插入的代价
unshift 的作用是在数组头部添加一个或多个元素,也是原地操作,返回值同样是新长度。
js复制const list = ['b', 'c'];
list.unshift('a');
console.log(list); // ['a', 'b', 'c']
不过要提醒一句,unshift 的内部实现是把现有元素逐个往后移动,再在头部腾出位置。数组越长,这个操作的代价越大。如果你需要频繁地往头部插入数据,比如一个实时消息流,那么使用 unshift 的时间复杂度是 O(n),在数据量大的时候会比较吃力。
一个比较常见的优化思路是:用 push 先往尾部加,最后展示的时候再 reverse 一次;或者使用一个"头指针"来模拟双端队列。不过这是后话了。对于绝大多数业务场景,列表长度在一万以内,unshift 的性能差异你基本感知不到,别过度优化,优先保证代码语义清晰就好。
2.3 splice:任意位置的插入
splice 是数组里功能最强大的方法之一,它能做插入、删除、替换三种操作。很多人在它身上踩过坑,尤其是参数含义容易搞混。
splice(start, deleteCount, item1, item2, ...) 的含义是:从 start 位置开始,先删除 deleteCount 个元素,然后把后面的 item 依次插入到这个位置。举个例子,要把 x 插入到索引 2 的位置:
js复制const list = ['a', 'b', 'd'];
list.splice(2, 0, 'c');
console.log(list); // ['a', 'b', 'c', 'd']
注意中间的参数是 0,表示不删除任何元素,只是插入。很多人第一次写会忘记这个参数,直接写 list.splice(2, 'c'),结果 'c' 被当成了 deleteCount,虽然被隐式转成数字 0 后倒也没报错,但要插入的内容直接没了,这是个非常隐蔽的 bug。
另外大家要记住 splice 也是原地操作,而且它返回的是被删掉的元素组成的数组。插入的时候返回的是空数组,所以想着用返回值拿到新列表是拿不到的。
2.4 直接下标赋值:能不用尽量不用
还有一种添加方式是直接给数组的某个下标赋值,比如 list[3] = 'x'。这个方式在数组长度恰好等于要插入位置的时候效果等同于添加,但如果下标超出当前长度,会在中间产生空位,形成所谓的稀疏数组。这会给后续遍历带来隐患,比如 map 和 forEach 会跳过空位,而 for 循环不会,两种遍历方式得到的结果不一致,很容易排查半天。
所以我的建议是:普通场景能不用下标赋值就别用,语义不清还容易埋坑。唯一的例外是你明确知道这个位置是空位,需要"填坑"的时候,用下标赋值才是合理的。
3. 多数据批量添加:concat、扩展运算符与组合方案
3.1 concat 的语义
如果要把一个数组的所有元素全部添加到另一个数组的尾部,前端时间经常有人问"js怎么用扩展运算符把一个数组里面的值都添加到另外一个数组",其实在扩展运算符流行之前,标准的做法是 concat。
js复制const base = [1, 2];
const extra = [3, 4];
const merged = base.concat(extra);
console.log(merged); // [1, 2, 3, 4]
console.log(base); // [1, 2]
注意 concat 跟 push 有本质区别:push 是原地修改原数组,concat 是返回一个全新的数组,原数组不变。如果你在写纯函数或状态管理逻辑,比如 Redux 的 reducer 里,就必须用 concat 或扩展运算符创建新数组,不能直接 push 改原数组,否则就是不可变数据规范的违规。
3.2 扩展运算符的批量合并
扩展运算符写起来更直观:
js复制const base = [1, 2];
const extra = [3, 4];
const merged = [...base, ...extra];
console.log(merged); // [1, 2, 3, 4]
这个写法的好处是顺序完全由你控制。你既可以把新数据放前面,也可以放后面,甚至可以穿插着放:const merged = [...extra, ...base],新数据在前。它还支持在一个数组中合并多个来源,比如 [...a, ...b, ...c],比 concat 一层层嵌套看起来清爽多了。
但有几点需要注意。扩展运算符只能展开可迭代对象,普通对象是不能直接展开放进数组的,除非你用的是对象展开 {...obj} 而不是数组展开。另外,如果数组里有空位,扩展运算符会把它展开成 undefined,这跟 concat 和 map 的行为又不一样。这种细节在数据清洗时容易踩雷。
3.3 批量添加且去重判断
多个列表合并的时候,经常同时有去重需求。我记得热词里有一条是"jquery var arr=[] 添加 判断arr有没有数据",这其实是在问:往数组里添加数据之前,怎么判断这个数据是不是已经存在。
最简单的是用 indexOf 或 includes 做检查:
js复制if (!list.includes(item)) {
list.push(item);
}
如果是一个一个添加,这样写没问题。但如果是批量合并两个大列表并去重,每次都调用 includes 就会变成 O(n^2) 的复杂度,数据量一大就会卡。
更好的方案是借助 Set 来做去重:
js复制const set = new Set(list);
const merged = [...new Set([...list, ...extra])];
这样整个合并去重操作是 O(n) 级别的。这也是我经常跟团队同学强调的一点:去重不是不能做,而是要注意做法的时间复杂度,尤其在数据量上来之后,差别非常明显。
4. 对象数组的添加、去重与更新:实际业务最常见的场景
4.1 判断数据是否存在再添加
前面讲的都是基础数据类型的数组。实际业务里,列表里放的最多的是对象,比如用户列表、商品列表、影视剧列表。对象数组的"判断数据是否存在"就不能简单用 includes 了,因为两个对象即使内容完全相同,它们在内存里的引用也是不同的,includes 拿引用进行比较,永远判不相等。
正确的做法是找一个唯一标识字段来比较,常见的是 id。需要先判断数组中是否已经存在某个 id 的对象,存在就不加,不存在才加,于是可以这样写:
js复制function addUser(list, user) {
const exists = list.some(item => item.id === user.id);
if (!exists) {
list.push(user);
}
return list;
}
有些人会问,能不能用 filter 加 length 判断?也可以,但 some 在找到第一个匹配项后就会提前返回,性能更好,语义也更清晰。如果是多个对象批量添加并去重,建议先把已有列表按 id 转成一个 Map 或 Set,查重的时候直接 O(1) 命中,避免两层循环。
4.2 引用类型带来的坑
对象数组还有一个经典的坑:你往列表里 push 一个对象,下次修改这个对象,列表里的数据也变了。原因很简单,对象是引用类型,push 进数组的是引用而不是拷贝。
举个实际例子。表单里有个"暂存"功能,用户填写一份数据后点击暂存,把当前表单对象 push 进列表。下一条继续填写时,因为表单对象是同一个引用,于是列表里所有暂存记录全变成了最新一条。这个 bug 非常经典,我在 code review 里见过不止一次。
解决办法是 push 之前做浅拷贝:
js复制list.push({ ...formData });
如果对象内部还有嵌套的对象或数组,浅拷贝还不够,得用递归的深拷贝,或者用 JSON.parse(JSON.stringify(obj)) 这种简单粗暴的方式。不过深拷贝本身是个大话题,这里只提醒一句:往列表里放对象时,想清楚你放进去的是"这个对象本身"还是"它的一个快照"。
4.3 不可变数据与函数式更新
再往深一点说,现代前端框架和状态管理库(Redux、Zustand、Pinia 等)都强调不可变更新:不修改原数组,而是返回一个新的数组。这样做的原因是框架可以借助引用对比快速判断状态是否变化,从而决定是否触发视图更新。
比如要往列表尾部加一项:
js复制// 不推荐:直接改原数组
state.list.push(newItem);
// 推荐:生成新数组再赋值
state.list = [...state.list, newItem];
这个习惯越早养成越好。我刚带团队的时候,经常看到有人写 Vue 3 的 reactive 数据时直接 push,虽然也能触发更新,但在某些需要深度监听和精细控制更新的场景里还是会有隐性风险。下面单独开一节讲框架里的响应式更新问题。
5. 框架场景下的列表添加:Vue、React 中的响应式细节
5.1 Vue 数组响应式的历史坑
用 Vue 2 的老同学应该都对数组响应式的坑记忆犹新。Vue 2 的响应式系统基于 Object.defineProperty,对数组下标赋值是无法拦截的,所以当年你直接写 list[2] = 'x',页面是不会更新的。Vue 也因此提供了一套重写过的数组方法,包括 push、pop、shift、unshift、splice、sort、reverse,官方文档明确要求:要触发数组更新,必须使用这些变异方法。
在 Vue 3 里,响应式系统改用 Proxy,可以直接拦截数组下标赋值,这个限制被解除了。但 Vue 3 仍然推荐使用不可变更新的写法来保证可预测性。有时候热词里出现"vue查询列表全选按钮"、"vue 移动 拉滚动条 列表页数累加",本质上都牵扯到列表更新方式的问题,处理不当就会出现"数据变了但界面没变"或者"滚动加载时列表被覆盖"的现象。
5.2 分页加载与列表累加
分页加载是一个典型场景。很多人写"加载更多"时,直接把接口返回的新数据赋值给了整个列表,结果第二页数据把第一页覆盖了。正确的做法是追加:
js复制this.list = [...this.list, ...pageData];
但这里还有一个细节容易忽略:必须判断当前是第一页还是后续页。第一页应该用接口返回的数据"替换"列表,后续页才用"追加"的方式。因为如果用户从第 3 页刷新页面,回到第 1 页,你直接追加的话,列表里会有重复数据。
实际开发中我会用一个 currentPage 变量记录当前页数,每次请求成功之后,如果 currentPage === 1,就用 res.data 初始化列表,否则用展开运算符追加。同时在追加前判断一下 res.data 是否为空数组,为空就说明没有更多数据了,要把"加载更多"按钮隐藏或禁用,避免用户一直触发无效请求。这种细节虽然简单,却是很多线上 bug 的来源。
6. 常见问题与排查技巧实录
6.1 常见问题速查表
我把平时排查过的"往列表添加数据"相关的典型问题整理成了一张表,方便大家快速对照。
| 现象 | 常见原因 | 解法 |
|---|---|---|
| list.push is not a function | 操作的是类数组对象,不是真正的数组 | 用 Array.from / slice 转成数组 |
| push 之后页面没更新 | Vue 2 下标赋值、或直接改了非响应式数组 | 使用 splice / 重新赋值新数组 |
| 列表里所有数据变成最后一条 | 往数组里 push 了同一个对象引用 | push 前做浅拷贝或深拷贝 |
| unshift 之后页面卡顿 | 数组很长且头部插入频繁 | 改为尾部 push 后用 reverse,或使用双端队列结构 |
| 扩展运算符展开后出现 undefined | 原数组存在空位,扩展运算符把空位转成 undefined | 先 filter 去除空位,或避免稀疏数组 |
| 合并去重后数据丢失 | 使用了引用比较而不是 id 比较 | 用 Map / Set 按唯一字段去重 |
| 第二页数据覆盖第一页 | 分页加载时直接赋值而不是追加 | 判断页码后使用扩展运算符追加 |
6.2 性能实测经验分享
我在实际项目中做过一次简单的性能测试:对一个长度为 10 万的数组,分别用 push、unshift 和 splice 在头部插入 1000 个元素。结果是 unshift 和 splice 明显慢于 push,差距在几十毫秒级别,虽然单次看起来不多,但如果是在滚动监听里频繁触发,就会造成肉眼可见的卡顿。
另外一个性能相关的经验是:批量添加大量数据时,尽量把循环放在 push 内部,也就是一次 push 传多个参数,而不是在循环里一次次调用 push。虽然现代引擎已经优化得不错,但一次传多个参数仍然比多次调用函数调用开销小。还有一种更快的方式是用 apply:
js复制const lots = [/* ...很多数据 */];
Array.prototype.push.apply(targetList, lots);
或者用扩展运算符也可以:targetList.push(...lots)。不过用扩展运算符传参有个限制,如果 lots 数组非常大,可能超出函数的参数个数上限,导致 RangeError。遇到超大数组时,用 apply 或分片 push 更稳妥。这在实际处理日志数据、批量渲染数据时是有意义的。
6.3 几个高频踩坑场景和我的建议
最后分享几个我踩过之后印象特别深的场景。
第一个是"先判断再添加"的逻辑。接口返回的数据里可能有重复项,如果每次请求都无脑 push,列表会越来越长、重复数据越来越多。最稳的做法是在接口数据层就做好去重,而不是把脏数据带进视图层再处理。视图层处理一次,数据层处理一次,逻辑分散,排查起来很痛苦。
第二个是"添加之后要排序"的场景。有些人往列表里 push 完数据后,立刻调用 sort 排序。如果列表是由后端返回的、本身有分页语义,那么"本地排序 + 分页追加"很可能造成顺序错乱。更合理的做法是让后端按统一规则排序,前端只负责追加和展示。前端不要做跨页的全局排序,除非你一次性把全量数据都拿到了。
第三个是"模板渲染里的列表添加"。在 Vue 或 React 的模板里,列表数据往往需要经过 map 或 filter 变换后再展示。如果你的原始列表变了,但派生列表没有跟着变,就要检查是不是用了缓存(比如 Vue 的 computed 或 React 的 useMemo),以及依赖数组有没有写全。很多时候,并不是"添加数据"这一步错了,而是派生数据没有正确更新。
我在实际项目里处理列表添加时,一般会先做一次快速自检:要加的列表是不是真正的数组,要加的数据是引用还是拷贝,加完要不要处理去重和排序,以及视图依赖的数据是不是需要整体替换而不是原地修改。这几个问题问完,选型基本就出来了,不需要死记 API。这个习惯帮我少踩了很多坑,也希望你下次再遇到"往列表里加数据"的需求时,能比我当年更快地想明白。
