深浅拷贝与数组方法
为什么拷贝和数组方法总是面试重灾区
拷贝问题的本质是对 JavaScript 数据类型和内存模型的理解。你在项目里改了一个对象的属性,结果另一个"看起来无关"的变量也跟着变了——这种 bug 几乎每个前端都踩过。面试官通过拷贝问题考察的是你对引用类型的掌握深度。
数组方法则是日常编码的基本功。map、filter、reduce 用得顺不顺手,直接影响代码可读性和维护成本。面试中手写 reduce 实现数组去重、用 flat 拍平嵌套结构这类题目出现频率极高。
这篇文章先把拷贝讲透,再系统过一遍高频数组方法,最后覆盖面试中常见的追问。
值类型与引用类型:拷贝问题的根源
JavaScript 的数据类型分两大类:
值类型(原始类型):number、string、boolean、undefined、null、symbol、bigint。变量直接存储值本身,赋值时复制的是值的副本。
引用类型:object、array、function、Map、Set 等。变量存储的是堆内存中的地址(引用),赋值时复制的是引用,而不是对象本身。
// 值类型:互不影响
let a = 1;
let b = a;
b = 2;
console.log(a); // 1
// 引用类型:共享同一块内存
let obj1 = { name: 'Alice' };
let obj2 = obj1;
obj2.name = 'Bob';
console.log(obj1.name); // 'Bob' —— obj1 也被改了
obj2 = obj1 这行代码并没有创建新对象,只是让 obj2 和 obj1 指向了同一块内存。想要"真正复制一个对象",就需要拷贝。
浅拷贝:只复制第一层
浅拷贝会创建一个新对象,把原对象的第一层属性复制过来。如果属性值是值类型,拷贝的是值本身;如果属性值是引用类型,拷贝的是引用地址。换句话说,嵌套对象还是共享的。
Object.assign
const original = { a: 1, b: { c: 2 } };
const copy = Object.assign({}, original);
copy.a = 99;
console.log(original.a); // 1 —— 值类型,互不影响
copy.b.c = 99;
console.log(original.b.c); // 99 —— 嵌套对象还是同一个引用
Object.assign 的行为是把所有可枚举的自有属性从源对象复制到目标对象。它不会复制原型链上的属性,也不会复制不可枚举属性。
展开运算符
const original = { a: 1, b: { c: 2 } };
const copy = { ...original };
行为和 Object.assign({}, original) 完全一致,本质上也是浅拷贝。语法更简洁,项目中用得更多。
Array.from 与数组展开
对数组做浅拷贝有几种方式:
const arr = [1, [2, 3], { x: 4 }];
const copy1 = [...arr];
const copy2 = Array.from(arr);
const copy3 = arr.slice();
const copy4 = arr.concat();
这四种方式都是浅拷贝,嵌套的数组和对象仍然共享引用。
浅拷贝的适用场景
当你确定对象只有一层结构,或者你只需要修改第一层属性时,浅拷贝就够用了。典型场景包括:React 中更新 state 的第一层字段、合并配置项默认值等。
深拷贝:完全独立的副本
深拷贝会递归复制对象的所有层级,拷贝后的新对象和原对象完全独立,修改任何一方都不会影响另一方。
JSON.parse(JSON.stringify()) —— 简单但有坑
const original = { a: 1, b: { c: 2 }, d: [3, 4] };
const copy = JSON.parse(JSON.stringify(original));
copy.b.c = 99;
console.log(original.b.c); // 2 —— 完全独立
这是最简单的深拷贝方式,但它有一堆限制:
const problematic = {
fn: function() {}, // 函数 → 丢失
undef: undefined, // undefined → 丢失
sym: Symbol('id'), // Symbol → 丢失
date: new Date(), // Date → 变成字符串
regex: /abc/g, // RegExp → 变成空对象 {}
map: new Map(), // Map → 变成空对象 {}
set: new Set(), // Set → 变成空对象 {}
nan: NaN, // NaN → 变成 null
infinity: Infinity, // Infinity → 变成 null
};
const copy = JSON.parse(JSON.stringify(problematic));
// 函数、undefined、Symbol 直接消失
// Date 变成 "2026-05-13T..." 字符串
// RegExp、Map、Set 变成 {}
另一个致命问题:循环引用直接报错。
const obj = { a: 1 };
obj.self = obj;
JSON.parse(JSON.stringify(obj)); // TypeError: Converting circular structure to JSON
适用场景:纯数据对象(只包含 number、string、boolean、null、普通对象和数组),且没有循环引用。比如从接口拿到的 JSON 数据做快照,用这个方式完全没问题。
structuredClone —— 现代浏览器的原生方案
structuredClone 是浏览器和 Node.js(v17+)提供的原生深拷贝 API,基于结构化克隆算法。
const original = {
date: new Date(),
regex: /abc/g,
map: new Map([['key', 'value']]),
set: new Set([1, 2, 3]),
nested: { deep: { value: 42 } },
};
const copy = structuredClone(original);
copy.nested.deep.value = 0;
console.log(original.nested.deep.value); // 42
它能正确处理 Date、RegExp、Map、Set、ArrayBuffer、Error 等内置类型,也能处理循环引用:
const obj = { a: 1 };
obj.self = obj;
const copy = structuredClone(obj); // 正常工作,不会报错
console.log(copy.self === copy); // true —— 循环引用关系被保留
但 structuredClone 也有限制:
- 不能克隆函数:会抛出
DataCloneError - 不能克隆 DOM 节点
- 不会保留原型链:克隆出来的对象原型是
Object.prototype - 不会复制属性描述符:
getter、setter、writable: false等都不会保留
适用场景:绝大多数需要深拷贝的业务场景。如果你的对象里没有函数和 DOM 节点,优先用它。
手写深拷贝:面试必考
面试中经常要求手写一个能处理循环引用的深拷贝函数。核心思路是递归 + 用 WeakMap 记录已经拷贝过的对象来处理循环引用。
function deepClone(source, cache = new WeakMap()) {
// 值类型和 null 直接返回
if (source === null || typeof source !== 'object') {
return source;
}
// 命中缓存,说明存在循环引用,直接返回之前的拷贝
if (cache.has(source)) {
return cache.get(source);
}
// 处理特殊内置类型
if (source instanceof Date) return new Date(source);
if (source instanceof RegExp) return new RegExp(source.source, source.flags);
if (source instanceof Map) {
const mapCopy = new Map();
cache.set(source, mapCopy);
source.forEach((value, key) => {
mapCopy.set(deepClone(key, cache), deepClone(value, cache));
});
return mapCopy;
}
if (source instanceof Set) {
const setCopy = new Set();
cache.set(source, setCopy);
source.forEach((value) => {
setCopy.add(deepClone(value, cache));
});
return setCopy;
}
// 创建新对象或数组,保留原型链
const target = Array.isArray(source) ? [] : Object.create(Object.getPrototypeOf(source));
// 先存入缓存,再递归(防止循环引用时无限递归)
cache.set(source, target);
// 拷贝所有自有属性(包括 Symbol 键)
const keys = [...Object.keys(source), ...Object.getOwnPropertySymbols(source)];
for (const key of keys) {
target[key] = deepClone(source[key], cache);
}
return target;
}
验证一下:
const obj = {
name: 'test',
nested: { value: 42 },
arr: [1, 2, [3]],
date: new Date(),
regex: /abc/gi,
map: new Map([['a', 1]]),
};
obj.self = obj; // 循环引用
const cloned = deepClone(obj);
console.log(cloned.self === cloned); // true
console.log(cloned.nested === obj.nested); // false
console.log(cloned.date.getTime() === obj.date.getTime()); // true
面试中写到这个程度基本就够了。如果面试官追问"还能优化什么",可以提:
- 使用
Reflect.ownKeys()替代手动合并Object.keys和getOwnPropertySymbols - 处理
ArrayBuffer、TypedArray等二进制类型 - 保留属性描述符(通过
Object.getOwnPropertyDescriptors)
拷贝方式对比
| 方式 | 深/浅 | 循环引用 | 函数 | Date/RegExp | 性能 |
|---|---|---|---|---|---|
Object.assign / 展开 | 浅 | — | 保留引用 | 保留引用 | 快 |
JSON.parse/stringify | 深 | ❌ 报错 | ❌ 丢失 | ❌ 转换异常 | 中 |
structuredClone | 深 | ✅ | ❌ 报错 | ✅ | 快 |
| 手写递归 | 深 | ✅ (WeakMap) | 可支持 | ✅ | 取决于实现 |
数组方法全景:按功能分类
JavaScript 的数组方法有几十个,面试中不会全考,但需要你有全局认知。按功能可以分为四大类:
遍历类
| 方法 | 返回值 | 是否改变原数组 | 用途 |
|---|---|---|---|
forEach | undefined | 否 | 纯遍历,不产生新数组 |
map | 新数组 | 否 | 遍历并转换每个元素 |
查找类
| 方法 | 返回值 | 用途 |
|---|---|---|
find | 第一个匹配的元素 / undefined | 找单个元素 |
findIndex | 第一个匹配的索引 / -1 | 找索引 |
indexOf | 第一个匹配的索引 / -1 | 按值查找(用 ===) |
includes | boolean | 判断是否存在 |
some | boolean | 是否存在至少一个满足条件的 |
every | boolean | 是否全部满足条件 |
转换类
| 方法 | 返回值 | 用途 |
|---|---|---|
filter | 新数组 | 过滤出满足条件的元素 |
reduce | 累积值 | 将数组归约为单个值 |
flat | 新数组 | 拍平嵌套数组 |
flatMap | 新数组 | 先 map 再 flat(1) |
slice | 新数组 | 截取子数组 |
concat | 新数组 | 合并数组 |
排序与修改类(会改变原数组)
| 方法 | 用途 | 注意事项 |
|---|---|---|
sort | 排序 | 默认按字符串 Unicode 排序,数字排序必须传比较函数 |
reverse | 反转 | 直接改变原数组 |
splice | 增删改 | 直接改变原数组 |
push / pop | 尾部增删 | 直接改变原数组 |
unshift / shift | 头部增删 | 直接改变原数组 |
map:转换每个元素,返回新数组
map 对数组每个元素执行回调函数,把返回值收集到一个新数组中。
const numbers = [1, 2, 3, 4];
const doubled = numbers.map(num => num * 2);
// [2, 4, 6, 8]
回调函数接收三个参数:(currentValue, index, array)。
const users = [
{ name: 'Alice', age: 25 },
{ name: 'Bob', age: 30 },
];
const names = users.map(user => user.name);
// ['Alice', 'Bob']
注意点:map 一定会返回和原数组等长的新数组。如果你在回调里没有 return,对应位置会是 undefined。
[1, 2, 3].map(num => {
if (num > 1) return num * 2;
// num === 1 时没有 return,结果是 undefined
});
// [undefined, 4, 6]
forEach 与 map 的区别
这是面试高频追问。核心区别:
| 维度 | forEach | map |
|---|---|---|
| 返回值 | undefined | 新数组 |
| 用途 | 执行副作用(打印、修改外部变量等) | 数据转换 |
| 能否链式调用 | 不能 | 能 |
| 能否中断 | 不能(除非抛异常) | 不能 |
经验法则:如果你需要一个新数组,用 map;如果只是"对每个元素做点什么"但不需要返回值,用 forEach。如果你需要中途跳出循环,两个都不合适,用 for...of + break。
filter:筛选满足条件的元素
const numbers = [1, 2, 3, 4, 5, 6];
const evenNumbers = numbers.filter(num => num % 2 === 0);
// [2, 4, 6]
回调函数返回 truthy 值的元素会被保留。filter 不会改变原数组。
组合使用 filter + map 是非常常见的模式:
const users = [
{ name: 'Alice', active: true, age: 25 },
{ name: 'Bob', active: false, age: 30 },
{ name: 'Charlie', active: true, age: 35 },
];
// 先筛选活跃用户,再提取名字
const activeNames = users
.filter(user => user.active)
.map(user => user.name);
// ['Alice', 'Charlie']
reduce:数组方法里的"万能工具"
reduce 把数组"归约"成一个值。它接收一个回调函数和一个初始值,回调函数的参数是 (accumulator, currentValue, index, array)。
const numbers = [1, 2, 3, 4];
const sum = numbers.reduce((acc, num) => acc + num, 0);
// 10
执行过程:
acc = 0, num = 1 → 返回 1acc = 1, num = 2 → 返回 3acc = 3, num = 3 → 返回 6acc = 6, num = 4 → 返回 10
始终传初始值。不传初始值时,reduce 会用数组第一个元素作为初始 acc,空数组会直接报错。
reduce 实现分组
const people = [
{ name: 'Alice', department: 'Engineering' },
{ name: 'Bob', department: 'Marketing' },
{ name: 'Charlie', department: 'Engineering' },
{ name: 'Diana', department: 'Marketing' },
];
const grouped = people.reduce((groups, person) => {
const dept = person.department;
if (!groups[dept]) {
groups[dept] = [];
}
groups[dept].push(person);
return groups;
}, {});
// {
// Engineering: [{ name: 'Alice', ... }, { name: 'Charlie', ... }],
// Marketing: [{ name: 'Bob', ... }, { name: 'Diana', ... }]
// }
现代 JavaScript 已经有了 Object.groupBy()(ES2024),功能等价但语义更清晰。不过面试里通常还是考 reduce 实现。
reduce 实现管道
把一组函数串联起来,前一个函数的输出作为后一个的输入:
const pipe = (...fns) => (initialValue) =>
fns.reduce((acc, fn) => fn(acc), initialValue);
const add10 = x => x + 10;
const multiply2 = x => x * 2;
const toString = x => `Result: ${x}`;
const transform = pipe(add10, multiply2, toString);
console.log(transform(5)); // "Result: 30"
reduce 实现去重
const numbers = [1, 2, 2, 3, 3, 3, 4];
const unique = numbers.reduce((acc, num) => {
if (!acc.includes(num)) {
acc.push(num);
}
return acc;
}, []);
// [1, 2, 3, 4]
这种方式时间复杂度是 O(n²),因为 includes 每次都要遍历 acc。更好的做法是用 Set(见后文去重专题)。
find 与 some / every
find 返回第一个满足条件的元素,找不到返回 undefined:
const users = [
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' },
];
const bob = users.find(user => user.name === 'Bob');
// { id: 2, name: 'Bob' }
some 检查是否存在至少一个满足条件的元素,返回 boolean:
const hasAdmin = users.some(user => user.role === 'admin');
every 检查是否所有元素都满足条件:
const allActive = users.every(user => user.active);
some 和 every 都有短路特性——some 找到第一个 true 就停止,every 找到第一个 false 就停止。在大数组上性能比 filter(...).length > 0 好得多。
flat 与 flatMap
flat 把嵌套数组拍平,参数是拍平的深度,默认 1 层:
const nested = [1, [2, 3], [4, [5, 6]]];
nested.flat(); // [1, 2, 3, 4, [5, 6]]
nested.flat(2); // [1, 2, 3, 4, 5, 6]
nested.flat(Infinity); // [1, 2, 3, 4, 5, 6] —— 无论多深都拍平
flatMap 等价于先 map 再 flat(1),但只会拍平一层,性能比分开调用稍好:
const sentences = ['Hello World', 'Goodbye Moon'];
const words = sentences.flatMap(sentence => sentence.split(' '));
// ['Hello', 'World', 'Goodbye', 'Moon']
// 等价于:
// sentences.map(s => s.split(' ')).flat()
// [['Hello', 'World'], ['Goodbye', 'Moon']].flat()
flatMap 的一个实用技巧是用它来同时做 filter + map:
const numbers = [1, 2, 3, 4, 5, 6];
// 只保留偶数并翻倍
const result = numbers.flatMap(num =>
num % 2 === 0 ? [num * 2] : []
);
// [4, 8, 12]
返回空数组等于过滤掉,返回包含一个元素的数组等于转换,拍平后就得到了想要的结果。
数组去重的多种方式
这是面试中出现频率极高的题目。按照方案从简单到复杂排列:
Set 去重(推荐)
const unique = [...new Set([1, 2, 2, 3, 3, 4])];
// [1, 2, 3, 4]
一行搞定,时间复杂度 O(n)。能处理原始类型,但对引用类型(对象)做的是引用比较,所以两个 { id: 1 } 不会被认为是重复的。
filter + indexOf
const unique = arr.filter((item, index) => arr.indexOf(item) === index);
原理:indexOf 返回的是第一次出现的位置,如果当前索引不等于第一次出现的位置,说明是重复元素。时间复杂度 O(n²)。
reduce 去重
const unique = arr.reduce((acc, item) => {
return acc.includes(item) ? acc : [...acc, item];
}, []);
时间复杂度同样 O(n²)。
对象数组按某个字段去重
实际项目中更常见的需求是按对象的某个字段去重:
const users = [
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' },
{ id: 1, name: 'Alice (duplicate)' },
];
// 方式一:Map
const uniqueUsers = [...new Map(users.map(user => [user.id, user])).values()];
// 方式二:reduce
const uniqueUsers2 = users.reduce((acc, user) => {
if (!acc.some(existing => existing.id === user.id)) {
acc.push(user);
}
return acc;
}, []);
Map 方式的特点是后出现的会覆盖先出现的(保留最后一个),reduce 方式保留的是第一个。具体用哪种取决于业务需求。
sort 的陷阱
sort 是最容易踩坑的数组方法。默认情况下,它会把元素转成字符串再按 Unicode 码点排序:
[10, 9, 2, 1, 100].sort();
// [1, 10, 100, 2, 9] —— 不是你想要的
数字排序必须传比较函数:
// 升序
[10, 9, 2, 1, 100].sort((a, b) => a - b);
// [1, 2, 9, 10, 100]
// 降序
[10, 9, 2, 1, 100].sort((a, b) => b - a);
sort 会改变原数组,这在 React 等框架中容易引发状态污染。安全做法是先拷贝再排序:
const sorted = [...original].sort((a, b) => a - b);
ES2023 新增的 toSorted 方法返回新数组,不改变原数组:
const sorted = original.toSorted((a, b) => a - b);
面试高频追问汇总
Q:如何判断一个变量是数组?
Array.isArray(arr) 是最可靠的方式。typeof [] 返回 'object',不能用来判断数组。instanceof Array 在跨 iframe 场景下会失效(不同 iframe 有独立的 Array 构造函数)。
Q:map 里能用 async/await 吗?
能用,但 map 不会等待 Promise resolve。map 的回调如果是 async 函数,返回的新数组里每个元素都是 Promise,你需要用 Promise.all 包一层:
const results = await Promise.all(
urls.map(async (url) => {
const response = await fetch(url);
return response.json();
})
);
Q:for...of 和 forEach 的区别?
for...of可以用break、continue、return中断循环,forEach不行for...of可以遍历任何可迭代对象(Map、Set、string、NodeList),forEach只能用在数组和类数组上for...of支持await(配合for await...of),forEach内的await不会让迭代等待
Q:哪些数组方法会改变原数组?
push、pop、shift、unshift、splice、sort、reverse、fill、copyWithin。
ES2023 新增了不改变原数组的对应版本:toSorted、toReversed、toSpliced、with。
Q:lodash 的 _.cloneDeep 和 structuredClone 怎么选?
如果项目已经引入了 lodash 且需要克隆包含函数的对象,用 _.cloneDeep。否则优先用 structuredClone——它是原生 API,零依赖,性能也更好。structuredClone 不支持函数克隆,这是唯一需要 _.cloneDeep 的场景。
总结
拷贝和数组方法看似是两个独立话题,但面试中经常组合考察。比如"用 reduce 实现深拷贝""为什么 map 返回的新数组里的对象改了原数组也会变"——这些问题背后都是引用类型的理解。
几个关键要点:
- 理解值类型和引用类型的内存模型是理解拷贝的前提
- 浅拷贝只复制第一层,嵌套对象仍然共享引用
- 深拷贝优先用
structuredClone,需要兼容函数时用递归实现或 lodash - 手写深拷贝的核心是递归 +
WeakMap处理循环引用 - 数组方法要区分会不会改变原数组,这在 React/Vue 等框架中直接影响状态管理
reduce是最灵活的数组方法,分组、管道、去重、扁平化都能实现- 数组去重优先用
Set,O(n) 时间复杂度且代码最简洁