前端进阶之旅前端进阶之旅
基础篇
进阶篇
高频篇
精选篇
手写篇
面经篇
AI 篇
原理篇
每日一题
小程序题库
知识卡片
  • 场景篇按分类整理的大前端场景考点
  • 历年面经按年份追踪真实考点
  • 算法题库NEW在线编码即时判题
  • 专项自测100 题快速查漏
  • 前端基础
    • HTTP从报文一路讲到 HTTPS
    • 浏览器渲染、事件循环、进程
    • 计算机基础Linux、网络、操作系统
  • 进阶专项
    • 设计模式23 种模式怎么用
    • 前端系统进阶学习大型项目工程化
    • 前端综合文章长期沉淀的实践文
  • 工程与工具
    • Node学习指南从环境搭建到服务端
    • NPM工作流script、依赖与发布
    • Docker容器化部署上手
    • Canvas图形与动画实战
  • 路线与导图
    • 思维导图知识点全景图
    • 学习路线按图索骥不跑偏
  • 动态
    • 公众号动态公众号历史文章
    • 博客动态站长的技术博客
    • 开发者导航常用工具与文档站
  • AI 助手随时提问,即时解析
  • AI 模拟面试模拟真实面试 + 报告
  • AI 知识地图串起全站知识点
  • AI 定制路线按你的简历现排
AI 热点
旧版
基础篇
进阶篇
高频篇
精选篇
手写篇
面经篇
AI 篇
原理篇
每日一题
小程序题库
知识卡片
  • 场景篇按分类整理的大前端场景考点
  • 历年面经按年份追踪真实考点
  • 算法题库NEW在线编码即时判题
  • 专项自测100 题快速查漏
  • 前端基础
    • HTTP从报文一路讲到 HTTPS
    • 浏览器渲染、事件循环、进程
    • 计算机基础Linux、网络、操作系统
  • 进阶专项
    • 设计模式23 种模式怎么用
    • 前端系统进阶学习大型项目工程化
    • 前端综合文章长期沉淀的实践文
  • 工程与工具
    • Node学习指南从环境搭建到服务端
    • NPM工作流script、依赖与发布
    • Docker容器化部署上手
    • Canvas图形与动画实战
  • 路线与导图
    • 思维导图知识点全景图
    • 学习路线按图索骥不跑偏
  • 动态
    • 公众号动态公众号历史文章
    • 博客动态站长的技术博客
    • 开发者导航常用工具与文档站
  • AI 助手随时提问,即时解析
  • AI 模拟面试模拟真实面试 + 报告
  • AI 知识地图串起全站知识点
  • AI 定制路线按你的简历现排
AI 热点
旧版

JavaScript数组方法总结 改不改原数组一次记牢

首页2018-08-18 18:40:43Front-End
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 这批方法能替掉哪些手写代码

# 一、先按「改不改原数组」分成两堆

下面这张图把常用方法按类别摆在了一起,可以当作全文的地图,后面每一节都是在给图里的某一块补细节。

JavaScript 数组方法分类预览图

记这些方法有个省力的办法:先看它返回什么。会改原数组的那一堆,返回值往往不是新数组,而是新长度(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()

方法将数组中的元素颠倒顺序,返回逆序的数组

fe
  • 一、先按「改不改原数组」分成两堆
  • 二、会改变原数组的方法
    • 2.1 push()
    • 2.2 pop()
    • 2.3 unshift()
    • 2.4 shift()
    • 2.5 splice()
    • 2.6 sort()
    • 2.7 reverse()
    • 2.8 fill()
  • 三、不改变原数组的方法
    • 3.1 slice()
    • 3.2 join()
    • 3.3 toString()
      • 3.4 toLocaleString()
      • 3.5 concat()
      • 3.6 isArray()
    • 四、数组遍历、映射、过滤、检测、简化等方法
      • 4.1 forEach()
      • 4.2 map()
      • 4.3 filter()
      • 4.4 every()
      • 4.5 some()
      • 4.6 reduce() 和 reduceRight()
      • 4.7 indexof()
      • 4.8 includes()
      • 4.9 find() 和 findIndex()
      • 4.10 其他

← 快应用入门小结篇Immutable之回顾 持久化数据结构与结构共享原理 →