面试被问过一个问题:数组方法里哪些会改原数组?我当场只答出了 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]; |
返回值: 数组的长度
push 可以一次追加多个元素,参数有几个就追加几个。它返回的是追加之后的新长度而不是数组本身,所以想链式写 arr.push(1).push(2) 会直接报错,因为数字身上没有 push 方法。
想追加另一个数组里的全部元素,别写 arr.push(other),那样会把整个数组当成一个元素塞进去。正确写法是 arr.push(...other),不过 other 特别大的时候展开会撑爆参数个数上限,那种情况用 for 循环或者 concat 更稳。
2.2 pop()
- 方法删除数组的最后一个元素,减小数组长度并返回它删除的值
//组合使用push()和pop()能够用JavaScript数组实现先进后出的栈 |
返回值: 从数组中删除的元素(当数组为空时返回undefined)。
push 加 pop 就是一个栈,两个操作都在数组尾部进行,代价都很低。这一对是实现撤销栈、面包屑、路由历史这类结构的默认选择。
空数组上调 pop 返回 undefined 而不是报错,但如果数组里本来就存着 undefined,你就分不清「取到了一个 undefined」和「数组空了」。要区分的话得先看 length。
2.3 unshift()
方法在数组的头部添加一个或多个元素,并将已存在的元素移动到更高索引的位置来获得足够的空间,最后返回数组新的长度
let arr = [3,4,5]; |
返回值: 返回数组新的长度
注意 unshift(1, 2) 之后开头是 1, 2 而不是 2, 1,参数保持原有顺序整体插到前面。我早年在做消息列表插入的时候写反过一次,一直以为它是逐个 unshift。
「将已存在的元素移动到更高索引的位置」这句话是它性能的关键,后面第五节会展开讲。
2.4 shift()
方法删除数组的第一个元素并将其返回,然后把所有随后的元素下移一个位置来填补数组头部的空缺,返回值是删除的元素
let arr = [1,2,3]; |
返回值: 从数组中删除的元素; 如果数组为空则返回undefined
push 加 shift 组合起来就是队列,先进先出。做任务队列、埋点上报缓冲区的时候会用到,但数组量大的时候 shift 有性能问题,第五节会说该换成什么。
2.5 splice()
方法是在数组中插入或删除元素的通用方法
splice 是这一堆里最全能也最容易记错的。签名是 splice(start, deleteCount, ...items),返回的是被删掉的那些元素组成的数组,没删任何东西就返回空数组。
下面这组示例要连着看,每一行都作用在上一行的结果上。
// start不超过数组长度(以下操作是连续的) |
有几条规则从上面这些例子里能提炼出来。只传 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"]; |
返回值: 返回排序后的数组。原数组已经被排序后的数组代替
那为什么不传比较函数的时候,[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()
方法将数组中的元素颠倒顺序,返回逆序的数组
let arr = [1,2,3]; |
返回值: 返回顺序颠倒后的数组。原数组已经被排序后的数组代替
跟 sort 一样,reverse 返回的也是原数组本身。这一点在写 arr.reverse().map(...) 这种链式调用时特别隐蔽,你以为只是取了个逆序视图,实际上原数组已经被翻过去了。上游还有别人在用这个数组的话,就是一个很难查的 bug。
想安全地拿逆序副本,写 [...arr].reverse() 或者用新的 toReversed。
2.8 fill()
方法用一个固定值填充一个数组中从起始索引到终止索引内的全部元素
arr.fill(value[, start[, end]]) |
value用来填充数组元素的值。start(可选) 起始索引,默认值为0。end(可选) 终止索引,默认值为 this.length。- 如果 start 是个负数, 则开始索引会被自动计算成为
length+start, 其中 length 是 this 对象的length属性值. 如果 end 是个负数, 则结束索引会被自动计算成为length+end
返回值: 修改后的数组
[1, 2, 3].fill(4); // [4, 4, 4] |
Array(3).fill(4) 这个组合值得单拎出来。Array(3) 造出来的是一个长度为 3 但没有任何实际元素的稀疏数组,map、forEach 这些方法会直接跳过这些空位,什么都不做。fill 不跳,它会实打实地把每个位置都写上值,把稀疏数组变成密集数组。所以「生成一个初始化好的定长数组」这件事,标准写法是 Array(n).fill(0) 而不是 Array(n).map(() => 0)。
这里有个坑要注意,fill 填的是同一个值。Array(3).fill([]) 得到的三个位置指向的是同一个数组,改一个另外两个跟着变。要生成互相独立的对象,得用 Array.from({ length: 3 }, () => [])。
三、不改变原数组的方法
这一堆的共同点是产出一个新东西,原数组从头到尾没动过。但「不改原数组」不等于「深拷贝」,产出的新数组里如果装的是对象,那些对象还是原来那几个引用。这个区别是 3.1 和 3.5 两节的重点。
3.1 slice()
方法返回一个从开始到结束(不包括结束)选择的数组的一部分浅拷贝到一个新数组对象。且原始数组不会被修改
- 返回值: 一个含有提取元素的新数组
let arr = [1,2,3,4,5]; |
上面这段代码演示的就是「浅拷贝」四个字的具体含义。arr.slice() 造出来的确实是一个新数组,往里 push 不会影响原数组;但里面装的那个 {name: 'xiaoming'} 从头到尾只有一份,从哪边改都是改同一个对象。
这一点在处理接口返回的列表时最容易翻车。你以为 slice() 了一份就安全了,改一改发现源数据也跟着变了。真要一份互不影响的副本,得走深拷贝那条路,这块我在 JavaScript深浅拷贝原理与手写实现 里写全了。
不传参数的 slice() 是浅拷贝整个数组,这个写法在展开运算符普及之前用得非常多。现在写 [...arr] 更直观,两者结果一样。
3.2 join()
方法将数组(或一个类数组对象)中所有元素都转化为字符串并连接在一起,返回最后生成的字符串
- 返回值: 一个所有数组元素连接的字符串。如果 arr.length 为0,则返回空字符串
let num = [1,2,3]; |
null 和 undefined 被转成空字符串这条很重要。数组里混了空值,join(', ') 出来的字符串就会有 ', , ' 这样的连续分隔符。展示用户标签这类场景,稳妥的写法是先 filter(Boolean) 再 join。
嵌套数组会递归调用 join 但用的是默认逗号,这个行为可以用来做一个偷懒的扁平化:
// 扁平化简单的二维数组 |
这个技巧当年很流行,但它有明确的边界:所有元素都会被转成字符串,数字变成了 "11" 而不是 11;元素里本身含逗号的话结果就全乱了。现在有原生的 flat 了,别再用这个技巧,第六节会讲。
3.3 toString()
方法将数组的每个元素转化为字符串(如有必要将调用元素的
toString()方法)并且输出用逗号分割的字符串列表。返回一个字符串表示数组中的元素
[1,2,3].toString(); // 1,2,3 |
注意: 当数组和字符串操作的时候,js 会调用这个方法将数组自动转换成字符串
[1,2,3]+'abc' //1,2,3abc |
返回值: 返回一个字符串表示数组中的元素
// 扁平化简单的二维数组 |
3.4 toLocaleString()
数组中的元素将使用各自的 toLocaleString 方法转成字符串,这些字符串将使用一个特定语言环境的字符串(例如一个逗号 “,”)隔开
//数组中的元素将会使用各自的 toLocaleString 方法: |
返回值: 表示数组元素的字符串
3.5 concat()
它的元素包括调用concat()的原始数组的元素和concat()的每个参数,但是要注意,concat()不会递归扁平化数组的数组,concat()也不会修改调用的数组
[1,2,3].concat([4,5,6],[7,8,9]) // [1, 2, 3, 4, 5, 6, 7, 8, 9] |
3.6 isArray()
用于确定传递的值是否是一个 Array
// 下面的函数调用都返回 true |
四、数组遍历、映射、过滤、检测、简化等方法
4.1 forEach()
方法从头到尾遍历数组,为每个元素调用指定的函数
callback为数组中每个元素执行的函数,该函数接收三个参数
// 1、 空元素不遍历,undefined和null是会遍历的。 |
4.2 map()
方法创建一个新数组,其结果是该数组中的每个元素都调用一个callback函数后返回的结果
- 返回值: 一个新数组,每个元素都是回调函数的结果
- 不要用
map代替forEach,map会创建一个新的数组,占用内存。如果你不用map的返回值,那你就应当使用forEach
4.3 filter()
- 方法返回的数组元素是调用的数组的一个子集。传入的函数时用来逻辑判定的,该函数返回 true 或 false,如果返回值为true或能转化为true的值,那么传递给判断函数的元素就是这个子集的成员,它将被添加倒一个作为返回值的数组中
- 返回值: 一个新的通过测试的元素的集合的数组,如果没有通过测试则返回空数组
4.4 every()
方法测试数组的所有元素是否都通过了指定函数的测试。当且仅当针对数组中的所有元素调用判定函数都返回true,它才返回true。
- 空数组上调用every方法,返回 true,因为空数组没有元素,所以空数组中所有元素都符合给定的条件
- 返回值: 一个布尔值,当所有的元素都符合条件才返回true,否则返回false
let arr = [12,34,5,23,44]; |
4.5 some()
方法测试数组中的某些元素是否通过由提供的函数实现的测试。当数组中至少有一个元素调用判定函数返回true,它就返回true,当且仅当数组中的所有元素调用判定函数都返回false,它才返回false
- 空数组调用some,返回false
- 返回值: 只要数组中的任意一个元素在回调函数中返回的是真值,就返回true,否则为false
// 一个简单的例子说明 |
4.6 reduce() 和 reduceRight()
这两个方法使用指定的函数将数组元素进行组合,生成单个值。这在函数式编程中是常见的操作,也可以称为“注入”和“折叠”。reduceRight() 和 reduce() 工作原理是一样的,不同的是reduceRight() 按照数组索引从高到低(从右到左)处理数组,而不是从低到高
- 如果数组为空且没有提供initialValue,会抛出TypeError 。如果数组仅有一个元素(无论位置如何)并且没有提供initialValue, 或者有提供initialValue但是数组为空,那么此唯一值将被返回并且callback不会被执行
- 返回值: 函数累计处理的结果
let arr = [1,2,3,4,5]; |
4.7 indexof()
方法返回在数组中可以找到一个给定元素的第一个索引,如果不存在,则返回-1
- 注意:
indexOf使用严格相等(即===)比较searchElement和数组中的元素。而且indexOf()不能识别NaN - 返回值: 首个被找到的元素在数组中的索引位置; 若没有找到则返回 -1
let array = [2, 5, 9]; |
4.8 includes()
方法用来判断一个数组是否包含一个指定的值,根据情况,如果包含则返回 true,否则返回false。 ES7新增
- 返回值: 一个布尔值,根据情况,如果包含则返回 true,否则返回false
[1, 2, 3].includes(2); // true |
4.9 find() 和 findIndex()
find 方法返回数组中满足提供的测试函数的第一个元素的值。否则返回 undefined。findIndex 方法返回数组中满足提供的测试函数的第一个元素的索引。否则返回-1
find方法,当某个元素通过 callback 的测试时,返回数组中的一个值,否则返回 undefined。findIndex方法,返回数组中满足提供的测试函数的第一个元素的索引。否则返回-1
// find |
4.10 其他
keys()方法返回一个新的Array迭代器,它包含数组中每个索引的键values()方法返回一个新的Array迭代器,它包含数组中每个索引的值entries()方法返回一个新的Array迭代器,该对象包含数组中每个索引的键/值对