题干与适用场景
实现 deepClone(value)。输入是一个有限、同一 JavaScript realm 内的对象图,可以包含基本类型、数组、原型为 Object.prototype 或 null 的普通对象、Date、RegExp、Map 和 Set。副本中的每个受支持对象都必须是新对象;如果源图中两条边指向同一对象,副本中对应的两条边也必须指向同一个副本。循环引用不能导致无限递归。
本题还要求复制所有自有数据属性,包括不可枚举属性和 Symbol 键,并保留这些属性的描述符。数组空位、Date 时间值、RegExp 的 source、flags 与 lastIndex,以及 Map 的键和值、Set 的值都要正确复制。
函数、存取器属性、WeakMap、WeakSet、Proxy、TypedArray、ArrayBuffer、自定义类实例和其他未列出的内置对象不在契约内,遇到时应抛出 TypeError。实现使用递归,因此还假设嵌套深度不会耗尽调用栈。真实业务若只需要平台支持的可结构化克隆类型,应优先评估 structuredClone,而不是把面试实现包装成通用库。
面试官考察点
第一个信号是先定义“深拷贝”的边界。JavaScript 没有一个能覆盖函数闭包、DOM 节点、私有字段、Proxy 和所有内置槽位的通用用户态克隆规则。强回答会先列支持类型、属性语义、循环规则和失败方式,再开始写递归。
第二个信号是把输入看成对象图,而非对象树。只递归创建新对象可以复制树,却无法处理 source.self = source,也会把 source.a === source.b 错误地拆成两个副本。正确状态不是“访问过没有”,而是“这个源对象对应哪个副本”。
第三个信号是分配与遍历的顺序:先创建空容器并写入源对象到副本的映射,再递归复制它的出边。若先复制子属性后登记,第一次遇到回边时仍找不到副本,循环引用会继续递归。
第四个信号是能否如实说明属性和内置类型边界。Object.entries 会漏掉 Symbol 键和不可枚举属性;读取 source[key] 可能执行 getter;给 Date、Map 或 Set 只创建相同原型的空对象,并不会复制它们的内部槽位。
回答前需要澄清的问题
- 需要支持哪些类型? 只含 JSON 数据时,数组和普通对象递归即可;加入
Date、RegExp、Map、Set后,每类都需要专门的构造和遍历规则;加入 TypedArray 或 ArrayBuffer 又会改变缓冲区共享与复制策略。 - 循环和重复引用要报错、断开,还是保留拓扑? 本题要求保留,因此需要源对象到副本的映射。只用
WeakSet能发现重复,却无法返回正确副本。 - 属性只复制可枚举字符串键,还是保留 Symbol、不可枚举属性和描述符? 本题选择后者,并拒绝存取器属性,避免在复制时执行 getter 或共享 getter/setter 函数。
- 需要复制自定义类和原型链吗? 本题只接受普通对象与列出的内置类型。仅用
Object.create(instancePrototype)无法复制私有字段或构造器建立的内部状态,继续伪装成完整实例会产生失真的对象。 - 这是面试算法还是生产接口? 面试实现用于展示契约和对象图不变量;生产代码应先比较
structuredClone的类型支持、传输语义和元数据损失,再决定是否需要受控的自定义序列化器。 - 最大嵌套深度是多少? 递归方案的辅助栈为
O(d)。如果输入可能形成十万层链,应改用显式工作栈,算法状态和测试重点都会改变。
30 秒回答框架
“我会先限定支持类型,再把输入视为对象图。基本类型直接返回;对象先查 WeakMap。首次遇到时先创建空副本并登记,再复制属性或条目,因此循环和共享引用都会落到同一个副本。Date 与 RegExp 单独重建,函数、存取器和未支持类型抛错。期望时间与副本空间为 O(V + E),递归栈为 O(d)。”
分步骤深入解答
JSON.parse(JSON.stringify(value)) 只适合更窄的 JSON 契约。它遇到循环引用会失败,还会改变或丢失 undefined、BigInt、Symbol、Date、RegExp、Map、Set 与特殊数值。普通递归比 JSON 更可控,但如果没有源对象到副本的映射,仍然只适用于树。
实现的核心不变量是:开始遍历任一受支持对象的属性或条目前,seen.get(sourceObject) 已经等于为它分配的唯一副本。
function deepClone(input) {
const seen = new WeakMap();
function clone(value) {
if (typeof value === 'function') {
throw new TypeError('Functions are not supported');
}
if (value === null || typeof value !== 'object') {
return value;
}
if (seen.has(value)) {
return seen.get(value);
}
let result;
if (value instanceof Date) {
result = new Date(value.getTime());
seen.set(value, result);
copyOwnDataProperties(value, result);
return result;
}
if (value instanceof RegExp) {
result = new RegExp(value.source, value.flags);
result.lastIndex = value.lastIndex;
seen.set(value, result);
copyOwnDataProperties(value, result, new Set(['lastIndex']));
return result;
}
if (value instanceof Map) {
result = new Map();
seen.set(value, result);
for (const [key, item] of value) {
result.set(clone(key), clone(item));
}
copyOwnDataProperties(value, result);
return result;
}
if (value instanceof Set) {
result = new Set();
seen.set(value, result);
for (const item of value) {
result.add(clone(item));
}
copyOwnDataProperties(value, result);
return result;
}
if (Array.isArray(value)) {
result = new Array(value.length);
seen.set(value, result);
copyOwnDataProperties(value, result, new Set(['length']));
Object.defineProperty(
result,
'length',
Object.getOwnPropertyDescriptor(value, 'length'),
);
return result;
}
const prototype = Object.getPrototypeOf(value);
if (prototype !== Object.prototype && prototype !== null) {
throw new TypeError('Unsupported object type');
}
result = Object.create(prototype);
seen.set(value, result);
copyOwnDataProperties(value, result);
return result;
}
function copyOwnDataProperties(source, target, skipped = new Set()) {
for (const key of Reflect.ownKeys(source)) {
if (skipped.has(key)) {
continue;
}
const descriptor = Object.getOwnPropertyDescriptor(source, key);
if (!('value' in descriptor)) {
throw new TypeError('Accessor properties are not supported');
}
descriptor.value = clone(descriptor.value);
Object.defineProperty(target, key, descriptor);
}
}
return clone(input);
}seen 必须保存源对象到副本,而不能只保存“访问过”。假设 source.first 和 source.second 都指向 shared:第一次复制 shared 时创建 sharedCopy 并登记;第二次直接返回 sharedCopy,于是共享关系被保留。若 source.self 指回 source,根对象在复制属性前已经登记,因此回边会指向根副本。
这里选择 WeakMap,因为键一定是对象,且无需枚举记录。单次调用内使用普通 Map 也能保证正确性,并不会因为函数返回后自动永久泄漏;WeakMap 的关键价值是符合对象键映射与不可枚举的用途,不能把“防内存泄漏”当成循环正确性的证明。
Reflect.ownKeys 同时返回字符串键和 Symbol 键,也包含不可枚举属性。代码先读取描述符,不会主动调用 getter;遇到存取器便按契约失败。数据属性的 value 递归复制后,再用 Object.defineProperty 恢复 writable、enumerable 和 configurable。数组的 length 是特殊的不可配置属性,所以先复制其他键,最后单独恢复它的描述符,空位不会被无意变成值为 undefined 的元素。
Date、RegExp、Map 与 Set 都有普通属性复制无法触及的内部状态。实现分别重建时间值、正则 source/flags/lastIndex、映射键值与集合元素,并在遍历前登记容器,所以 Map 或 Set 自身参与循环时同样可用。代码假设这些对象来自同一 realm;跨 realm 的 instanceof 判断不可靠,应改用平台克隆或更严格的品牌检查。
这个实现仍有明确边界:不复制对象的 frozen、sealed 或不可扩展状态,不复制原型链本身,不支持存取器、私有字段、Proxy、缓冲区和未列出的内置对象。structuredClone 支持更多平台类型、循环和共享身份,但也不复制函数、属性描述符、getter/setter 或原型链,并且不会保留 RegExp.lastIndex。两者是不同契约,不能仅凭都叫“深拷贝”互换。
令 V 为不同对象数量,E 为自有属性、Map 条目和 Set 元素形成的引用数量。在内置映射操作符合常见平均性能假设时,总遍历工作与副本空间都是 O(V + E);每个源对象只展开一次。递归调用栈为 O(d),其中 d 是最长嵌套路径。ECMAScript 只要求 Map、Set 和 WeakMap 的平均访问时间优于线性,不保证所有实现都具有严格 O(1)。
验证应覆盖对象图,而非只比较序列化结果:自环;两个属性共享同一子对象;Map 的对象键同时出现在其他属性中;Set 引用共享对象;稀疏数组;null 原型对象;不可枚举与 Symbol 数据属性;Date;带 lastIndex 的 RegExp;函数、存取器和自定义类的失败;修改副本后源对象不变。非常深的无环链还应验证递归边界。
高质量示范回答
“我先把范围定成同一 realm 内的有限对象图,支持基本类型、Array、普通 Object、Date、RegExp、Map 和 Set。函数、存取器、弱集合、缓冲区和自定义类直接报错,因为这些类型没有在题目里定义可验证的复制语义。
关键不是递归本身,而是保持对象身份关系。我会维护 WeakMap<source, copy>。每次遇到对象先查表;首次出现时先分配空副本并写表,再复制它的属性或条目。这样自环会回到当前副本,两条指向同一源对象的边也会得到同一个目标副本。Map 的键和值、Set 的元素都走同一 clone 路径,所以跨容器的共享关系不会丢。
普通对象属性用 Reflect.ownKeys 和描述符处理,既保留 Symbol、不可枚举属性与数据描述符,也避免偷偷执行 getter。Date、RegExp、Map 和 Set 单独重建内部状态。按不同对象和引用边计数,期望工作量和副本空间是 O(V + E),调用栈是 O(d)。生产环境如果 structuredClone 的支持矩阵符合需求,我会直接用平台实现,并明确它不保留描述符、原型链和 RegExp 的 lastIndex。”
常见错误
- 直接 JSON 序列化再解析 → 循环会报错,多种合法 JavaScript 值会丢失或变形,且共享引用会被拆开 → 先定义只允许 JSON 数据的窄契约,否则使用对象图算法或
structuredClone。 - 只用递归复制数组和对象 → 自环无限递归,重复引用被复制成多个对象 → 维护源对象到唯一副本的映射。
- 先复制子属性,再写入
seen→ 第一个回边到来时映射仍不存在 → 创建空副本后立即登记,再展开出边。 - 用
WeakSet记录访问过的对象 → 能发现重复,却不知道该返回哪个副本 → 使用WeakMap<source, copy>。 - 用
for...in或Object.entries复制全部属性 → 前者会混入继承的可枚举属性,后者会漏掉 Symbol 和不可枚举属性 → 按契约使用Reflect.ownKeys与自有描述符。 - 直接读取
source[key]→ getter 可能执行副作用或抛错,复制过程改变了输入的可观察行为 → 读取属性描述符,并对存取器采用明确策略。 - 把所有对象都写成
Object.create(proto)→ Date、Map、Set 等内部槽位为空,方法调用会失败;自定义类的私有字段也不会出现 → 为支持的内置类型专门重建,其他类型明确拒绝。 - 宣称严格
O(V + E)→ 语言规范没有承诺 Map、Set、WeakMap 严格常数时间,递归也可能栈溢出 → 说明平均性能假设和O(d)调用栈边界。
追问及应对
追问一:为什么重复引用也必须保留,而不只是防止循环?
对象身份可能承载业务语义。若 order.customer === cache.currentCustomer,分别复制会让副本中的严格相等关系变成 false;修改其中一条路径也不会再被另一条路径观察到。源到副本的一一映射同时解决循环和别名保持。测试时应断言 copy.first === copy.second,只断言“没有栈溢出”不足以证明对象图正确。
追问二:输入可能有十万层嵌套,怎样避免调用栈溢出?
保留相同的 seen 不变量,但把递归改成显式工作栈。首次遇到对象时分配并登记副本,再把“源容器、目标容器、待处理键或条目”作为帧压栈;循环逐帧处理。时间和堆空间仍按 V + E 增长,辅助结构从语言调用栈转移到可控的堆栈。Date 与 RegExp 可直接完成,Object、Array、Map、Set 才需要后续帧。
追问三:如果加入 ArrayBuffer,并允许选择复制或转移怎么办?
API 必须增加显式选项。复制需要创建等长缓冲区并复制字节;转移会让源缓冲区失效,属于带副作用的所有权移动,不能偷偷放进 deepClone。平台 structuredClone(value, { transfer: [...] }) 已定义这套语义。自定义实现若不能真正分离底层存储,应拒绝 transfer,而不是返回共享同一 buffer 的视图并称为深拷贝。
追问四:如何支持自定义类、getter 和私有字段?
通用反射无法读取私有字段,也无法复制闭包。直接保留 getter/setter 会共享函数与闭包,执行 getter 又可能产生副作用。可行方案是把契约改成注册式序列化:每个类提供 serialize 与 deserialize,明确重建不变量;存取器由类型适配器决定保留、求值还是拒绝。没有适配器时继续抛错,比产出一个 instanceof 为 true 但内部状态损坏的对象更安全。