跳转到内容

深拷贝、浅拷贝与堆栈面试题复盘

约 11 分钟阅读发布于 2024/1/1

这是前端面试里非常常见的一类问题:

什么是深拷贝和浅拷贝?它们和堆、栈有什么关系?

这道题看起来像是在问 API,实际上是在考察你是否理解 JavaScript 的数据类型、内存模型和引用关系。

如果只回答“浅拷贝只拷贝一层,深拷贝会递归拷贝”,只能算答到表面。更完整的回答应该从基本类型和引用类型讲起,再解释为什么对象拷贝容易互相影响。

在 JavaScript 中,数据大致可以分成两类:

  • 基本类型:stringnumberbooleanundefinednullsymbolbigint
  • 引用类型:objectarrayfunctionDateMapSet

基本类型保存的是值本身,赋值时通常是值的复制。

引用类型保存的是对象的引用,赋值时复制的是引用地址,所以多个变量可能指向同一个对象。

浅拷贝只拷贝对象第一层。如果第一层里还有对象,里面的对象仍然共享引用。

深拷贝会把嵌套对象也复制出来,让新对象和旧对象尽量互不影响。

面试里常见说法是:

  • 基本类型的值通常放在栈中。
  • 引用类型的对象内容通常放在堆中。
  • 变量里保存的是指向堆中对象的引用。

这是一种帮助理解的简化模型,不需要把它讲得像浏览器引擎源码一样复杂。

可以这样理解:

let name = 'xiaoxi';
栈:
name -> 'xiaoxi'

基本类型比较直接,变量和值之间的关系很简单。

再看对象:

const user = {
name: 'xiaoxi',
profile: {
age: 18,
},
};

可以理解为:

栈:
user -> 引用地址 0x001
堆:
0x001 -> {
name: 'xiaoxi',
profile: 引用地址 0x002
}
0x002 -> {
age: 18
}

对象本身放在堆里,变量 user 保存的是引用。

很多拷贝问题都从赋值开始。

reference-assignment.js
const user1 = {
name: 'xiaoxi',
profile: {
age: 18,
},
};
const user2 = user1;
user2.name = 'veyliss';
console.log(user1.name); // veyliss

这里 user2 = user1 并没有创建一个新对象,只是让 user2user1 指向同一个对象。

所以修改 user2.nameuser1.name 也会变。

可以理解为:

user1 -> 0x001
user2 -> 0x001

两个变量指向同一个堆对象。

浅拷贝会创建一个新的外层对象,但里面的引用类型属性仍然和原对象共享。

常见浅拷贝方式:

  • 展开运算符:{ ...obj }
  • Object.assign()
  • 数组的 slice()concat()Array.from()[...arr]

看一个例子:

shallow-copy.js
const user1 = {
name: 'xiaoxi',
profile: {
age: 18,
},
};
const user2 = {
...user1,
};
user2.name = 'veyliss';
user2.profile.age = 20;
console.log(user1.name); // xiaoxi
console.log(user1.profile.age); // 20

为什么 name 没有互相影响,但 profile.age 互相影响了?

因为 user2 是一个新的外层对象,但 profile 仍然指向同一个内部对象。

可以理解为:

user1 -> 0x001 -> {
name: 'xiaoxi',
profile: 0x002
}
user2 -> 0x003 -> {
name: 'xiaoxi',
profile: 0x002
}

外层对象不同,内层 profile 相同。

这就是浅拷贝。

深拷贝会递归复制对象中的嵌套对象,让新对象和旧对象不再共享内部引用。

deep-copy-result.js
const user1 = {
name: 'xiaoxi',
profile: {
age: 18,
},
};
const user2 = structuredClone(user1);
user2.profile.age = 20;
console.log(user1.profile.age); // 18
console.log(user2.profile.age); // 20

这时可以理解为:

user1 -> 0x001 -> profile -> 0x002
user2 -> 0x003 -> profile -> 0x004

外层对象不同,内层对象也不同。

以前很常见的一种写法是:

json-clone.js
const copy = JSON.parse(JSON.stringify(source));

这种方式简单,但有明显限制。

它适合普通 JSON 数据:

const source = {
name: 'xiaoxi',
tags: ['前端', 'JavaScript'],
};
const copy = JSON.parse(JSON.stringify(source));

但它处理不了很多特殊值:

json-clone-limit.js
const source = {
name: 'xiaoxi',
createdAt: new Date(),
sayHello() {
console.log('hello');
},
value: undefined,
};
const copy = JSON.parse(JSON.stringify(source));
console.log(copy.createdAt); // 字符串,不再是 Date
console.log(copy.sayHello); // undefined
console.log(copy.value); // undefined

它的主要问题包括:

  • Date 会变成字符串。
  • undefined、函数、symbol 会丢失。
  • MapSet 不能按原结构保留。
  • 遇到循环引用会直接报错。

所以面试时不要把 JSON.parse(JSON.stringify()) 说成万能深拷贝。

现代浏览器和 Node.js 中可以使用 structuredClone()

structured-clone.js
const source = {
name: 'xiaoxi',
createdAt: new Date(),
items: new Map([['count', 1]]),
};
const copy = structuredClone(source);
console.log(copy.createdAt instanceof Date); // true
console.log(copy.items instanceof Map); // true

相比 JSON 方法,structuredClone() 能保留更多内置类型,也能处理循环引用。

structured-clone-cycle.js
const source = {
name: 'xiaoxi',
};
source.self = source;
const copy = structuredClone(source);
console.log(copy.self === copy); // true

不过它也不是没有限制。函数、DOM 节点这类内容不能直接被结构化克隆。

面试里有时会要求手写一个简单版本。

最基础的写法是递归:

simple-deep-clone.js
function deepClone(value) {
if (value === null || typeof value !== 'object') {
return value;
}
const result = Array.isArray(value) ? [] : {};
for (const key in value) {
if (Object.prototype.hasOwnProperty.call(value, key)) {
result[key] = deepClone(value[key]);
}
}
return result;
}

这个版本可以处理普通对象和数组,但不能处理循环引用,也不能完整处理 DateMapSet 等类型。

如果要支持循环引用,可以用 WeakMap 记录已经拷贝过的对象。

deep-clone-with-weakmap.js
function deepClone(value, cache = new WeakMap()) {
if (value === null || typeof value !== 'object') {
return value;
}
if (cache.has(value)) {
return cache.get(value);
}
if (value instanceof Date) {
return new Date(value.getTime());
}
if (value instanceof Map) {
const result = new Map();
cache.set(value, result);
value.forEach((item, key) => {
result.set(deepClone(key, cache), deepClone(item, cache));
});
return result;
}
if (value instanceof Set) {
const result = new Set();
cache.set(value, result);
value.forEach((item) => {
result.add(deepClone(item, cache));
});
return result;
}
const result = Array.isArray(value) ? [] : {};
cache.set(value, result);
Reflect.ownKeys(value).forEach((key) => {
result[key] = deepClone(value[key], cache);
});
return result;
}

这个版本已经能覆盖不少面试场景:

  • 基本类型直接返回。
  • 普通对象和数组递归复制。
  • Date 单独处理。
  • MapSet 单独处理。
  • WeakMap 解决循环引用。
  • Reflect.ownKeys() 可以拿到 symbol key。

但它仍然不是完整工业级实现。比如属性描述符、原型链、不可枚举属性、函数、DOM 节点等,还需要更多额外处理。

不是所有场景都需要深拷贝。

场景推荐方式
只改第一层字段浅拷贝即可
React 更新一层状态展开运算符或 Object.assign()
嵌套对象也要完全隔离深拷贝
普通 JSON 数据复制JSON 方法可以考虑
复杂对象、循环引用structuredClone() 或专门工具
高性能、大数据量场景尽量避免无脑深拷贝

在 React 里也经常会遇到这个问题。

比如更新用户名称:

setUser((user) => ({
...user,
name: 'veyliss',
}));

这是浅拷贝,足够更新第一层。

如果要更新嵌套字段:

setUser((user) => ({
...user,
profile: {
...user.profile,
age: 20,
},
}));

这不是对整个对象做深拷贝,而是只拷贝发生变化的路径。这个方式在 React 里更常见,也更可控。

这道题可以这样回答:

JavaScript 里基本类型通常保存值本身,引用类型保存的是对象引用。对象内容可以理解为存在堆中,变量里保存的是引用地址。普通赋值只是复制引用,不会创建新对象。浅拷贝会创建一个新的外层对象,但嵌套对象仍然共享引用;深拷贝会递归复制嵌套对象,让新旧对象尽量不共享引用。常见浅拷贝方式有展开运算符、Object.assign()、数组 slice() 等;常见深拷贝方式有 structuredClone()、JSON 序列化和手写递归。JSON 方法有局限,会丢失函数、undefined,也不能处理循环引用。手写深拷贝时要考虑递归、特殊类型和循环引用,可以用 WeakMap 做缓存。

如果面试官继续追问,可以展开这些点:

  • 赋值、浅拷贝、深拷贝的区别。
  • 为什么浅拷贝会影响嵌套对象。
  • JSON.parse(JSON.stringify()) 有哪些问题。
  • structuredClone() 能解决什么,不能解决什么。
  • 手写深拷贝如何处理循环引用。
  • React 中为什么经常只拷贝变化路径。

深拷贝、浅拷贝和堆栈这道题,本质是在问你是否理解引用。

可以记住这几句话:

  • 基本类型更像“直接保存值”。
  • 引用类型更像“变量保存地址,对象放在堆里”。
  • 赋值复制的是引用。
  • 浅拷贝复制第一层。
  • 深拷贝递归复制嵌套层。
  • 深拷贝不是越多越好,要看业务是否真的需要完全隔离。

面试里把这些关系讲清楚,比单纯背一个手写深拷贝函数更重要。