Javascript篇 | 深浅拷贝
前言
深拷贝和浅拷贝是前端面试的经典题目,几乎每个前端工程师都被问过”手写一个深拷贝”。
这道题看起来简单,但实际上水很深:
- 你以为
JSON.parse(JSON.stringify(obj))就够了?那undefined、函数、Date、正则怎么办? - 你以为递归遍历就行了?那循环引用怎么处理?
- 你知道
structuredClone吗?它能解决所有问题吗?
面试官通过这道题,不仅考你对 JavaScript 数据类型的理解,还考你的代码功底和对边界情况的处理能力。今天我们就从基本类型说起,一步步实现一个生产级别的深拷贝。
诊断自测
在开始之前,试着回答以下问题:
1. 以下代码输出什么?
const a = { x: 1, y: { z: 2 } };
const b = { ...a };
b.x = 10;
b.y.z = 20;
console.log(a.x);
console.log(a.y.z);
2. JSON.parse(JSON.stringify(obj)) 有哪些局限?至少说出 3 个。
3. 以下对象能被正确深拷贝吗?
const obj = { a: 1 };
obj.self = obj; // 循环引用
点击查看答案
第1题: a.x 输出 1,a.y.z 输出 20。展开运算符是浅拷贝,b.x 是基本类型的独立副本,修改不影响 a.x。但 b.y 和 a.y 指向同一个对象,修改 b.y.z 等于修改 a.y.z。
第2题: JSON 方法的局限包括:
- 丢失
undefined、函数、Symbol Date对象变成字符串- 正则变成空对象
{} NaN、Infinity变成null- 无法处理循环引用(直接报错)
- 丢失原型链
- 不支持
Map、Set、ArrayBuffer等
第3题: 用 JSON.parse(JSON.stringify(obj)) 会报 TypeError: Converting circular structure to JSON。需要用 WeakMap 记录已拷贝的对象来处理循环引用。structuredClone(obj) 可以正确处理。
如果三道题都答对了,说明你基础不错!继续阅读可以查漏补缺。
基本类型 vs 引用类型
这是理解深浅拷贝的前提。
基本类型(Primitive)
7 种基本类型:string、number、boolean、null、undefined、symbol、bigint
基本类型存储在栈内存中,赋值时直接复制值:
let a = 42;
let b = a; // 复制值
b = 100;
console.log(a); // 42 —— 不受影响
引用类型(Reference)
引用类型包括:Object、Array、Function、Date、RegExp、Map、Set 等
引用类型存储在堆内存中,变量保存的是指向堆内存的引用(指针):
let obj1 = { name: 'Alice' };
let obj2 = obj1; // 复制的是引用,不是对象本身
obj2.name = 'Bob';
console.log(obj1.name); // 'Bob' —— obj1 也被修改了!
💡 核心:基本类型赋值是”复制值”,引用类型赋值是”复制指针”。理解了这点,深浅拷贝就没什么难的了。
浅拷贝
浅拷贝:创建新对象,只复制第一层属性。如果属性是基本类型,复制值;如果属性是引用类型,复制引用。
Object.assign
const original = {
name: 'Alice',
age: 25,
address: {
city: 'Beijing'
}
};
const copy = Object.assign({}, original);
copy.name = 'Bob'; // 不影响 original
copy.address.city = 'Shanghai'; // 影响 original!
console.log(original.name); // 'Alice'
console.log(original.address.city); // 'Shanghai' ← 被修改了
展开运算符
const original = { a: 1, b: { c: 2 } };
const copy = { ...original };
// 效果和 Object.assign 一样,都是浅拷贝
copy.b.c = 99;
console.log(original.b.c); // 99
数组的浅拷贝
const arr = [1, 2, { x: 3 }];
// 以下方法都是浅拷贝
const copy1 = [...arr];
const copy2 = arr.slice();
const copy3 = Array.from(arr);
const copy4 = arr.concat();
copy1[2].x = 99;
console.log(arr[2].x); // 99 —— 共享引用
浅拷贝够用吗?
如果对象只有一层(没有嵌套对象),浅拷贝完全足够。 实际上大部分场景浅拷贝就够了,不需要动不动就深拷贝。
// ✅ 这种场景浅拷贝就够了
const config = { theme: 'dark', fontSize: 14, lang: 'zh' };
const newConfig = { ...config, theme: 'light' };
// ⚠️ 这种场景需要深拷贝
const state = {
user: { name: 'Alice', preferences: { theme: 'dark' } },
posts: [{ id: 1, title: 'Hello' }]
};
深拷贝
深拷贝:创建新对象,递归复制所有层级的属性,新对象和原对象完全独立,互不影响。
方法一:JSON.parse(JSON.stringify())
最简单的深拷贝方式,日常开发中用得很多:
const original = {
name: 'Alice',
address: {
city: 'Beijing',
district: { name: 'Haidian' }
},
hobbies: ['reading', 'coding']
};
const copy = JSON.parse(JSON.stringify(original));
copy.address.city = 'Shanghai';
copy.address.district.name = 'Pudong';
copy.hobbies.push('gaming');
console.log(original.address.city); // 'Beijing' ✅
console.log(original.address.district.name); // 'Haidian' ✅
console.log(original.hobbies); // ['reading', 'coding'] ✅
但它有很多致命局限:
const problematic = {
fn: function() {}, // ❌ 丢失
undef: undefined, // ❌ 丢失
sym: Symbol('test'), // ❌ 丢失
date: new Date(), // ❌ 变成字符串
regex: /hello/gi, // ❌ 变成空对象 {}
nan: NaN, // ❌ 变成 null
infinity: Infinity, // ❌ 变成 null
map: new Map([[1, 2]]), // ❌ 变成空对象 {}
set: new Set([1, 2, 3]), // ❌ 变成空对象 {}
};
const copy = JSON.parse(JSON.stringify(problematic));
console.log(copy);
// {
// date: "2024-01-01T00:00:00.000Z", (字符串)
// regex: {},
// nan: null,
// infinity: null,
// map: {},
// set: {}
// }
// fn, undef, sym 直接消失了
循环引用直接报错:
const obj = { a: 1 };
obj.self = obj;
JSON.parse(JSON.stringify(obj));
// TypeError: Converting circular structure to JSON
适用场景: 数据是纯 JSON 结构(只有字符串、数字、布尔、null、数组、普通对象),没有循环引用。
方法二:手写深拷贝
这是面试的重头戏。我们从简单到复杂,一步步实现。
最基础版:递归拷贝
function deepClone(obj) {
// 基本类型和 null 直接返回
if (obj === null || typeof obj !== 'object') {
return obj;
}
// 数组和普通对象
const copy = Array.isArray(obj) ? [] : {};
for (const key in obj) {
if (obj.hasOwnProperty(key)) {
copy[key] = deepClone(obj[key]); // 递归
}
}
return copy;
}
这个版本能处理普通对象和数组的嵌套,但还有很多问题。
进阶版:处理循环引用
用 WeakMap 记录已经拷贝过的对象:
function deepClone(obj, map = new WeakMap()) {
if (obj === null || typeof obj !== 'object') {
return obj;
}
// 循环引用检测
if (map.has(obj)) {
return map.get(obj);
}
const copy = Array.isArray(obj) ? [] : {};
map.set(obj, copy); // 先记录,再递归
for (const key in obj) {
if (obj.hasOwnProperty(key)) {
copy[key] = deepClone(obj[key], map);
}
}
return copy;
}
// 测试循环引用
const obj = { a: 1, b: { c: 2 } };
obj.self = obj;
obj.b.parent = obj;
const copy = deepClone(obj);
console.log(copy.self === copy); // true ✅
console.log(copy.b.parent === copy); // true ✅
console.log(copy !== obj); // true ✅
为什么用 WeakMap 而不是 Map?因为 WeakMap 的 key 是弱引用,不会阻止垃圾回收。深拷贝完成后,如果原对象不再被引用,WeakMap 中对应的条目会被自动清理。
完整版:处理特殊对象类型
function deepClone(obj, map = new WeakMap()) {
// 基本类型和 null
if (obj === null || typeof obj !== 'object') {
return obj;
}
// 循环引用
if (map.has(obj)) {
return map.get(obj);
}
// Date
if (obj instanceof Date) {
return new Date(obj.getTime());
}
// RegExp
if (obj instanceof RegExp) {
return new RegExp(obj.source, obj.flags);
}
// Map
if (obj instanceof Map) {
const copy = new Map();
map.set(obj, copy);
obj.forEach((value, key) => {
copy.set(deepClone(key, map), deepClone(value, map));
});
return copy;
}
// Set
if (obj instanceof Set) {
const copy = new Set();
map.set(obj, copy);
obj.forEach(value => {
copy.add(deepClone(value, map));
});
return copy;
}
// 普通对象和数组
const copy = Array.isArray(obj)
? []
: Object.create(Object.getPrototypeOf(obj)); // 保留原型链
map.set(obj, copy);
// 拷贝自身所有属性(包括 Symbol 属性)
const keys = [
...Object.keys(obj),
...Object.getOwnPropertySymbols(obj)
];
for (const key of keys) {
copy[key] = deepClone(obj[key], map);
}
return copy;
}
测试:
const sym = Symbol('test');
const original = {
num: 42,
str: 'hello',
bool: true,
nil: null,
undef: undefined,
arr: [1, [2, 3], { nested: true }],
date: new Date('2024-01-01'),
regex: /hello/gi,
map: new Map([['key', { deep: true }]]),
set: new Set([1, 2, { three: 3 }]),
[sym]: 'symbol value',
};
original.self = original;
const copy = deepClone(original);
// 验证
console.log(copy.date instanceof Date); // true
console.log(copy.date !== original.date); // true(独立副本)
console.log(copy.regex instanceof RegExp); // true
console.log(copy.map.get('key').deep); // true
console.log(copy.set.size); // 3
console.log(copy[sym]); // 'symbol value'
console.log(copy.self === copy); // true(循环引用正确)
方法三:structuredClone(推荐)
structuredClone 是浏览器和 Node.js 内置的深拷贝 API,2022 年开始各大环境都已支持:
const original = {
date: new Date(),
regex: /hello/gi,
map: new Map([['a', 1]]),
set: new Set([1, 2, 3]),
arrayBuffer: new ArrayBuffer(8),
nested: { deep: { value: 42 } }
};
original.self = original; // 循环引用
const copy = structuredClone(original);
console.log(copy.date instanceof Date); // true ✅
console.log(copy.map instanceof Map); // true ✅
console.log(copy.self === copy); // true ✅(循环引用正确)
structuredClone 的优势:
- 原生实现,性能好
- 支持循环引用
- 支持 Date、RegExp、Map、Set、ArrayBuffer、Blob 等
- 支持嵌套对象的深度拷贝
structuredClone 的局限:
// ❌ 不支持函数
structuredClone({ fn: () => {} });
// DOMException: Failed to execute 'structuredClone'
// ❌ 不支持 DOM 节点
structuredClone(document.body);
// ❌ 不支持 Symbol 属性
const sym = Symbol('key');
const obj = { [sym]: 'value' };
const copy = structuredClone(obj);
console.log(copy[sym]); // undefined
// ❌ 不保留原型链
class Dog {
constructor(name) { this.name = name; }
bark() { return 'woof'; }
}
const dog = new Dog('Buddy');
const copy2 = structuredClone(dog);
console.log(copy2 instanceof Dog); // false
console.log(copy2.bark); // undefined
三种方法对比
| 特性 | JSON 方法 | 手写深拷贝 | structuredClone |
|---|---|---|---|
| 循环引用 | ❌ 报错 | ✅ WeakMap | ✅ 原生支持 |
| Date | ❌ 变字符串 | ✅ | ✅ |
| RegExp | ❌ 变 {} | ✅ | ✅ |
| Map/Set | ❌ 变 {} | ✅ | ✅ |
| 函数 | ❌ 丢失 | ✅ 保留引用 | ❌ 报错 |
| undefined | ❌ 丢失 | ✅ | ✅ |
| Symbol 属性 | ❌ 丢失 | ✅ | ❌ |
| 原型链 | ❌ 丢失 | ✅ | ❌ |
| 性能 | 中等 | 取决于实现 | 最快 |
| 兼容性 | 全兼容 | 全兼容 | 现代环境 |
选择建议:
- 纯 JSON 数据 →
JSON.parse(JSON.stringify()) - 需要处理特殊类型 →
structuredClone - 需要保留函数/原型链/Symbol → 手写或 lodash
_.cloneDeep
常见误区
误区1:展开运算符是深拷贝
const obj = { a: 1, b: { c: 2 } };
const copy = { ...obj };
copy.b.c = 99;
console.log(obj.b.c); // 99 —— 浅拷贝!第二层共享引用
展开运算符只拷贝第一层,和 Object.assign 一样是浅拷贝。
误区2:JSON 方法能处理所有情况
上面已经详细讲过了。最常见的坑是 Date 对象反序列化后变成字符串,导致后续 getTime() 等方法调用失败。
const obj = { createdAt: new Date() };
const copy = JSON.parse(JSON.stringify(obj));
console.log(typeof copy.createdAt); // 'string' ← 不是 Date 了
console.log(copy.createdAt.getTime()); // TypeError!
误区3:深拷贝能复制函数
函数无法被真正”深拷贝”。 手写深拷贝和 lodash 的 _.cloneDeep 都只是保留函数的引用:
const obj = {
greet: function() { console.log('hello'); }
};
const copy = deepClone(obj);
console.log(copy.greet === obj.greet); // true —— 同一个函数引用
这在大多数场景下是合理的——函数本身是无状态的(不考虑闭包),复制引用就够了。
误区4:手写深拷贝只需要递归
纯递归的深拷贝有栈溢出的风险:
// 极深嵌套的对象
let obj = {};
let current = obj;
for (let i = 0; i < 100000; i++) {
current.child = {};
current = current.child;
}
deepClone(obj); // RangeError: Maximum call stack size exceeded
如果需要处理极深嵌套,可以用迭代 + 栈来代替递归。但面试中一般不要求这个,知道有这个问题即可。
小结
深浅拷贝是 JavaScript 基础中的基础,但要把它完全掌握并不容易。
核心要点
- 基本类型赋值是值拷贝,引用类型赋值是引用拷贝
- 浅拷贝:只复制第一层属性,嵌套对象仍然共享引用
- 深拷贝:递归复制所有层级,完全独立
- JSON 方法:简单好用但局限多,只适合纯 JSON 数据
- 手写深拷贝关键点:WeakMap 处理循环引用、特殊对象类型处理、Symbol 属性
- structuredClone:现代 API 首选,但不支持函数和 Symbol
本章思维导图
- 基础概念
- 基本类型:栈内存,值拷贝
- 引用类型:堆内存,引用拷贝
- 浅拷贝
- Object.assign
- 展开运算符 { ...obj }
- Array.from / slice / concat
- 只复制第一层
- 深拷贝
- JSON.parse(JSON.stringify())
- 优点:简单
- 缺点:丢失函数/undefined/Symbol/Date/RegExp
- 手写深拷贝
- 递归遍历
- WeakMap 处理循环引用
- 特殊类型:Date/RegExp/Map/Set
- Symbol 属性
- 原型链保留
- structuredClone
- 原生 API,性能最好
- 支持循环引用和特殊类型
- 不支持函数/Symbol/原型链
- JSON.parse(JSON.stringify())
- 选择建议
- 纯 JSON → JSON 方法
- 特殊类型 → structuredClone
- 函数/原型链 → 手写或 lodash
练习挑战
挑战一:基础(⭐)
以下代码输出什么?解释原因。
const arr = [1, [2, 3], { a: 4 }];
const copy = [...arr];
copy[0] = 10;
copy[1][0] = 20;
copy[2].a = 40;
console.log(arr[0]); // A
console.log(arr[1][0]); // B
console.log(arr[2].a); // C
答案与解析
- A:
1—copy[0]是基本类型,修改不影响原数组。 - B:
20—copy[1]和arr[1]指向同一个数组,修改会互相影响。 - C:
40—copy[2]和arr[2]指向同一个对象,修改会互相影响。
展开运算符是浅拷贝。第一层的基本类型是值拷贝(独立),但第一层的引用类型只是拷贝了引用(共享)。
挑战二:进阶(⭐⭐)
手写一个深拷贝函数,至少支持以下类型:普通对象、数组、Date、RegExp、循环引用。
const obj = {
arr: [1, { nested: true }],
date: new Date('2024-06-01'),
regex: /test/gi,
};
obj.self = obj;
const copy = deepClone(obj);
// copy 应该是 obj 的完全独立副本
答案与解析
function deepClone(obj, map = new WeakMap()) {
if (obj === null || typeof obj !== 'object') return obj;
if (map.has(obj)) return map.get(obj);
if (obj instanceof Date) return new Date(obj.getTime());
if (obj instanceof RegExp) return new RegExp(obj.source, obj.flags);
const copy = Array.isArray(obj) ? [] : {};
map.set(obj, copy);
for (const key of Object.keys(obj)) {
copy[key] = deepClone(obj[key], map);
}
return copy;
}
面试中写出这个版本就足够了。关键点:
- 基本类型直接返回
- WeakMap 检测循环引用
- Date 和 RegExp 用构造函数创建新实例
- 递归处理普通对象和数组
挑战三:综合(⭐⭐⭐)
实现一个 deepEqual 函数,用于判断两个值是否”深度相等”:
deepEqual(1, 1); // true
deepEqual({ a: 1 }, { a: 1 }); // true
deepEqual({ a: { b: 2 } }, { a: { b: 2 } }); // true
deepEqual([1, [2]], [1, [2]]); // true
deepEqual({ a: 1, b: 2 }, { b: 2, a: 1 }); // true(键顺序无关)
deepEqual(new Date('2024-01-01'), new Date('2024-01-01')); // true
deepEqual(NaN, NaN); // true
deepEqual({ a: 1 }, { a: 1, b: undefined }); // false
答案与解析
function deepEqual(a, b) {
// 严格相等(包括同一引用)
if (a === b) return true;
// NaN 特殊处理
if (Number.isNaN(a) && Number.isNaN(b)) return true;
// 类型不同或有 null
if (typeof a !== typeof b) return false;
if (a === null || b === null) return false;
// 非对象类型到这里说明不相等
if (typeof a !== 'object') return false;
// Date 比较
if (a instanceof Date && b instanceof Date) {
return a.getTime() === b.getTime();
}
// RegExp 比较
if (a instanceof RegExp && b instanceof RegExp) {
return a.source === b.source && a.flags === b.flags;
}
// 确保同一种对象类型
if (a.constructor !== b.constructor) return false;
// 数组和对象
const keysA = Object.keys(a);
const keysB = Object.keys(b);
if (keysA.length !== keysB.length) return false;
return keysA.every(key =>
Object.prototype.hasOwnProperty.call(b, key) &&
deepEqual(a[key], b[key])
);
}
关键点:
NaN !== NaN,需要特殊处理- Date 用
getTime()比较 - 先比较键的数量,再递归比较每个值
- 键顺序无关,只要所有键都存在且值相等
自我检测
读完本章后,确认你能回答以下问题:
- 能解释基本类型和引用类型在赋值时的区别(值拷贝 vs 引用拷贝)
- 能列举至少 3 种浅拷贝的方法
- 能说出 JSON.parse(JSON.stringify()) 的至少 5 个局限
- 能手写深拷贝,处理普通对象、数组、循环引用
- 能扩展手写深拷贝,处理 Date、RegExp、Map、Set
- 知道 structuredClone 的优势和局限
- 能根据场景选择合适的拷贝方案
- 理解 WeakMap 在深拷贝中的作用(弱引用、防止内存泄漏)
- 知道函数无法被真正深拷贝,只能保留引用
购买课程解锁全部内容
大厂前端面试通关:71 篇构建完整知识体系
¥89.90