JavaScript 数组操作方法大全

数组操作是 JavaScript 中非常重要也非常常用的技巧。本文整理了常用的数组操作方法(包括 ES6 的 map、forEach、every、some、filter、find、from、of 等),熟悉了这些数组操作方法,编写程序也会更加简洁高效。

push()可以将某些值加入到数组的最后一个位置,不限制添加数量,添加的内容使用逗号隔开即可,加入后数组长度会增加。

使用push()后会改变原本的数组内容。

pop()会移除 (取出) 数组的最后一个元素。

使用pop()后会改变原本的数组内容。

shift()会移除 (取出) 数组的第一个元素。

使用shift()后会改变原本的数组内容。

unshift()会将指定的元素添加到第一个位置。

使用nushift()后会改变原本的数组内容。

reverse()会将数组反转。

使用push()后会改变原本的数组内容。

splice()可以移除或新增数组的元素,它包含了三个参数,第一个是要移除或要添加的序列号码 (必填),第二个是要移除的长度 ( 选填,若不填则第一个号码位置后方的所有元素都会被移除,若设定为 0 则不会有元素被移除 ),第三个是要添加的内容 ( 选填 )

使用splice()后会改变原本的数组内容。

设定第三个参数就能够添加或替代元素。

sort()可以针对数组的元素进行排序,里头包含了一个排序用的判断函数,函数内必须包含两个参数,这两个参数分别代表数组里第 n 个和第 n+1 个元素,通过比较第 n 和第 n+1 个元素的大小来进行排序。

使用sort()后会改变原本的数组内容。

如果不使用判断函数,默认会将元素转换成字符串,并采用 unicode 来判断,这也会造成某些数字的排序错误。

copyWithin()能复制数组中的某些元素,并将它们放到并取同一个数组指定的位置,copyWithin()有三个参数,第一个是要替换的位置 (必填),第二个是从什么位置开始复制 ( 选填,默认 0 ),第三个是停止复制的元素的前一个位置 ( 选填,默认等于数组长度 )。

使用copyWithin()后会改变原本的数组内容。

fill()会把数组中所有元素,替换为指定的值,fill()有三个参数,第一个是准备要替换的内容 (必填),第二个是从什么位置开始替换 ( 选填,不设定就全部替换 ),第三个是停止替换的元素的前一个位置 ( 选填,默认等于数组长度 )。

使用fill()会改变原本的数组内容。

length可以取得数组的长度 (所有元素的数量)。

indexOf()会判断数组中是否包含某个值,判断的方式为「由左而右」,如果有包含就返回这个值在数组中的索引值,如果没有就返回 -1,有两个参数,第一个参数表示要判断的值 (必填),第二个参数表示从数组的哪个位置开始判断 ( 选填,默认为 0 )。

lastIndexOf()会判断数组中是否包含某个值,判断的方式为「由右而左」,如果有包含就返回这个值在数组中的索引值,如果没有就返回 -1,有两个参数,第一个参数表示要判断的值 (必填),第二个参数表示判断到数组的哪个位置 ( 选填,默认为整个数组长度 – 1 )。

find()会将数组中的「每一个」元素带入指定的函数内做判断,并会返回第一个符合判断条件的元素,如果没有元素符合则会返回 undefined。

findIndex()会将数组中的「每一个」元素带入指定的函数内做判断,并会返回第一个符合判断条件元素的位置号码,如果没有元素符合则会返回 -1。

filter()会将数组中的「每一个」元素带入指定的函数内做判断,如果元素符合判断条件则会返回,成为一个新的数组元素。

forEach()会将数组中每个元素套用到指定的函数里进行运算,函数有三个参数,第一个参数表示每个元素的值 (必填),第二个参数为该元素的索引值 ( 选填 ),第三个参数则表示原本的数组 ( 选填 )。

如果通过第二和第三个参数搭配,就能做到改变原本数组的效果。

join()可以将数组中所有元素,由指定的字符合并在一起变成字符串呈现,若没有指定字符默认会用「逗号」合并。

concat()可以将两个数组合并在一起,如果是使用 ES6 语法也可以用扩展运算符来代替。

slice()可以截取出数组某部份的元素为一个新的数组,有两个必填的参数,第一个是起始位置,第二个是结束位置 (操作时数字减 1)。

map()会处理数组中每个元素,最后返回出一个新的数组,里头有一个函数 (必填) 和一个返回函数里的 this 参数 ( 选填 ),函数内又包含三个参数,第一个是每个元素的值 ( 必填 ),第二个是当前元素的索引值 ( 选填 ),第三个是当前的数组 ( 选填 )。

套用第二个和第三个参数的变化

如果要使用返回函数里 this 的参数,则「不能使用」箭头函数,因为箭头函数的 this 指向和函数的 this 指向不同,所以要用一般的函数处理。

reduce()可以将数组中每个元素进行计算,每次计算的结果会再与下个元素作计算,直到结束为止,里头包含一个函数 (必填) 和初始计算的数值 ( 选填 ),函数内有四个参数,第一个是计算的值 ( 必填 ),第二个是取得的元素 ( 必填 ),第三个是该元素的索引值 ( 选填 ),第四个是原本的数组 ( 选填 )。

reduceRight()和reduce()大同小异,只是其计算方式是由右到左,对于加法来说没什么影响,但对于减法而言就有差异。

flat()可以将一个多维数组的深度转成一维 (扁平化),它有一个选填的参数,代表要转换的深度数字,默认为 1,如果深度有很多层,可使用Infinity来全部展开成一维数组。

flatMap()的方法等于map()flat()的组合,在运算后直接将数组扁平化处理。

Array.isArray()能判断一个对象是否为数组,如果是就返回 true,不然就返回 false。

Array.from()会将「类数组对象」或是「可迭代的对象」转换成数组,Array.from()有两个参数,第一个参数为「类数组对象」或「可迭代的对象」(必填),第二个参数则是改变转换成数组元素的函数 ( 选填 )。

类数组对象具有 length 属性以及索引化 index 的元素,可迭代对象表示具有可以利用迭代的方式取得它自己本身的元素,例如 Map 和 Set… 等。(参考 MDN 说法)

类数组对象写法必须包含 length 属性,且对象 key 须为 0 开始的数字,对应转换后的元素索引。

Array.of()可以快速将数字、字符串等内容,转换成数组。

toString()会把整个数组转换成文字。

every()会将数组中的「每一个」元素带入指定的函数内做判断,只要有任何一个元素不符合判断条件,会返回 false,如果全部符合,就会返回 true。

some()会将数组中的「每一个」元素带入指定的函数内做判断,只要有任何一个元素符合判断条件,就会返回 true,如果全都不符合,就会返回 false。

includes()会判断数组中是否包含某个值,如果有包含就返回 true,否则返回 false,有两个参数,第一个参数表示要判断的值 (必填),第二个参数表示从数组的哪个位置开始判断 ( 选填 )。

valueOf()会返回数组的原始值,如果原本的数组有修改,那么返回的原始值也会跟着改变

keys()会返回数组中的每一个索引值 (key) 成为一个新的 Array Iterator 对象,因为是 Array Iterator 对象,可以通过for…of来取得。

javascript2

数组遍历归纳

数组遍历

随着 JS 的不断发展,截至 ES7 规范已经有十多种遍历方法。下面按照功能类似的方法为一组,来介绍数组的常用遍历方法。

for、forEach、for …of

小结

  • 三者都是基本的由左到右遍历数组
  • forEach 无法跳出循环;for 和 for ..of 可以使用 break 或者 continue 跳过或中断。
  • for …of 直接访问的是实际元素。for 遍历数组索引,forEach 回调函数参数更丰富,元素、索引、原数组都可以获取。
  • for …of 与 for 如果数组中存在空元素,同样会执行。

some、every

小结

  • 二者都是用来做数组条件判断的,都是返回一个布尔值
  • 二者都可以被中断
  • some 若某一元素满足条件,返回 true,循环中断;所有元素不满足条件,返回 false。
  • every 与 some 相反,若有益元素不满足条件,返回 false,循环中断;所有元素满足条件,返回 true。

filter、map

小结

  • 二者都是生成一个新数组,都不会改变原数组(不包括遍历对象数组是,在回调函数中操作元素对象)
  • 二者都会跳过空元素。有兴趣的同学可以自己打印一下
  • map 会将回调函数的返回值组成一个新数组,数组长度与原数组一致。
  • filter 会将符合回调函数条件的元素组成一个新数组,数组长度与原数组不同。
  • map 生成的新数组元素是可自定义。
  • filter 生成的新数组元素不可自定义,与对应原数组元素一致。

find、findIndex

小结

  • 二者都是用来查找数组元素。
  • find 方法返回数组中满足 callback 函数的第一个元素的值。如果不存在返回 undefined。
  • findIndex 它返回数组中找到的元素的索引,而不是其值,如果不存在返回 -1。

reduce、reduceRight

reduce 方法接收两个参数,第一个参数是回调函数(callback) ,第二个参数是初始值(initialValue)。

reduceRight 方法除了与reduce执行方向相反外(从右往左),其他完全与其一致。

回调函数接收四个参数:

  • accumulator:MDN 上解释为累计器,但我觉得不恰当,按我的理解它应该是截至当前元素,之前所有的数组元素被回调函数处理累计的结果。
  • current:当前被执行的数组元素。
  • currentIndex: 当前被执行的数组元素索引。
  • sourceArray:原数组,也就是调用 reduce 方法的数组。

如果不传入初始值,reduce 方法会从索引 1 开始执行回调函数,如果传入初始值,将从索引 0 开始、并从初始值的基础上累计执行回调。

计算对象数组某一属性的总和

对象数组的去重,并统计每一项重复次数

对象数组最大/最小值获取

reduce 很强大,更多奇技淫巧推荐查看这篇《25个你不得不知道的数组reduce高级用法》

性能对比

说了这么多,那这些遍历方法, 在性能上有什么差异呢?我们在 Chrome 浏览器中尝试。我采用每个循环执行10次,去除最大、最小值 取平均数,降低误差。

从打印结果可以看出,for 循环的速度最快,for of 循环最慢

常用遍历的终止、性能表格对比

最后,不同浏览器内核 也会有些差异,有兴趣的同学也可以尝试一下。

Vue2 重写了数组方法,你知道 Vue3 也重写了吗?

关于 Vue2 数组方法重写其实是一道很常见的八股文,如果有去系统背 Vue 相关面试题的话很容易就能了解到,但是自己也调研了一下很少有人提到关于 Vue3 数组的重写问题

开始之前,我们先来看瞟一眼源码打包后关于数组方法重写部分确认一下:

添加图片注释,不超过 140 字(可选)

添加图片注释,不超过 140 字(可选)

接下来针对于 Vue2、Vue3 的数组方法重写我们分开探讨 浅谈 Object.defineProperty 提到 Vue2 数组方法重写的时候就要先提到 Vue2 的响应式原理,提到 Vue2 的响应式原理就又要提到一个 API:Object.defineProperty 关于这个 API 具体使用方法就不再过多介绍了,不清楚的直接查看文档: Object.defineProperty() – JavaScript | MDN (mozilla.org) 我们在这里只讨论该 API 的局限性,根据其描述可以看出它是用来自定义对象上的属性,专业些来讲就是定义属性描述符,所以其实它并没有强调数据劫持的操作,只是在属性描述符中提供了访问器描述符:get、set 而 Vue2 就是借助这两个访问器进行数据劫持实现了响应式数据,我们精简一下核心源码就是这样:

其实按照我个人的想法来讲这种数据代理劫持并不完美,可以看到 Vue2 主要是通过在外获取了对应的 val,然后针对于该 val 变量以闭包的形式进行 get、set 操作

如果按照我对数据劫持的设想它应该是这样才对:

但毫无疑问这种方式按照访问器的规则肯定是有问题的,比如针对于 get 操作中又进行了一次 get 操作,所以会造成无限递归爆栈,set 操作也是一样的问题 所以后续 ES6 的 Proxy 才是数据劫持的真正解决方案,这点我们放到后续再讲 Vue2 重写数组方法 那问题来了,数组或者其他引用类型也可以通过该 API 劫持吗?答案是可以的,毕竟它们的本质还是对象 拿数组来讲,通过下标访问数组元素的本质也是在访问属性,所以同样能够被 get、set 访问器劫持到 但是我们要考虑数组的方法调用,它的 push、pop 等方法调用的是 Array.prototype 上的属性,也就是说要想劫持的话需要这样:

很显然它与一开始封装的数据劫持方法 defineReactive 不兼容,而且这样劫持的意义不大,想象一下我们调用 push 需要关注两个点:push 的内容、push 的结果 然而上面这种方式只能劫持到 push 属性的访问(注意劫持不到调用)其他什么都拿不到,所以自然而然不会使用这种方法,(当然在最后的总结部分有提到也可以使用该方法,但会遇到性能问题,个人认为这就是我们常说的使用 Object.defineProperty 无法劫持数组的原因) 深入研究的话并不是劫持不到数组,而是只使用该 API 无法满足响应式系统的实现,比如 push 一个新的元素它是一个对象,那我们依然需要对该对象进行数据劫持,但现在我们连这个对象都拿不到,更别说劫持了 为了解决上面的问题, Vue2 没有选择对数组进行劫持而是选择了一个巧妙的方式:重写数组方法 首先明确一下需要对哪些方法进行重写,可以发现我们只需要针对于会修改自身数组的方法进行劫持,而像查找遍历的相关的方法正常使用就可以 数组修改自身的方法:push、pop、shift、unshift、splice、sort、reverse 源码其实很简单没多少行,就是最开始截图的部分,可以明确针对于劫持数组方法的调用会有三个操作:

  1. 调用原生的数组方法拿到结果,最后将其返回
  2. 针对于插入操作获取到插入的内容,对插入的内容进行数据劫持
  3. 通知依赖收集的函数执行

简单画张图来感受一下重写数组的魅力:

添加图片注释,不超过 140 字(可选)

当然聪明的你一定能想到关于数组的增删还有一些歪门邪道的做法,比如直接通过索引进行设置来添加元素,以及调用 delete 关键字来删除元素,同样也适用于对象的增删 关于这一点不管是之前实现 defineReactive 还是数组重写是都无法拦截到的,直接进行修改的话由于无法拦截自然就无法触发对应的响应式流程,所以 Vue2 提供了 $set、$delete 两个全局方法来解决这个问题 同样这两个方法的核心源码也没几行,本质就是调用数组上的 splice 方法做到添加、删除元素,由于 splice 方法已被重写,因此针对于添加的元素会被数据劫持且通知该数组收集的所有依赖函数执行 浅谈 Proxy Proxy 作为 ES6 新增特性给 JS 提供了强大的代理功能,该 API 的介绍就是针对于对象的基本操作能够进行拦截,而且这里的基本操作并不局限于 get、set,大概有十几种操作,具体可以看文档: Proxy – JavaScript | MDN (mozilla.org) 当然这里我们还是考虑响应式数据这块,依旧先使用 get、set 实现一个数据劫持的效果:

可以看出这种 Proxy 代理方式要比 Object.defineProperty 省事的多,最主要的区别在于 Proxy 的最小单元是对象,而 Object.defineProperty 最小单元是对象属性 这就导致了 Vue2 需要针对于某个对象还需要进行属性遍历,针对于每个属性进行 Object.defineProperty,也导致了直接添加和删除对象属性无法被劫持到 除此之外再来看这样的例子,下面通过 cdn 分别引入 Vue2 和 Vue3,我们声明一个响应式数组:

为了统一风格 Vue3 我也使用了 Options API,当然重点在于当数组元素都是基础数据类型 Vue3 依旧做了劫持,而 Vue2 定时器 2s 后界面上依旧没有变化 我们打印 Vue2 中响应式数组来看看结果,可以看到定时器后数组元素发生改变,且也有对应的依赖函数更新视图:

添加图片注释,不超过 140 字(可选)

归根究底如果你有去研究源码的话可以发现 Vue2 针对于数组从始至终都没有进行 defineReactive,只不过给它增加了一个 observer 对象罢了,当遇到一个 value 是数组时 Vue2 会进行遍历针对于每个元素执行 defineReactive 操作,唯独数组本身没有 然而 Vue3 能够实现这一点要归功于 Proxy API,针对于一个数组代理只需要在 getter 中根据你访问的属性增加额外的判断处理逻辑即可 Vue3 重写数组方法 由最开始的截图可以发现 Vue3 针对于数组方法分了两组重写: 第一组针对于查找相关的方法:includes、indexOf、lastIndexOf 第二组针对于增删相关的方法:push、pop、shift、unshift、splice 我们根据设计与实现中的讲解,分别介绍两组重写的原因 首先来看关于查找相关的方法,书中举了这样的例子:

我抽离了响应式中代理的核心逻辑代码复现了书中的问题,其主要关键在于最后代理的数组对象通过调用 includes 方法居然返回 false,这其实不是我们想要看到的结果, 首先我们知道 Vue3 数据劫持是惰性的,因为 Proxy 本身的特性,它不需要一开始就遍历对象的属性然后对每个属性进行劫持,而是以一个对象为整体,当访问到该属性时再去进行劫持。因此如果访问该属性其 value 值是一个引用值时,会进行递归代理 也就是代理后的对象已经不再是原来的对象了:

添加图片注释,不超过 140 字(可选)

而数组的 includes 方法底层也是帮我们遍历数组找到对应的 value,这一点我们在 getter 中打印一下 key 就能发现:

添加图片注释,不超过 140 字(可选)

它会先访问数组的 includes 属性,接着再访问 length 属性,然后开始遍历访问数组下标进行查找 关于 includes 具体执行流程可以自行查阅 ECMA262 文档: ECMAScript® 2025 Language Specification (tc39.es) 或者看设计与实现这部分的内容,霍春阳大佬已经介绍了这个整个流程 所以我们最终解决问题的方案在 includes 方法上,假如我们数组存储的全是普通对象,那经过 reactive 代理后这里的普通对象会全部变成代理对象,所以 includes 底层进行遍历的时候拿到的都是代理对象进行比对,因此才不符合我们的预期 Vue3 对于这个问题的处理很简单,直接重写 includes 方法,先针对于代理数组中调用 includes 方法查找,如果没有找到再拿到原始数组中调用 includes 方法查找,两次查找就能完美解决这个问题 我们简单来尝试一下,首先改造原来的代理,需要增加一个 raw 字段来保存原始数据,然后只针对于 includes 方法进行重写。具体见注释,没有按照源码封装,精简下来只实现该功能:

这样就解决了最开始的问题,而关于数组的查找还有 indexOf、lastIndexOf 这两个 API,统一进行重写即可,都是一样的思路 下面来看第二组重写,是针对于数组的增删方法 为了复现这个问题就需要回顾 Vue3 的响应式数据整体实现了,借这个机会简单复习一下依赖收集和触发依赖的过程,无非就是实现 track、trigger 函数,再提供一个 effect 的方法来触发一开始的依赖收集:

稍微了解一些 Vue3 响应式原理源码实现的应该都能看明白,这里只是实现了一个丐版响应式,可以直接复制到 html 里查看效果:

添加图片注释,不超过 140 字(可选)

但假如我们去代理一个数组,然后添加一个副作用函数,该副作用函数里进行 push 操作:

这时候会发现直接就爆栈了:

添加图片注释,不超过 140 字(可选)

我们来分析一下原因,主要来研究 push 操作的流程,在设计与实现中也根据了 ECMA262 文档分析其过程,这里不再过多展开,需要关注的一点当调用 push 方法时会有这个过程:

  1. 访问数组的 push 属性(getter)
  2. 访问数组的 length 属性(getter)
  3. 修改数组的 length 属性 +1(setter)

问题就出在 length 属性上,当执行副作用函数时 getter 会进行依赖收集,而它的 setter 又会导致该副作用函数重新执行,因此就这样无限循环下去爆栈 所以 Vue3 给到的解决方案就是屏蔽掉 length 属性的依赖收集,实现方式简单粗暴,给个 flag 标志控制是否收集依赖就行,重点在于该 flag 应该在何时改变 其实就在 push 调用上,调用之前我们修改标志禁止收集,调用结束后再解开即可,而重写的过程和上面 includes 思路一样:

我们来看看源码这部分怎么实现的:

添加图片注释,不超过 140 字(可选)

添加图片注释,不超过 140 字(可选)

都是一样的控制 shouldTrack 变量实现,至于为什么还用了 stack 存储,个人猜测跟嵌套依赖收集有关,毕竟函数调用是栈结构嘛,这里就不展开深究了 End(总结) 最后我们针对于 Vue2、Vue3 这两种重写数组方法的方式进行一个总结,谈谈我的个人看法 首先两者要解决的问题完全不一样,其根本原因在于 Object.defineProperty 和 Proxy 的特性不同 Vue2 中使用的 Object.defineProperty 操作的最小单元是对象的属性,因此如果数组进行 push 添加新元素时,需要针对于该元素再调用 Object.defineProperty 进行劫持操作,所以需要扩展原有的 push 方法 但了解到 Vue3 的重写方式后我产生了一个疑问, Vue2 也完全可以按照 Vue3 中的模式,针对于每个数组枚举出需要进行重写的方法,然后通过 Object.defineProerty 拦截到对应的方法名,然后返回重写的数组方法,这样就可以不使用以原型继承的方式来重写,且该方式也会避免 Vue3 针对于 length 属性造成爆栈的问题,因为就没有对 length 属性进行劫持操作 :

添加图片注释,不超过 140 字(可选)

不过很快我就打消了这个念头,这样的做法会导致每个数组实例都需要先通过 Object.defineProperty 添加这几个需要重写的数组方法,但Vue2 中重写的方式不管有多少个数组实例,都始终只有一个中间对象来存储重写的方式,所以开销较小 而且在我们的认知中数组方法往往是挂载到原型上的,以这种挂载到实例上方式其实并不合适 Vue3 中使用的 Proxy 操作的最小单元是对象,也就是说无论该对象动态添加多少个属性同样都能劫持到,因此无需考虑 Vue2 上面的问题,但这种方式同样也引出了其他问题: 第一个问题:由于 proxy 返回的是一个新的代理对象,因此如果一个数组中的元素都是引用类型,则通过代理后会发现产生的新代理对象不再是原始的引用值,这就导致数组中查找元素的方式产生问题,Vue3 就针对于这几个查找的方式进行重写,先在代理后的数组对象中查找,再去原始数组中查找,两次查找便能解决上述问题 第二个问题:由于 proxy 是对象级别的代理,那么针对于数组常用方法操作时会产生不必要的劫持属性:length 属性,比如针对于 push 方法的调用底层会进行访问 length、修改 length 两个操作,因此会导致收集的副作用函数无限循环下去造成爆栈,而 Vue3 解决方式就是避免 length 属性的依赖收集操作,通过重写对应的数组方法动态修改 flag 值,其依赖收集的 track 方法会根据该 flag 来判断是否进行收集

本文作者及来源:Renderbus瑞云渲染农场https://www.renderbus.com

点赞 0
收藏 0

文章为作者独立观点不代本网立场,未经允许不得转载。