題目與適用情境
實作 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 不變量,但把遞迴改成顯式工作堆疊。第一次遇到物件時配置並登記副本,再把「來源容器、目標容器、待處理鍵或項目」當成 frame 壓入堆疊;迴圈逐一處理。時間與堆積空間仍依 V + E 成長,輔助結構從語言呼叫堆疊移到可控的資料堆疊。Date 與 RegExp 可直接完成,Object、Array、Map、Set 才需要後續 frame。
追問三:如果加入 ArrayBuffer,並允許選擇複製或轉移呢?
API 必須增加明確選項。複製需要建立等長緩衝區並複製位元組;轉移會讓來源緩衝區失效,屬於帶副作用的所有權移動,不能暗中放進 deepClone。平台 structuredClone(value, { transfer: [...] }) 已定義這套語意。自訂實作若無法真正分離底層儲存,就應拒絕 transfer,不可回傳共用相同 buffer 的 view 後宣稱是深層複製。
追問四:如何支援自訂類別、getter 與私有欄位?
通用反射無法讀取私有欄位,也無法複製閉包。直接保留 getter/setter 會共用函式與閉包,執行 getter 又可能產生副作用。可行方案是把契約改為註冊式序列化:每個類別提供 serialize 與 deserialize,明確重建不變量;存取器由型別轉接器決定保留、求值或拒絕。沒有轉接器時繼續拋錯,比產生一個 instanceof 為 true 但內部狀態損壞的物件更安全。