JavaScript数组方法总结 改不改原数组一次记牢
面试被问过一个问题:数组方法里哪些会改原数组?我当场只答出了 push、pop、splice、sort,漏了 reverse 和 fill,unshift、shift 也是想了几秒才补上。面试官接着问,那 slice 和 splice 差一个字母,行为差在哪?这一下就问到了我平时全靠 IDE 提示混过去的地方。
回来把 MDN 上的数组方法过了一遍才发现,「改不改原数组」这个分类不只是面试题,它是日常写 React 和 Vue 时最该有的一根弦。用错一个方法,要么该更新的视图不更新,要么该干净的数据被就地改脏。这篇按这个维度把方法归了两堆,每个方法的返回值、边界情况和常见坑都写清楚,最后补上时间复杂度和 2018 年之后新加的那批方法。
在本篇文章中,我们将从浅入深,和大家一起学习以下知识:
- 会改变原数组的八个方法,以及它们各自返回什么
- 不改变原数组的六个方法,浅拷贝的边界在哪
- 遍历、映射、过滤、检测、归并这一大类的完整行为,包括稀疏数组和中途改数组的表现
push和unshift的时间复杂度为什么差一个数量级sort的稳定性和默认比较规则,为什么[1, 10, 8].sort()结果不对- 2018 年之后新增的
flat、at、findLast、toSorted这批方法能替掉哪些手写代码
# 一、先按「改不改原数组」分成两堆
下面这张图把常用方法按类别摆在了一起,可以当作全文的地图,后面每一节都是在给图里的某一块补细节。

记这些方法有个省力的办法:先看它返回什么。会改原数组的那一堆,返回值往往不是新数组,而是新长度(push、unshift)或者被删掉的元素(pop、shift、splice)。不改原数组的那一堆,返回的一定是一个全新的数组或者一个别的值。
这条规律不是巧合。既然原数组已经被改了,再返回一份数组就是多余的,所以设计上就让它返回一点别的信息。反过来,如果一个方法返回了新数组,它多半就不会去动原来那个。
sort、reverse、fill 是这条规律的例外,它们改了原数组还把原数组本身返回了出来,这是最容易踩的地方,后面单独说。
# 二、会改变原数组的方法
这一堆方法用完之后,你手里那个数组变量指向的对象内容已经变了。在 React 里直接调它们,useState 的浅比较看到的还是同一个引用,视图不会更新。在 Vue 2 里 push、pop、shift、unshift、splice、sort、reverse 这七个被专门做过拦截,能触发更新,但 fill 不在其中。Vue 3 换成 Proxy 之后没这个限制了。
# 2.1 push()
- 方法在数组的尾部添加一个或多个元素,并返回数组的长度
参数: item1, item2, …, itemX ,要添加到数组末尾的元素
let arr = [1,2,3];
let length = arr.push('末尾1','末尾2'); // 返回数组长度
console.log(arr,length)
// [1, 2, 3, "末尾1", "末尾2"] 5
返回值: 数组的长度
push 可以一次追加多个元素,参数有几个就追加几个。它返回的是追加之后的新长度而不是数组本身,所以想链式写 arr.push(1).push(2) 会直接报错,因为数字身上没有 push 方法。
想追加另一个数组里的全部元素,别写 arr.push(other),那样会把整个数组当成一个元素塞进去。正确写法是 arr.push(...other),不过 other 特别大的时候展开会撑爆参数个数上限,那种情况用 for 循环或者 concat 更稳。
# 2.2 pop()
- 方法删除数组的最后一个元素,减小数组长度并返回它删除的值
//组合使用push()和pop()能够用JavaScript数组实现先进后出的栈
let stack = [];
stack.push(1,2) // 返回长度2,这时stack的值是[1,2]
stack.pop() // 返回删除的值2,这时stack的值是[1]
返回值: 从数组中删除的元素(当数组为空时返回undefined)。
push 加 pop 就是一个栈,两个操作都在数组尾部进行,代价都很低。这一对是实现撤销栈、面包屑、路由历史这类结构的默认选择。
空数组上调 pop 返回 undefined 而不是报错,但如果数组里本来就存着 undefined,你就分不清「取到了一个 undefined」和「数组空了」。要区分的话得先看 length。
# 2.3 unshift()
方法在数组的头部添加一个或多个元素,并将已存在的元素移动到更高索引的位置来获得足够的空间,最后返回数组新的长度
let arr = [3,4,5];
let length = arr.unshift(1,2); // 返回长度是5
console.log(arr, length)
//[1, 2, 3, 4, 5] 5
返回值: 返回数组新的长度
注意 unshift(1, 2) 之后开头是 1, 2 而不是 2, 1,参数保持原有顺序整体插到前面。我早年在做消息列表插入的时候写反过一次,一直以为它是逐个 unshift。
「将已存在的元素移动到更高索引的位置」这句话是它性能的关键,后面第五节会展开讲。
# 2.4 shift()
方法删除数组的第一个元素并将其返回,然后把所有随后的元素下移一个位置来填补数组头部的空缺,返回值是删除的元素
let arr = [1,2,3];
let item = arr.shift(); // 返回删除的值1
console.log(arr, item)
// [2, 3] 1
返回值: 从数组中删除的元素; 如果数组为空则返回undefined
push 加 shift 组合起来就是队列,先进先出。做任务队列、埋点上报缓冲区的时候会用到,但数组量大的时候 shift 有性能问题,第五节会说该换成什么。
# 2.5 splice()
方法是在数组中插入或删除元素的通用方法
splice 是这一堆里最全能也最容易记错的。签名是 splice(start, deleteCount, ...items),返回的是被删掉的那些元素组成的数组,没删任何东西就返回空数组。
下面这组示例要连着看,每一行都作用在上一行的结果上。
// start不超过数组长度(以下操作是连续的)
let arr = [1,2,3,4,5];
arr.splice(2) // arr是[1,2],返回值是[3,4,5]
arr.splice(1,1) // arr是[1],返回值是[2]
arr.splice(0,3) // arr是[],返回值是[1],因为此时数组从第0位开始不够3位,所以是删除从0开始到最后的所有元素。
// start大于数组长度(以下操作是连续的)
let arr = [1,2,3,4,5];
arr.splice(5) // arr是[1,2,3,4,5],返回值是[]
arr.splice(5,3,6) // arr是[1,2,3,4,5,6],返回值是[]
arr.splice(5,3,7) // arr是[1,2,3,4,5,7] 返回值是[6]
// start是负数(以下操作是连续的)
let arr = [1,2,3,4,5];
arr.splice(-3,2); // arr是[1,2,5], 返回值是[3,4]
arr.splice(-4); // arr是[],返回值是[1,2,5]
// 插入数组时,是插入数组本身,而不是数组元素
let arr = [1,4,5];
arr.splice(1,0,[2,3]) // arr是[1,[2,3],4,5],返回值是[]
有几条规则从上面这些例子里能提炼出来。只传 start 不传 deleteCount,会从 start 一路删到末尾。start 是负数就加上数组长度,加完还是负数就按 0 算。start 超出数组长度,前面不会补 undefined,直接接在末尾。deleteCount 超出剩余元素个数也不报错,有多少删多少。
最后那个例子最要命,插入的是数组本身而不是数组元素。想把一个数组里的元素批量插进去,得写成 arr.splice(1, 0, ...[2, 3])。
splice 和 slice 这两个名字太像了,我的记法是:splice 多一个 p,可以理解成 patch,要动原数组;slice 是切一片带走,不动原来的。
# 2.6 sort()
sort()方法将数组中的元素排序并返回排序后的数组
var stringArray = ["Blue", "Humpback", "Beluga"];
var numberArray = [40, 1, 5, 200];
function compareNumbers(a, b){
return a - b;
}
console.log('stringArray:' + stringArray.join());
console.log('Sorted:' + stringArray.sort());
console.log('numberArray:' + numberArray.join());
// 没有使用比较函数时,数字并不会按照我们设想的那样排序
console.log('Sorted without a compare function:'+ numberArray.sort());
console.log('Sorted with compareNumbers:'+ numberArray.sort(compareNumbers));
//打印如下
// stringArray: Blue,Humpback,Beluga
// Sorted: Beluga,Blue,Humpback
// numberArray: 40,1,5,200
// Sorted without a compare function: 1,200,40,5
// Sorted with compareNumbers: 1,5,40,200
返回值: 返回排序后的数组。原数组已经被排序后的数组代替
那为什么不传比较函数的时候,[40, 1, 5, 200] 排出来是 1, 200, 40, 5 呢?
因为 sort 默认会把每个元素转成字符串,再按 UTF-16 码元逐位比较。字符串比较是从第一个字符开始的,'200' 的首字符 '2' 小于 '40' 的 '4',所以 200 排在了 40 前面。这跟数值大小没有半点关系。
所以排数字必须传比较函数,(a, b) => a - b 升序,(a, b) => b - a 降序。比较函数的约定是:返回负数表示 a 排前面,返回正数表示 b 排前面,返回 0 表示两者顺序不变。
还有两点要补充。一是排序的稳定性,ES2019 之后规范要求 sort 必须是稳定排序,也就是比较结果相等的元素保持原有相对顺序。在那之前 V8 对短数组用插入排序(稳定)、长数组用快排(不稳定),同一段代码在不同长度下表现不一致,这个历史坑现在已经没有了。
二是 sort 返回的就是原数组本身,const sorted = arr.sort() 之后 sorted === arr 是 true。想要一份新的排序结果,2018 年的写法是 [...arr].sort(fn),现在有 toSorted 可以直接用,第六节会讲。
# 2.7 reverse()
方法将数组中的元素颠倒顺序,返回逆序的数组