【每日一面】深浅拷贝
浅拷贝只复制第一层、嵌套引用依旧共享,深拷贝则要递归到每一层,还得用 WeakMap 兜住循环引用、用类型分支兜住 Date / RegExp / Map / Set。
基础问答
问:浅拷贝和深拷贝有什么区别?为什么需要深拷贝?
答: 浅拷贝和深拷贝的核心区别在于 是否递归复制嵌套的引用类型。
JavaScript 的数据类型分为基本类型(string、number、boolean、null、undefined、symbol、bigint)和引用类型(object、array、function 等)。基本类型值直接存储在栈内存中,赋值即复制值;引用类型的栈内存只存指向堆内存的地址,赋值仅拷贝地址。
-
浅拷贝:只复制对象的第一层属性,嵌套的引用类型拷贝的是引用地址,新旧对象共享嵌套数据。
-
深拷贝:递归复制所有层级,新旧对象完全独立,修改拷贝后的对象不会影响原始对象。
使用深拷贝的目的很简单,就是为了防止因共享引用而意外篡改原始数据。例如 React 中 setState 要求不可变性,Vue 响应式系统中也需要深拷贝来隔离数据。
扩展延伸
值与引用
在讨论拷贝之前,必须先理解 JavaScript 的传值方式。
// 基本类型:传值
let a = 10;
let b = a;
b = 20;
console.log(a); // 10 —— 互不影响
// 引用类型:传引用
let obj1 = { x: 10 };
let obj2 = obj1;
obj2.x = 20;
console.log(obj1.x); // 20 —— 改一个全改如上这种差异是深浅拷贝问题的根源。
常见浅拷贝方式
| 方式 | 适用对象 | 特点 |
|---|---|---|
Object.assign({}, obj) | 对象 | 仅拷贝自身可枚举属性,不拷贝继承属性 |
扩展运算符 { ...obj } | 对象/数组 | 语法简洁,ES6 常用 |
Array.prototype.slice() | 数组 | 返回新数组,适合截取子集 |
这里用代码展示浅拷贝的共享引用问题
const original = { name: 'Alice', address: { city: 'Beijing' } };
const copy = { ...original };
copy.name = 'Bob'; // 基本类型:不影响原对象
copy.address.city = 'Shanghai'; // 引用类型:原对象也被改了
console.log(original.name); // 'Alice'
console.log(original.address.city); // 'Shanghai' —— 被意外修改了深拷贝的实现
深拷贝的核心难题有三个:递归遍历、循环引用、特殊类型处理。
-
JSON 序列化
javascriptconst obj = { a: 1, b: { c: 2 }, d: [3, 4] }; const deep = JSON.parse(JSON.stringify(obj));这种方式最常用,但是存在一些问题,比如循环引用无法拷贝,部分类型序列化会丢失等:
javascriptconst problematic = { func: () => {}, undef: undefined, sym: Symbol('id'), big: 123n, // BigInt date: new Date(), reg: /test/g, self: null // 故意制造循环引用标记 }; problematic.self = problematic; // 循环引用 // JSON.parse(JSON.stringify(problematic)) 会直接报错类型 序列化结果 原因 Function丢失(键被删除) JSON 不支持函数 undefined丢失(键被删除) JSON 无对应类型 Symbol丢失 JSON 无对应类型 BigInt抛出 TypeError 无法序列化 Date转为字符串 反序列化后是字符串而非 Date 对象 RegExp转为空对象 {}无法正确还原 循环引用 抛出 TypeError 无限递归 -
手写深拷贝
手写深拷贝是过去前端面试的一个必考点,不过现在也很少见考这个了,但是仍旧是需要理解的内容。
实现的基本逻辑:递归遍历 + WeakMap 缓存 + 特殊类型分支。
flowchart TD A[输入 target] --> B{target 是 null 或<br/>非 object?} B -->|是| C[直接返回 target] B -->|否| D{WeakMap 中<br/>已缓存该对象?} D -->|是| E[返回缓存副本] D -->|否| F{判断 target 类型} F -->|Date| G["new Date(target)"] F -->|RegExp| H["new RegExp(target)"] F -->|Map| I["new Map() 递归 set"] F -->|Set| J["new Set() 递归 add"] F -->|Array| K["new Array()"] F -->|Object| L[保持原型链创建] G & H & I & J & K & L --> M[缓存到 WeakMap] M --> N[遍历属性递归拷贝] N --> O[返回 copy]javascriptfunction deepClone(target, cache = new WeakMap()) { // 基本类型直接返回 if (target === null || typeof target !== 'object') { return target; } // 处理循环引用 if (cache.has(target)) { return cache.get(target); } let result; // 处理 Date if (target instanceof Date) { result = new Date(target); cache.set(target, result); return result; } // 处理 RegExp if (target instanceof RegExp) { result = new RegExp(target.source, target.flags); result.lastIndex = target.lastIndex; cache.set(target, result); return result; } // 处理 Map if (target instanceof Map) { result = new Map(); cache.set(target, result); target.forEach((value, key) => { result.set(deepClone(key, cache), deepClone(value, cache)); }); return result; } // 处理 Set if (target instanceof Set) { result = new Set(); cache.set(target, result); target.forEach(value => { result.add(deepClone(value, cache)); }); return result; } // 处理数组和普通对象(保持原型链) result = Array.isArray(target) ? [] : Object.create(Object.getPrototypeOf(target)); cache.set(target, result); // 遍历自身属性(包括 Symbol 属性) const keys = [...Object.keys(target), ...Object.getOwnPropertySymbols(target)]; for (const key of keys) { result[key] = deepClone(target[key], cache); } return result; } // 测试 const obj = { name: 'test', date: new Date(), reg: /hello/gi, map: new Map([['key', 'value']]), set: new Set([1, 2, 3]), nested: { a: { b: 1 } }, [Symbol('id')]: 'symbol-value' }; obj.self = obj; // 循环引用 const cloned = deepClone(obj); console.log(cloned.self === cloned); // true —— 循环引用保留结构 console.log(cloned === obj); // false —— 完全独立 console.log(cloned.date instanceof Date); // true console.log(cloned.reg instanceof RegExp); // true
小结
| 对比维度 | 浅拷贝 | 深拷贝 |
|---|---|---|
| 拷贝层级 | 仅第一层属性 | 递归所有层级 |
| 嵌套引用 | 共享引用 | 完全独立 |
| 内存占用 | 较小 | 较大 |
| 性能 | 快(O(n)) | 慢(O(n * depth)) |
| 循环引用 | 不需处理 | 需要 WeakMap 缓存 |
| 特殊对象(Date/RegExp/Map) | 直接拷贝引用 | 需要类型分支处理 |
| 实现复杂度 | 简单 | 复杂 |
| 天然处理 Symbol 属性 | Object.assign/扩展运算符可以 | 手写需显式处理 |
面试追问
追问1:Object.assign() 和扩展运算符 {...obj} 在浅拷贝上有区别吗?
两者都做浅拷贝,但有一个小的差异,Object.assign() 会触发源对象的 setter,而扩展运算符不会。此外,Object.assign() 接收多个源对象时有合并效果,扩展运算符仅操作单个源。
const obj = {
get a() { return 1; },
set a(v) { console.log('setter called', v); }
};
const copy1 = { ...obj }; // 不触发 setter
const copy2 = Object.assign({}, obj); // 触发 setter追问2:手写深拷贝时,用 WeakMap 和普通 Map 有什么区别?为什么选 WeakMap?
WeakMap 的 key 是弱引用,当原对象被垃圾回收时,WeakMap 中对应的条目会被自动清除,不会造成内存泄漏。如果用普通 Map,即使原对象已经不再使用,只要深拷贝过程中的 Map 还在引用它,就无法被 GC 回收。
// 用 Map 的隐患
function deepCloneWithMap(target, map = new Map()) {
// ... 即使 target 不再使用,map 里的引用会阻止 GC
}追问3:以下代码输出什么?为什么?
const a = { x: 1 };
const b = { ...a, x: undefined };
const c = JSON.parse(JSON.stringify(a));
console.log(b.x); // ?
console.log(c.x); // ?
delete a.x;
console.log('x' in a); // ?
console.log('x' in b); // ?
console.log('x' in c); // ?输出依次为:undefined、1、false、true、false
-
b.x = undefined是显式赋值,键仍然存在,值为 undefined -
JSON.parse(JSON.stringify({ x: 1 }))正常序列化/反序列化,x 仍为 1 -
delete a.x移除了键,所以'x' in a为 false -
'x' in b为 true,因为键还在(即使值为 undefined) -
JSON.stringify会忽略值为 undefined 的键,所以 c 中没有 x 键,'x' in c为 false
追问4:实现一个按需深拷贝函数,给定一个 paths 数组,只对指定路径做深拷贝,其他层级用浅拷贝。
function selectiveClone(obj, paths) {
// paths: [['a', 'b'], ['c']]
// 要求:obj.a.b 深拷贝,obj.c 深拷贝,其余浅拷贝
// 请实现
}答案:
function selectiveClone(obj, paths) {
const deepPaths = new Set(paths.map(p => p.join('.')));
function clone(value, path = '') {
if (value === null || typeof value !== 'object') return value;
// 当前路径在深拷贝集合中,进入深拷贝
if (deepPaths.has(path)) {
// 简单的深拷贝(处理基本嵌套,略去特殊类型)
if (Array.isArray(value)) {
return value.map((item, i) => clone(item, `${path}[${i}]`));
}
const result = {};
for (const key of Object.keys(value)) {
const newPath = path ? `${path}.${key}` : key;
result[key] = clone(value[key], newPath);
}
return result;
}
// 不在深拷贝集合中,浅拷贝
if (Array.isArray(value)) return [...value];
return { ...value };
}
return clone(obj, '');
}
// 测试
const data = {
a: { b: { c: 1 }, d: 2 },
e: { f: 3 }
};
const cloned = selectiveClone(data, [['a', 'b'], ['e']]);
console.log(cloned.a.b === data.a.b); // false —— 深拷贝
console.log(cloned.a.d === data.a.d); // true —— 浅拷贝,共享引用
console.log(cloned.e === data.e); // false —— 深拷贝追问5:在 React/Vue 项目中,深拷贝的不当使用可能导致什么性能问题?怎么解决?
深拷贝在以下场景容易成为性能瓶颈:
-
大对象频繁拷贝:比如 Redux reducer 中每次 dispatch 都深拷贝整个 state tree,O(n * depth) 的开销在大型表单或数据表格中可能导致页面卡顿
-
Props 层层穿透:父组件深拷贝数据后传入子组件,子组件再做一次深拷贝,O(n²) 复杂度
-
不可变数据的低效实现: 用深拷贝代替 Immutable.js 或 Immer 的结构共享
解决方案
方案 原理 适用场景 Immer (produce) Proxy 拦截 + 结构共享 Redux reducer、复杂状态 Immer (patch) 增量变更 + 补丁回放 撤销/重做、协同编辑 Immutable.js 持久化数据结构(Trie) 大型嵌套数据,需要高效比较 浅比较 + 不可变更新 展开运算符手动更新指定层级 简单状态、小规模数据 javascript// Immer 替代深拷贝 import { produce } from 'immer'; const baseState = { users: [{ name: 'Alice', scores: [90, 95] }] }; const nextState = produce(baseState, draft => { draft.users[0].scores.push(100); }); // Immer 只复制变更的节点,未变更的节点共享引用 // 性能远优于 JSON.parse(JSON.stringify(...))