Skip to content

数组变异方法:直接修改原数组的操作

在建筑工地上,工人们可以选择两种方式来调整材料的排列。一种是直接在原地重新摆放——搬动砖块、重新排序、添加或移除材料,这会改变工地的实际状态。另一种是先做一个副本,在副本上做修改,原始排列保持不变。在 JavaScript 中,数组方法也有类似的分类。变异方法(Mutating Methods)就像第一种方式——它们直接修改原数组,改变数组本身的内容、顺序或长度。理解这些方法的行为对于避免意外的副作用至关重要。

什么是变异方法

变异方法是指会直接修改调用它的原数组的方法。调用这些方法后,原数组的内容、顺序或长度会发生变化。这与非变异方法形成对比,后者会返回一个新数组而不修改原数组。

让我们先看一个简单的对比:

javascript
let numbers = [1, 2, 3];

// 变异方法:修改原数组
numbers.push(4);
console.log(numbers); // [1, 2, 3, 4] - 原数组被修改了

let fruits = ["apple", "banana"];

// 非变异方法:返回新数组
let moreFruits = fruits.concat(["orange"]);
console.log(fruits); // ["apple", "banana"] - 原数组未变
console.log(moreFruits); // ["apple", "banana", "orange"] - 新数组

变异方法的特点是直接、高效但有副作用。它们不需要创建新数组,节省内存,但也意味着你需要小心处理,避免意外修改共享的数组数据。

末尾操作:push 和 pop

push() - 在末尾添加元素

push() 方法在数组末尾添加一个或多个元素,并返回新的数组长度。这是最常用的数组操作之一。

javascript
let colors = ["red", "green"];

// 添加单个元素
let newLength = colors.push("blue");
console.log(colors); // ["red", "green", "blue"]
console.log(newLength); // 3 - 返回新长度

// 一次添加多个元素
colors.push("yellow", "purple", "orange");
console.log(colors); // ["red", "green", "blue", "yellow", "purple", "orange"]

// push可以添加任何类型的值
let mixed = [1, 2];
mixed.push("three", { value: 4 }, [5, 6]);
console.log(mixed); // [1, 2, "three", { value: 4 }, [5, 6]]

push() 的时间复杂度是 O(1),因为它只是在数组末尾添加元素,不需要移动其他元素。这使得它非常高效,特别适合构建列表或收集数据。

实际应用场景:

javascript
// 收集用户输入
let searchHistory = [];

function recordSearch(query) {
  searchHistory.push({
    query: query,
    timestamp: new Date(),
  });
}

recordSearch("JavaScript arrays");
recordSearch("Array methods");
recordSearch("push vs concat");

console.log(searchHistory);
// [
//   { query: "JavaScript arrays", timestamp: ... },
//   { query: "Array methods", timestamp: ... },
//   { query: "push vs concat", timestamp: ... }
// ]

pop() - 从末尾删除元素

pop() 方法删除数组的最后一个元素,并返回被删除的元素。如果数组为空,返回 undefined

javascript
let stack = ["first", "second", "third"];

// 删除最后一个元素
let removed = stack.pop();
console.log(removed); // "third"
console.log(stack); // ["first", "second"]

// 继续删除
stack.pop(); // "second"
stack.pop(); // "first"
console.log(stack); // []

// 在空数组上调用pop
let empty = stack.pop();
console.log(empty); // undefined
console.log(stack); // [] - 仍然是空数组

push()pop() 组合使用可以实现栈(Stack)数据结构,遵循"后进先出"(LIFO)的原则:

javascript
// 实现一个简单的撤销功能
let actions = [];

function doAction(action) {
  actions.push(action);
  console.log(`Performed: ${action}`);
}

function undo() {
  let lastAction = actions.pop();
  if (lastAction) {
    console.log(`Undoing: ${lastAction}`);
  } else {
    console.log("Nothing to undo");
  }
}

doAction("Create document"); // Performed: Create document
doAction("Add text"); // Performed: Add text
doAction("Format text"); // Performed: Format text

undo(); // Undoing: Format text
undo(); // Undoing: Add text
undo(); // Undoing: Create document
undo(); // Nothing to undo

开头操作:unshift 和 shift

unshift() - 在开头添加元素

unshift() 方法在数组开头添加一个或多个元素,并返回新的数组长度。与 push() 相反,它把元素添加到索引 0 的位置。

javascript
let numbers = [3, 4, 5];

// 在开头添加单个元素
numbers.unshift(2);
console.log(numbers); // [2, 3, 4, 5]

// 一次添加多个元素
numbers.unshift(-1, 0, 1);
console.log(numbers); // [-1, 0, 1, 2, 3, 4, 5]

需要注意的是,unshift() 的性能比 push() 差,因为它需要将所有现有元素向后移动一个位置。对于大数组,这个操作可能比较慢(时间复杂度 O(n))。

shift() - 从开头删除元素

shift() 方法删除数组的第一个元素,并返回被删除的元素。同样,它的性能也比 pop() 差,因为需要移动所有剩余元素。

javascript
let queue = ["Alice", "Bob", "Charlie", "David"];

// 删除第一个元素
let first = queue.shift();
console.log(first); // "Alice"
console.log(queue); // ["Bob", "Charlie", "David"]

first = queue.shift();
console.log(first); // "Bob"
console.log(queue); // ["Charlie", "David"]

push()shift() 组合使用可以实现队列(Queue)数据结构,遵循"先进先出"(FIFO)的原则:

javascript
// 模拟排队系统
let customerQueue = [];

function joinQueue(customerName) {
  customerQueue.push(customerName);
  console.log(`${customerName} joined the queue`);
  console.log(`Queue: [${customerQueue.join(", ")}]`);
}

function serveCustomer() {
  let customer = customerQueue.shift();
  if (customer) {
    console.log(`Serving ${customer}`);
  } else {
    console.log("No customers in queue");
  }
  console.log(`Queue: [${customerQueue.join(", ")}]`);
}

joinQueue("Alice"); // Alice joined the queue
joinQueue("Bob"); // Bob joined the queue
joinQueue("Charlie"); // Charlie joined the queue

serveCustomer(); // Serving Alice
serveCustomer(); // Serving Bob
joinQueue("David"); // David joined the queue
serveCustomer(); // Serving Charlie
serveCustomer(); // Serving David
serveCustomer(); // No customers in queue

splice() - 瑞士军刀般的方法

splice() 是数组最强大和灵活的变异方法之一。它可以删除、插入或替换数组中的元素。这个方法接受多个参数:

  • start:开始修改的索引位置
  • deleteCount:要删除的元素数量(可选)
  • items:要插入的新元素(可选,可以有多个)

删除元素

javascript
let fruits = ["apple", "banana", "orange", "grape", "mango"];

// 从索引2开始,删除2个元素
let removed = fruits.splice(2, 2);
console.log(removed); // ["orange", "grape"] - 返回被删除的元素
console.log(fruits); // ["apple", "banana", "mango"]

// 从索引1开始,删除所有后续元素
let numbers = [1, 2, 3, 4, 5];
numbers.splice(1);
console.log(numbers); // [1] - 只剩第一个元素

插入元素

javascript
let colors = ["red", "yellow"];

// 在索引1的位置插入元素,不删除任何元素(deleteCount为0)
colors.splice(1, 0, "orange");
console.log(colors); // ["red", "orange", "yellow"]

// 一次插入多个元素
colors.splice(2, 0, "green", "blue");
console.log(colors); // ["red", "orange", "green", "blue", "yellow"]

替换元素

javascript
let items = ["book", "pen", "eraser", "ruler"];

// 从索引1开始,删除2个元素,并插入2个新元素
items.splice(1, 2, "notebook", "pencil");
console.log(items); // ["book", "notebook", "pencil", "ruler"]

// 删除比插入多:缩短数组
let nums = [1, 2, 3, 4, 5];
nums.splice(1, 3, 99);
console.log(nums); // [1, 99, 5]

// 插入比删除多:延长数组
let letters = ["a", "b", "e"];
letters.splice(2, 0, "c", "d");
console.log(letters); // ["a", "b", "c", "d", "e"]

负数索引

splice() 支持负数索引,从数组末尾开始计数:

javascript
let data = [1, 2, 3, 4, 5];

// -2 表示倒数第二个位置
data.splice(-2, 1);
console.log(data); // [1, 2, 3, 5] - 删除了4

// 在倒数第一个位置之前插入
data.splice(-1, 0, 99);
console.log(data); // [1, 2, 3, 99, 5]

实际应用:管理播放列表

javascript
let playlist = ["Song A", "Song B", "Song C", "Song D", "Song E"];

// 删除一首歌
function removeSong(index) {
  let removed = playlist.splice(index, 1);
  console.log(`Removed: ${removed[0]}`);
}

// 在特定位置插入歌曲
function insertSong(index, songName) {
  playlist.splice(index, 0, songName);
  console.log(`Inserted ${songName} at position ${index}`);
}

// 替换歌曲
function replaceSong(index, newSong) {
  let old = playlist.splice(index, 1, newSong);
  console.log(`Replaced ${old[0]} with ${newSong}`);
}

insertSong(2, "New Hit"); // Inserted New Hit at position 2
console.log(playlist); // ["Song A", "Song B", "New Hit", "Song C", "Song D", "Song E"]

replaceSong(0, "Latest Song"); // Replaced Song A with Latest Song
console.log(playlist); // ["Latest Song", "Song B", "New Hit", "Song C", "Song D", "Song E"]

removeSong(3); // Removed: Song C
console.log(playlist); // ["Latest Song", "Song B", "New Hit", "Song D", "Song E"]

sort() - 排序数组

sort() 方法对数组元素进行排序,直接修改原数组并返回排序后的数组。

默认排序行为

默认情况下,sort() 将所有元素转换为字符串,然后按照 Unicode 码点顺序排序:

javascript
// 字符串排序正常工作
let fruits = ["banana", "apple", "orange", "grape"];
fruits.sort();
console.log(fruits); // ["apple", "banana", "grape", "orange"]

// ⚠️ 数字排序可能出现意外结果
let numbers = [10, 5, 40, 25, 1000, 1];
numbers.sort();
console.log(numbers); // [1, 10, 1000, 25, 40, 5] - 按字符串排序!

为什么 [10, 5, 40] 排序后变成 [10, 40, 5]?因为在字符串比较中,"10" 小于 "5""1" 小于 "5")。

使用比较函数

要正确排序数字或实现自定义排序逻辑,需要提供比较函数:

javascript
let numbers = [10, 5, 40, 25, 1000, 1];

// 升序排序
numbers.sort((a, b) => a - b);
console.log(numbers); // [1, 5, 10, 25, 40, 1000]

// 降序排序
numbers.sort((a, b) => b - a);
console.log(numbers); // [1000, 40, 25, 10, 5, 1]

比较函数的工作原理:

  • 如果返回值 < 0,a 排在 b 前面
  • 如果返回值 > 0,b 排在 a 前面
  • 如果返回值 = 0,a 和 b 的相对位置不变

复杂对象排序

javascript
let students = [
  { name: "Alice", grade: 85 },
  { name: "Bob", grade: 92 },
  { name: "Charlie", grade: 78 },
  { name: "David", grade: 92 },
  { name: "Emma", grade: 88 },
];

// 按成绩升序排序
students.sort((a, b) => a.grade - b.grade);
console.log(students);
// [
//   { name: "Charlie", grade: 78 },
//   { name: "Alice", grade: 85 },
//   { name: "Emma", grade: 88 },
//   { name: "Bob", grade: 92 },
//   { name: "David", grade: 92 }
// ]

// 按名字字母顺序排序
students.sort((a, b) => a.name.localeCompare(b.name));
console.log(students);
// [
//   { name: "Alice", grade: 85 },
//   { name: "Bob", grade: 92 },
//   { name: "Charlie", grade: 78 },
//   { name: "David", grade: 92 },
//   { name: "Emma", grade: 88 }
// ]

// 多级排序:先按成绩降序,成绩相同按名字升序
students.sort((a, b) => {
  if (b.grade !== a.grade) {
    return b.grade - a.grade; // 成绩降序
  }
  return a.name.localeCompare(b.name); // 名字升序
});
console.log(students);
// [
//   { name: "Bob", grade: 92 },
//   { name: "David", grade: 92 },
//   { name: "Emma", grade: 88 },
//   { name: "Alice", grade: 85 },
//   { name: "Charlie", grade: 78 }
// ]

reverse() - 反转数组

reverse() 方法反转数组的元素顺序,第一个元素变成最后一个,最后一个变成第一个。

javascript
let numbers = [1, 2, 3, 4, 5];
numbers.reverse();
console.log(numbers); // [5, 4, 3, 2, 1]

let words = ["hello", "world", "JavaScript"];
words.reverse();
console.log(words); // ["JavaScript", "world", "hello"]

reverse() 常与 sort() 配合使用:

javascript
let scores = [85, 92, 78, 95, 88];

// 先排序(升序),再反转得到降序
scores.sort((a, b) => a - b).reverse();
console.log(scores); // [95, 92, 88, 85, 78]

// 更高效的方式是直接降序排序
scores.sort((a, b) => b - a);
console.log(scores); // [95, 92, 88, 85, 78]

fill() - 填充数组

fill() 方法用一个固定值填充数组的全部或部分元素。

javascript
// 创建并填充新数组
let zeros = new Array(5).fill(0);
console.log(zeros); // [0, 0, 0, 0, 0]

// 填充部分数组(startIndex, endIndex)
let numbers = [1, 2, 3, 4, 5];
numbers.fill(99, 1, 4); // 从索引1到3(不包括4)
console.log(numbers); // [1, 99, 99, 99, 5]

// 从某个位置开始填充到结尾
let letters = ["a", "b", "c", "d", "e"];
letters.fill("x", 2);
console.log(letters); // ["a", "b", "x", "x", "x"]

实际应用:初始化游戏棋盘

javascript
// 创建一个8x8的棋盘,初始化为空
function createBoard(size) {
  return Array.from({ length: size }, () => new Array(size).fill(null));
}

let chessBoard = createBoard(8);
console.log(chessBoard[0]); // [null, null, null, null, null, null, null, null]

// 初始化特定区域
function initializeRow(row, value) {
  row.fill(value);
}

initializeRow(chessBoard[0], "♜"); // 初始化第一行
console.log(chessBoard[0]); // ["♜", "♜", "♜", "♜", "♜", "♜", "♜", "♜"]

copyWithin() - 内部复制

copyWithin() 方法在数组内部复制一段元素到另一个位置,会覆盖原有元素。

语法:array.copyWithin(target, start, end)

  • target:复制到的目标位置
  • start:开始复制的位置(可选,默认 0)
  • end:结束复制的位置(可选,默认数组长度)
javascript
let numbers = [1, 2, 3, 4, 5];

// 将索引0-2的元素复制到索引3的位置
numbers.copyWithin(3, 0, 2);
console.log(numbers); // [1, 2, 3, 1, 2]

// 将后3个元素复制到开头
let data = [1, 2, 3, 4, 5, 6];
data.copyWithin(0, 3, 6);
console.log(data); // [4, 5, 6, 4, 5, 6]

// 使用负数索引
let items = ["a", "b", "c", "d", "e"];
items.copyWithin(-2, 0, 2);
console.log(items); // ["a", "b", "c", "a", "b"]

这个方法相对不常用,但在需要在数组内部移动或复制数据时很有用,比如实现循环缓冲区。

变异方法的注意事项

1. 副作用问题

变异方法会修改原数组,这可能导致意外的副作用,特别是当数组被多个地方引用时:

javascript
let originalList = ["apple", "banana", "orange"];
let sharedList = originalList; // 共享引用

originalList.push("grape");
console.log(sharedList); // ["apple", "banana", "orange", "grape"]
// sharedList也被修改了!

// 如果需要保持原数组不变,先复制
let safeList = [...originalList];
safeList.push("mango");
console.log(originalList); // ["apple", "banana", "orange", "grape"] - 未变
console.log(safeList); // ["apple", "banana", "orange", "grape", "mango"]

2. 函数式编程考虑

在函数式编程范式中,纯函数不应该产生副作用。如果你追求纯函数风格,应该避免使用变异方法:

javascript
// ❌ 非纯函数:修改了输入数组
function addItemImpure(array, item) {
  array.push(item);
  return array;
}

let items = [1, 2, 3];
let result = addItemImpure(items, 4);
console.log(items); // [1, 2, 3, 4] - 原数组被修改

// ✅ 纯函数:返回新数组
function addItemPure(array, item) {
  return [...array, item];
}

let numbers = [1, 2, 3];
let newNumbers = addItemPure(numbers, 4);
console.log(numbers); // [1, 2, 3] - 原数组未变
console.log(newNumbers); // [1, 2, 3, 4]

3. 性能权衡

变异方法通常比非变异方法更高效,因为不需要创建新数组:

javascript
// 变异方法:快速,但修改原数组
let largeArray = new Array(100000).fill(0);
console.time("mutating");
largeArray.push(1);
console.timeEnd("mutating"); // 很快

// 非变异方法:需要复制整个数组
let anotherLargeArray = new Array(100000).fill(0);
console.time("non-mutating");
let newArray = [...anotherLargeArray, 1];
console.timeEnd("non-mutating"); // 相对较慢

在性能关键的场景中(如处理大型数组或频繁操作),变异方法可能是更好的选择。但在大多数情况下,代码的可维护性和可预测性更重要。

总结

变异方法是 JavaScript 数组操作的重要组成部分。它们直接修改原数组,提供了高效的数据操作方式:

  • push() / pop() - 末尾添加/删除,实现栈结构(LIFO)
  • unshift() / shift() - 开头添加/删除,配合 push 实现队列(FIFO)
  • splice() - 万能方法,可删除、插入、替换任意位置的元素
  • sort() - 排序数组,需要比较函数处理数字和对象
  • reverse() - 反转数组顺序
  • fill() - 用固定值填充数组
  • copyWithin() - 数组内部复制元素

使用变异方法时要记住:

  • 它们会修改原数组,可能产生副作用
  • 在需要保持原数组不变时,先创建副本
  • 在函数式编程中,考虑使用非变异替代方案
  • 在性能关键场景中,变异方法通常更高效

在下一篇文章中,我们将探讨非变异方法,了解如何在不修改原数组的情况下处理数据。