数组非变异方法:保持数据不变的操作
设想你在一家档案馆工作,需要从历史文件中提取信息。你不会直接在原始文件上做标记或修改——那些是珍贵的原件。相反,你会复印需要的部分,在副本上工作,原始文件保持完整无损。JavaScript 的非变异方法遵循同样的哲学:它们从原数组中提取或派生信息,但绝不修改原数组本身。这种方式让数据更可靠、代码更可预测,也是函数式编程的核心理念。
什么是非变异方法
非变异方法(Non-mutating Methods)是指不会修改调用它的原数组的方法。这些方法执行后,原数组保持不变,它们通常会返回新的数组、字符串或其他值。
让我们先对比变异方法和非变异方法的不同:
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() 方法用于合并两个或多个数组,返回一个新数组,不会修改原数组。
基本用法
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 中,展开运算符(...)提供了更简洁的语法:
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() 只进行浅拷贝。如果数组包含对象,新数组中的对象仍然引用原对象:
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:结束提取的索引(不包含),可选
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() 支持负数索引,从数组末尾开始计数:
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"]实际应用:分页
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() 名字相似但行为完全不同:
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() 方法将数组的所有元素连接成一个字符串,元素之间用指定的分隔符分隔。
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实际应用场景
// 生成 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-4567toString() - 转换为字符串
toString() 方法返回数组的字符串表示,等同于不带参数的 join():
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。
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() 返回元素在数组中最后一次出现的索引:
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() 使用严格相等(===)进行比较:
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() 更语义化。
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之后没有2includes() vs indexOf()
includes() 更适合简单的存在性检查,代码更清晰:
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:
let values = [1, 2, NaN, 4];
console.log(values.indexOf(NaN)); // -1 - indexOf找不到NaN
console.log(values.includes(NaN)); // true - includes可以找到NaN实际应用:权限检查
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. 数据安全性
非变异方法保证原数据不会被意外修改,这在多处共享数据时特别重要:
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. 函数式编程支持
非变异方法是纯函数的基础,支持函数式编程范式:
// 纯函数:无副作用,相同输入产生相同输出
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. 易于测试和调试
纯函数更容易测试,因为不需要考虑外部状态:
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. 支持时间旅行和撤销功能
不可变数据让实现历史记录和撤销功能变得简单:
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" }性能考虑
虽然非变异方法更安全,但它们通常需要创建新数组,在处理大型数组时可能影响性能:
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() 等强大的函数式操作,它们也都是非变异方法,能够以声明式的方式处理数组数据。