JS数组哪些方法会修改原数组?mutator与accessor方法区别解析

作者:拾光时间:2026-11-12 17:59:14浏览:503

深入解析JavaScript原生数组的Mutator修改器方法与Accessor访问器方法区别。详细梳理9个会改变原数组的原生方法、不修改原数组的经典与ES2023替代方案,以及在React与现代开发中的避坑实践。

在 JavaScript 开发中,数组操作是编写业务逻辑最高频的部分。根据是否会修改调用数组本身,JavaScript 原生数组方法可以严格划分为两类:Mutator(修改器/突变方法)和 Accessor(访问器方法)。Mutator 方法会直接就地修改原数组的长度或索引元素,而 Accessor 及迭代方法则不会影响原数组,而是返回一个新数组、标量值或遍历处理数据。搞清两者的核心差异,是避免状态意外污染、编写纯函数以及在 React、Vue 等现代响应式框架中正确更新状态的必备基本功。本文将全面梳理这 9 个就地修改原数组的核心方法、常见的非破坏性访问器方法、ES2023 最新替代方案,以及实际开发中的安全处理规范。
JS数组哪些方法会修改原数组?mutator与accessor方法区别解析
图注:JavaScript 数组 Mutator(就地突变)与 Accessor(纯函数返回副本)机制核心对照

一、核心概念:什么是 Mutator 方法与 Accessor 方法?

在 ECMAScript 规范与编程语言常识中,数组属于引用类型(Reference Type)。变量名本身只在栈内存(Stack)中存放着一个指向堆内存(Heap)数据块的内存地址。这种内存结构决定了数组的方法调用存在两种截然不同的行为模式:

  • Mutator 方法(突变器/修改器):直接在原数组所占用的堆内存空间中改动数据。无论是追加元素、删除项、改变排列次序还是重写索引,方法执行后原有内存区域的内容立即被改变。因此,任何指向该数组的变量别名都会察觉到变动,这种行为被称为副作用(Side Effects)。
  • Accessor 方法(访问器):只读取原数组中的数据,进行特定的转换、计算、切片或拼接,最后将结果作为一个全新的数据结构(新数组或标量值)返回,绝不改写原数组的任何元素或其长度。

为了更清晰地理解这两种方法在底层执行时的差异,我们通过图1来观察两者在堆栈内存中的指针与数据流向:

JS数组哪些方法会修改原数组?mutator与accessor方法区别解析
图1:Mutator 原地突变与 Accessor 纯计算底层内存机制对比

从图1中可以看出,当变量 arr 指向堆地址 0x10FF 时,调用 Mutator 方法会就地改写堆内的数据。如果代码中存在诸如 const shadow = arr; 这样的赋值,那么 shadow 的内容也会在不知不觉中被修改。相反,Accessor 方法(如 slice)会新建一个独立的堆地址 0x20AA,原有的 0x10FF 完好无损。

二、会修改原数组的 9 个 Mutator 方法深度解析

在 ECMAScript 规范的经典定义中,原生 Array.prototype 上共有 9 个典型的 Mutator 方法。记住这 9 个方法可以帮助我们在审查代码时迅速识别潜在的原地修改风险。

1. 端点增删:push()、pop()、unshift()、shift()

这四个方法用于在数组的两端插入或移除元素。需要特别注意:这四个方法的返回值往往不是数组本身,而是操作产生的数值或被移除的项。

  • push(...items):在数组末尾追加一个或多个元素,修改原数组,返回修改后的数组新长度(length)。
  • pop():移除数组的最后一个元素,修改原数组,返回被移除的那一个元素(若数组为空则返回 undefined)。
  • unshift(...items):在数组开头插入一个或多个元素,所有已有元素索引后移,修改原数组,返回修改后的数组新长度(length)。
  • shift():移除数组的第一个元素,后续元素索引前移,修改原数组,返回被移除的那一个元素。
const list = ['apple', 'banana'];

// push 返回的是新长度 3,而不是数组
const newLen = list.push('orange');
console.log(newLen); // 示例输出: 3
console.log(list);   // 示例输出: ['apple', 'banana', 'orange'] (原数组被改写)

// pop 返回被删除的末尾项
const removedItem = list.pop();
console.log(removedItem); // 示例输出: 'orange'
console.log(list);        // 示例输出: ['apple', 'banana']

2. 万能结构剪接:splice()

splice() 是 JavaScript 数组中功能最全面、破坏性最强的修改器方法。它可以在指定索引处同时完成“删除旧项”和“插入新项”。

  • 语法:arr.splice(start, deleteCount, ...items)
  • 行为:从 start 索引开始,删除 deleteCount 个元素,并在该位置插入 items。原数组被原地改变。
  • 返回值:返回一个包含所有被删除元素的新数组(如果没有删除元素,则返回空数组 [])。
const fruits = ['apple', 'banana', 'peach', 'pear'];

// 从索引 1 开始,删除 2 个元素,并在该位置插入 'kiwi' 和 'mango'
const deleted = fruits.splice(1, 2, 'kiwi', 'mango');

console.log(deleted); // 示例输出: ['banana', 'peach'] (返回被删除项)
console.log(fruits);  // 示例输出: ['apple', 'kiwi', 'mango', 'pear'] (原数组已被改写)
⚠️ 极易混淆的命名陷阱:splice vs slice
splice 包含字母 p(可联想为 Pluck 拔出/插入),会修改原数组;
slice 没有字母 p(可联想为 Slice 切片只读),不修改原数组,仅返回截取的浅拷贝副本。

3. 顺序重排:sort() 与 reverse()

sort() 和 reverse() 也是典型的原地修改方法。它们在执行完毕后,返回的是原数组本身的引用(即 arr.sort() === arr 为 true)。

  • reverse():原地颠倒数组中元素的顺序,返回被翻转的原数组。
  • sort(compareFn):对数组元素进行就地排序。如果省略比较函数 compareFn,默认会将所有元素转换为字符串,并按照 UTF-16 码元顺序升序排序。
const nums = [10, 2, 5, 1];

// 默认排序陷阱:按字符串字典序排,'10' 排在 '2' 前面
nums.sort();
console.log(nums); // 示例输出: [1, 10, 2, 5] (原数组被修改,且结果并非数值大小序)

// 正确做法:提供数值比较函数
nums.sort((a, b) => a - b);
console.log(nums); // 示例输出: [1, 2, 5, 10]

4. 批量操作:fill() 与 copyWithin()

这两个方法在日常业务逻辑中较少使用,但在底层数据结构或 TypedArray/Canvas 处理中很常见:

  • fill(value, start, end):用一个固定值填充数组中从 start 到 end 索引的所有位置,修改原数组并返回该数组。
  • copyWithin(target, start, end):浅复制数组中某一部分序列,并覆盖粘贴到同一数组的 target 位置,数组长度不变,返回被修改后的原数组。
const tags = ['a', 'b', 'c', 'd'];
tags.fill('x', 1, 3);
console.log(tags); // 示例输出: ['a', 'x', 'x', 'd'] (原数组被改写)
JS数组哪些方法会修改原数组?mutator与accessor方法区别解析
图2:JavaScript 数组核心方法分类与特性对照全景

三、不修改原数组的常用方法:Accessor 访问器与迭代方法

了解了 9 个突变修改器之后,剩下的绝大多数原生方法都属于不修改原数组的范畴。它们保证了调用的安全性。

1. 经典只读 Accessor 方法

这类方法通常提取或组合数据,生成新结构返回:

  • slice(start, end):截取原数组指定范围的元素,返回浅拷贝的全新数组。
  • concat(...arrays):拼接一个或多个数组或值,返回合并后的全新数组。
  • join(separator):将数组所有项连接为字符串并返回,原数组保持不变。
  • indexOf()、lastIndexOf()、includes():查找特定值的索引或是否存在,返回数值或布尔值标量。

2. 遍历与迭代方法(Iteration Methods)

严格来说,map()、filter()、reduce()、every()、some()、find()、flatMap() 属于迭代方法。这些方法本身不会修改原数组。

例如 arr.map(fn) 会根据每次回调的返回值创建一个新数组,原 arr 的元素及长度不会被 map 底层改写。

💡 细节提醒:回调函数内部的突变行为
虽然 forEach 或 map 本身不修改原数组,但如果开发者在回调函数内部执行了 arr[index] = newValue 或改写了对象属性 item.done = true,原数组或其引用的对象依然会被改变。这是业务代码层面的改写,而非方法本身的机制。

3. ES2023 新标准:四位不可变“孪生兄弟”

长期以来,开发者若想对数组排序或剪切,又不想污染原数组,必须写出 [...arr].sort() 这样繁琐的代码。ECMAScript 2023 (ES14) 正式推出了 4 个全新的非破坏性方法,作为 Mutator 方法的安全镜像:

传统 Mutator(会改原数组) ES2023 替代方法(返回新数组) 核心功能说明
arr.sort(compareFn) arr.toSorted(compareFn) 按比较规则排序,返回已排序的全新数组
arr.reverse() arr.toReversed() 翻转元素顺序,返回已反转的全新数组
arr.splice(start, count, ...items) arr.toSpliced(start, count, ...items) 删除/插入指定区间,返回修改后的全新数组
arr[index] = value arr.with(index, value) 单点替换索引处元素,返回替换后的全新数组
const original = [3, 1, 4];

// 使用 ES2023 toSorted:原数组安然无恙
const sorted = original.toSorted();
console.log(sorted);   // 示例输出: [1, 3, 4]
console.log(original); // 示例输出: [3, 1, 4] (未被修改)

// 使用 with 替换指定索引元素
const updated = original.with(1, 99);
console.log(updated);  // 示例输出: [3, 99, 4]
console.log(original); // 示例输出: [3, 1, 4] (未被修改)

四、开发实践:如何避免意外修改原数组的“防御性编程”?

在 React、Vue 3、Redux 以及函数式编程(Functional Programming)架构中,数据不可变性(Immutability)是核心铁律。

为什么不能随意修改原数组?

  1. React 渲染机制失效:React 的 useState 在判定是否需要重新渲染时,使用的是 Object.is() 浅比较(判断引用地址)。如果在原有 state 数组上调用 state.push(item) 然后调用 setState(state),React 发现前后内存地址相同,会直接跳过组件重新渲染,导致界面不更新。
  2. 跨模块污染隐蔽 Bug:当一个函数接收数组入参并直接执行 arr.splice() 时,外部调用方传递进来的原数据已被破坏,往往导致其他调用者读取到残缺数据,极难排查。

在实际编码中,我们可以通过图3所示的 4 步决策链来规范数组操作:

JS数组哪些方法会修改原数组?mutator与accessor方法区别解析
图3:业务场景中数组方法选型与安全操作决策流程 (Step-by-Step)

常见防御性操作对照示例

// ❌ 危险做法:直接就地修改状态
function handleAddItemBad(newFruit) {
  fruits.push(newFruit); // 破坏了外部变量或 state
  setFruits(fruits);      // 引用未改变,React 无法检测到更新
}

// ✅ 经典防御性浅拷贝写法:展开运算符
function handleAddItemGood(newFruit) {
  setFruits(prev => [...prev, newFruit]); // 生成全新数组地址
}

// ✅ 针对排序/反转的防御性处理
function getRankedList(rawList) {
  // 方案1:现代环境 ES2023+
  return rawList.toSorted((a, b) => b.score - a.score);

  // 方案2:传统兼容写法 (先浅拷贝再排序)
  // return [...rawList].sort((a, b) => b.score - a.score);
}

五、高频陷阱排查与避坑速查

在工程排查中,有三类与 Mutator 相关的 Bug 出现频率极高,开发者需形成敏锐的代码嗅觉:

陷阱 1:以为 sort/reverse 是纯函数而进行链式调用

有些开发者习惯写 const sortedList = list.sort(),并潜意识地认为 list 依然保持原有顺序。实际上,不仅 sortedList 被赋予了排序后的引用,原变量 list 的内容顺序也被彻底打乱。如果在多个地方共享 list,系统将发生不可预测的时序错误。

陷阱 2:误用 push/unshift 的返回值作为新数组

初学者常常误写出类似下面的代码:

let users = ['Alice'];
users = users.push('Bob'); // ❌ 致命错误!
console.log(users);         // 示例输出: 2 (users 变成了数字,而非数组)

push 和 unshift 的返回值是操作后的 数组长度(Number),将其重新赋值给原数组变量会导致数据直接丢失并报错。

陷阱 3:浅拷贝无法防止嵌套对象的修改

无论是 [...arr]、arr.slice() 还是 arr.toSorted(),它们创建的都是浅拷贝副本(Shallow Copy)。新旧数组虽然拥有不同的数组容器地址,但其内部包含的对象元素指向的依然是同一块对象堆内存:

const listA = [{ id: 1, name: 'Tom' }];
const listB = [...listA]; // 浅拷贝数组外壳

// 修改内部对象的属性
listB[0].name = 'Jerry';

console.log(listA[0].name); // 示例输出: 'Jerry' (原数组内部对象仍然被修改!)

如果数组内包含深层对象且需要完全隔离,应当结合 structuredClone() 或 Immer 库来完成深层不可变更新。

JS数组哪些方法会修改原数组?mutator与accessor方法区别解析
图4:Mutator 与 Accessor 核心要素与避坑规则总结速查

六、总结

区分 JavaScript 数组的 Mutator 与 Accessor 方法,核心在于把握数据变更的作用域边界:

在追求极致吞吐的局部算法(如私有函数内的缓冲池构建)中,9 个 Mutator 方法(push、pop、shift、unshift、splice、sort、reverse、fill、copyWithin)能够避免对象重复分配,效率极高;但在现代应用状态管理、组件通信和函数设计中,严格推崇不可变性。

在现代工程中,推荐全面拥抱 ES2023 不可变方法(toSorted、toReversed、toSpliced、with),或在旧环境中使用 [...arr] 浅拷贝,从代码语义上彻底杜绝原地副作用,让数据流动清晰可控。

作者最新文章
相关文章
更多