Skip to content

数组非变异方法:保持数据不变的操作

设想你在一家档案馆工作,需要从历史文件中提取信息。你不会直接在原始文件上做标记或修改——那些是珍贵的原件。相反,你会复印需要的部分,在副本上工作,原始文件保持完整无损。JavaScript 的非变异方法遵循同样的哲学:它们从原数组中提取或派生信息,但绝不修改原数组本身。这种方式让数据更可靠、代码更可预测,也是函数式编程的核心理念。

什么是非变异方法

非变异方法(Non-mutating Methods)是指不会修改调用它的原数组的方法。这些方法执行后,原数组保持不变,它们通常会返回新的数组、字符串或其他值。

让我们先对比变异方法和非变异方法的不同:

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

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

let numbers = [1, 2, 3];

// 非变异方法:返回新数组,原数组不变
let concatenated = numbers.concat([4, 5]);
console.log(numbers); // [1, 2, 3] - 原数组完好无损
console.log(concatenated); // [1, 2, 3, 4, 5] - 新数组

非变异方法的核心优势是可预测性和安全性。当你调用这些方法时,可以确信原数组不会被意外修改,这在多处共享数据或需要保留历史状态时特别重要。

concat() - 合并数组

concat() 方法用于合并两个或多个数组,返回一个新数组,不会修改原数组。

基本用法

javascript
let fruits = ["apple", "banana"];
let vegetables = ["carrot", "broccoli"];

// 合并两个数组
let food = fruits.concat(vegetables);
console.log(food); // ["apple", "banana", "carrot", "broccoli"]
console.log(fruits); // ["apple", "banana"] - 原数组未变
console.log(vegetables); // ["carrot", "broccoli"] - 原数组未变

// 合并多个数组
let arr1 = [1, 2];
let arr2 = [3, 4];
let arr3 = [5, 6];
let combined = arr1.concat(arr2, arr3);
console.log(combined); // [1, 2, 3, 4, 5, 6]

// 添加单个元素
let numbers = [1, 2, 3];
let withFour = numbers.concat(4);
console.log(withFour); // [1, 2, 3, 4]

// 混合数组和单个元素
let mixed = numbers.concat(4, [5, 6], 7);
console.log(mixed); // [1, 2, 3, 4, 5, 6, 7]

与展开运算符的对比

现代 JavaScript 中,展开运算符(...)提供了更简洁的语法:

javascript
let arr1 = [1, 2];
let arr2 = [3, 4];

// 使用 concat
let result1 = arr1.concat(arr2);

// 使用展开运算符
let result2 = [...arr1, ...arr2];

console.log(result1); // [1, 2, 3, 4]
console.log(result2); // [1, 2, 3, 4]

// 展开运算符更灵活
let result3 = [...arr1, 99, ...arr2, 100];
console.log(result3); // [1, 2, 99, 3, 4, 100]

浅拷贝特性

需要注意,concat() 只进行浅拷贝。如果数组包含对象,新数组中的对象仍然引用原对象:

javascript
let users = [
  { name: "Alice", age: 25 },
  { name: "Bob", age: 30 },
];

let moreUsers = users.concat([{ name: "Charlie", age: 35 }]);

// 修改新数组中的对象会影响原数组中的相同对象
moreUsers[0].age = 26;
console.log(users[0].age); // 26 - 原数组中的对象也被修改了

// 但是添加新元素不会影响原数组
moreUsers.push({ name: "David", age: 40 });
console.log(users.length); // 2 - 原数组长度未变

slice() - 提取数组片段

slice() 方法从数组中提取一段元素,返回新数组。它不会修改原数组。

基本用法

语法:array.slice(start, end)

  • start:开始提取的索引(包含)
  • end:结束提取的索引(不包含),可选
javascript
let numbers = [0, 1, 2, 3, 4, 5];

// 提取从索引2到4(不包括4)的元素
let sliced = numbers.slice(2, 4);
console.log(sliced); // [2, 3]
console.log(numbers); // [0, 1, 2, 3, 4, 5] - 原数组未变

// 从索引2到末尾
let fromTwo = numbers.slice(2);
console.log(fromTwo); // [2, 3, 4, 5]

// 提取最后3个元素
let lastThree = numbers.slice(-3);
console.log(lastThree); // [3, 4, 5]

// 复制整个数组
let copy = numbers.slice();
console.log(copy); // [0, 1, 2, 3, 4, 5]

负数索引

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

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

// 从倒数第3个到倒数第1个(不包括)
let lastTwo = fruits.slice(-3, -1);
console.log(lastTwo); // ["orange", "grape"]

// 从索引1到倒数第1个(不包括)
let middlePart = fruits.slice(1, -1);
console.log(middlePart); // ["banana", "orange", "grape"]

实际应用:分页

javascript
function paginate(array, pageSize, pageNumber) {
  let start = (pageNumber - 1) * pageSize;
  let end = start + pageSize;
  return array.slice(start, end);
}

let items = [
  "Item 1",
  "Item 2",
  "Item 3",
  "Item 4",
  "Item 5",
  "Item 6",
  "Item 7",
  "Item 8",
  "Item 9",
  "Item 10",
];

console.log(paginate(items, 3, 1)); // ["Item 1", "Item 2", "Item 3"]
console.log(paginate(items, 3, 2)); // ["Item 4", "Item 5", "Item 6"]
console.log(paginate(items, 3, 3)); // ["Item 7", "Item 8", "Item 9"]
console.log(paginate(items, 3, 4)); // ["Item 10"]

与 splice() 的对比

slice()splice() 名字相似但行为完全不同:

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

// slice:非变异,提取片段
let sliced = arr1.slice(1, 4);
console.log(arr1); // [1, 2, 3, 4, 5] - 未变
console.log(sliced); // [2, 3, 4]

// splice:变异,删除并插入
let spliced = arr2.splice(1, 3, 99);
console.log(arr2); // [1, 99, 5] - 被修改
console.log(spliced); // [2, 3, 4] - 返回删除的元素

join() - 转换为字符串

join() 方法将数组的所有元素连接成一个字符串,元素之间用指定的分隔符分隔。

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

// 使用默认分隔符(逗号)
let str1 = fruits.join();
console.log(str1); // "apple,banana,orange"

// 使用自定义分隔符
let str2 = fruits.join(" - ");
console.log(str2); // "apple - banana - orange"

// 使用空字符串(无分隔符)
let letters = ["H", "e", "l", "l", "o"];
let word = letters.join("");
console.log(word); // "Hello"

// 使用换行符
let lines = ["First line", "Second line", "Third line"];
let text = lines.join("\n");
console.log(text);
// First line
// Second line
// Third line

实际应用场景

javascript
// 生成 URL 路径
let pathSegments = ["api", "users", "123", "posts"];
let url = "/" + pathSegments.join("/");
console.log(url); // "/api/users/123/posts"

// 生成 CSV 数据
function createCSV(rows) {
  return rows.map((row) => row.join(",")).join("\n");
}

let data = [
  ["Name", "Age", "City"],
  ["Alice", "25", "New York"],
  ["Bob", "30", "London"],
  ["Charlie", "35", "Tokyo"],
];

console.log(createCSV(data));
// Name,Age,City
// Alice,25,New York
// Bob,30,London
// Charlie,35,Tokyo

// 格式化数字
let phoneNumber = [555, 123, 4567];
let formatted = `(${phoneNumber[0]}) ${phoneNumber[1]}-${phoneNumber[2]}`;
console.log(formatted); // (555) 123-4567

// 或者使用 join
let phone = [555, 123, 4567];
let formatted2 = `(${phone[0]}) ${phone.slice(1).join("-")}`;
console.log(formatted2); // (555) 123-4567

toString() - 转换为字符串

toString() 方法返回数组的字符串表示,等同于不带参数的 join()

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

console.log(numbers.toString()); // "1,2,3,4,5"
console.log(numbers.join()); // "1,2,3,4,5" - 相同结果

// 嵌套数组会被扁平化
let nested = [1, [2, 3], [4, [5, 6]]];
console.log(nested.toString()); // "1,2,3,4,5,6"

通常情况下,join() 更灵活,因为你可以自定义分隔符。toString() 主要用于需要字符串表示时的自动类型转换。

indexOf() 和 lastIndexOf() - 查找元素位置

这两个方法用于查找元素在数组中的位置。

indexOf() - 从头开始查找

indexOf() 返回元素在数组中第一次出现的索引,如果未找到返回 -1。

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

// 查找元素
console.log(fruits.indexOf("banana")); // 1 - 第一次出现的位置
console.log(fruits.indexOf("grape")); // 4
console.log(fruits.indexOf("mango")); // -1 - 未找到

// 从指定位置开始查找
console.log(fruits.indexOf("banana", 2)); // 3 - 从索引2开始查找

// 检查元素是否存在
if (fruits.indexOf("apple") !== -1) {
  console.log("Found apple!");
}

lastIndexOf() - 从末尾开始查找

lastIndexOf() 返回元素在数组中最后一次出现的索引:

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

console.log(numbers.indexOf(2)); // 1 - 第一次出现
console.log(numbers.lastIndexOf(2)); // 3 - 最后一次出现

console.log(numbers.indexOf(1)); // 0
console.log(numbers.lastIndexOf(1)); // 4

// 从指定位置向前查找
console.log(numbers.lastIndexOf(2, 2)); // 1

严格相等比较

indexOf()lastIndexOf() 使用严格相等(===)进行比较:

javascript
let items = [1, "1", 2, "2"];

console.log(items.indexOf(1)); // 0
console.log(items.indexOf("1")); // 1 - 区分类型

let objects = [{ id: 1 }, { id: 2 }];
let obj = { id: 1 };

console.log(objects.indexOf(obj)); // -1 - 对象引用不同
console.log(objects.indexOf(objects[0])); // 0 - 相同引用

includes() - 检查是否包含元素

includes() 方法判断数组是否包含某个元素,返回布尔值。这是 ES2016 引入的方法,比 indexOf() 更语义化。

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

// 检查是否包含
console.log(fruits.includes("banana")); // true
console.log(fruits.includes("grape")); // false

// 从指定位置开始检查
let numbers = [1, 2, 3, 4, 5];
console.log(numbers.includes(3, 2)); // true - 从索引2开始
console.log(numbers.includes(2, 2)); // false - 索引2之后没有2

includes() vs indexOf()

includes() 更适合简单的存在性检查,代码更清晰:

javascript
let items = ["book", "pen", "notebook"];

// ❌ 使用 indexOf(不够直观)
if (items.indexOf("pen") !== -1) {
  console.log("Found pen");
}

// ✅ 使用 includes(更清晰)
if (items.includes("pen")) {
  console.log("Found pen");
}

includes() 的另一个优势是能正确处理 NaN

javascript
let values = [1, 2, NaN, 4];

console.log(values.indexOf(NaN)); // -1 - indexOf找不到NaN
console.log(values.includes(NaN)); // true - includes可以找到NaN

实际应用:权限检查

javascript
function checkPermissions(userRoles, requiredRole) {
  return userRoles.includes(requiredRole);
}

let adminRoles = ["read", "write", "delete", "admin"];
let userRoles = ["read", "write"];

console.log(checkPermissions(adminRoles, "admin")); // true
console.log(checkPermissions(userRoles, "admin")); // false
console.log(checkPermissions(userRoles, "write")); // true

// 检查多个权限
function hasAllPermissions(userRoles, requiredRoles) {
  return requiredRoles.every((role) => userRoles.includes(role));
}

console.log(hasAllPermissions(adminRoles, ["read", "write"])); // true
console.log(hasAllPermissions(userRoles, ["read", "delete"])); // false

非变异方法的优势

1. 数据安全性

非变异方法保证原数据不会被意外修改,这在多处共享数据时特别重要:

javascript
let sharedConfig = {
  features: ["dark-mode", "notifications", "analytics"],
};

function addFeature(features, newFeature) {
  // ❌ 变异方法:修改了共享数据
  features.push(newFeature);
  return features;
}

function addFeatureSafe(features, newFeature) {
  // ✅ 非变异方法:返回新数组
  return features.concat([newFeature]);
}

let userFeatures = addFeatureSafe(sharedConfig.features, "custom-theme");
console.log(sharedConfig.features); // ["dark-mode", "notifications", "analytics"] - 未变
console.log(userFeatures); // ["dark-mode", "notifications", "analytics", "custom-theme"]

2. 函数式编程支持

非变异方法是纯函数的基础,支持函数式编程范式:

javascript
// 纯函数:无副作用,相同输入产生相同输出
function getTop3(scores) {
  return scores
    .slice() // 复制数组
    .sort((a, b) => b - a) // 排序
    .slice(0, 3); // 取前3个
}

let scores = [85, 92, 78, 95, 88, 90];
let top3 = getTop3(scores);

console.log(scores); // [85, 92, 78, 95, 88, 90] - 原数组未变
console.log(top3); // [95, 92, 90]

// 可以多次调用,每次结果相同
console.log(getTop3(scores)); // [95, 92, 90]
console.log(getTop3(scores)); // [95, 92, 90]

3. 易于测试和调试

纯函数更容易测试,因为不需要考虑外部状态:

javascript
function filterActive(users) {
  return users.filter((user) => user.active);
}

// 测试非常简单
let testUsers = [
  { name: "Alice", active: true },
  { name: "Bob", active: false },
  { name: "Charlie", active: true },
];

let result = filterActive(testUsers);
console.log(result.length); // 2
console.log(testUsers.length); // 3 - 原数据未变,不会影响其他测试

4. 支持时间旅行和撤销功能

不可变数据让实现历史记录和撤销功能变得简单:

javascript
class History {
  constructor(initialState) {
    this.states = [initialState];
    this.currentIndex = 0;
  }

  push(newState) {
    // 移除当前位置之后的状态
    this.states = this.states.slice(0, this.currentIndex + 1);
    // 添加新状态
    this.states = this.states.concat([newState]);
    this.currentIndex++;
  }

  undo() {
    if (this.currentIndex > 0) {
      this.currentIndex--;
    }
    return this.states[this.currentIndex];
  }

  redo() {
    if (this.currentIndex < this.states.length - 1) {
      this.currentIndex++;
    }
    return this.states[this.currentIndex];
  }

  current() {
    return this.states[this.currentIndex];
  }
}

let history = new History({ text: "" });
history.push({ text: "Hello" });
history.push({ text: "Hello World" });
history.push({ text: "Hello World!" });

console.log(history.current()); // { text: "Hello World!" }
console.log(history.undo()); // { text: "Hello World" }
console.log(history.undo()); // { text: "Hello" }
console.log(history.redo()); // { text: "Hello World" }

性能考虑

虽然非变异方法更安全,但它们通常需要创建新数组,在处理大型数组时可能影响性能:

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.concat([1]);
console.timeEnd("non-mutating"); // 相对较慢

在实际开发中,应该根据场景权衡:

  • 优先使用非变异方法,除非性能瓶颈明确
  • 对于频繁操作的大型数组,考虑使用变异方法
  • 可以使用专门的不可变数据结构库(如 Immutable.js)来兼顾性能和不可变性

总结

非变异方法是 JavaScript 数组操作的重要组成部分,它们返回新值而不修改原数组:

  • concat() - 合并数组,返回新数组
  • slice() - 提取数组片段,返回新数组
  • join() - 将数组转换为字符串
  • toString() - 转换为字符串表示
  • indexOf() / lastIndexOf() - 查找元素位置
  • includes() - 检查是否包含元素

非变异方法的优势:

  • 保证数据安全,避免意外修改
  • 支持函数式编程和纯函数
  • 易于测试和调试
  • 便于实现历史记录和撤销功能

在下一篇文章中,我们将探讨数组迭代方法,包括 map()filter()reduce() 等强大的函数式操作,它们也都是非变异方法,能够以声明式的方式处理数组数据。