算是蠻常出現的經典面試題,有時候會簡單口頭提問「如何用 JavaScript 做拷貝」或「請實作 lodash 的 cloneDeep」,不管哪一種問法都可能會需要了解 JavaScript 中的深淺拷貝的觀念,這邊也簡單整理一下筆記。
JavaScript 中的資料型別主要分成 primitive value 與 object 兩種,而在複製物件型別的資料時,又會有淺拷貝 (shallow copy) 和深拷貝 (deep copy) 的差異,簡單紀一下兩者差異:
淺拷貝:只複製該物件的第一層屬性,而深層物件 (nested object 或 array 等) 仍會參考到原本對應的記憶體位置 深拷貝:物件的每層都會被深層複製,具有不同的記憶體位置 在了解如何實作深拷貝前,先知道哪些常見的方式其實都只是淺拷貝,若資料較單純只有一層可直接使用:
深拷貝的解法有以下幾種:
JSON.parse(JSON.stringify(obj))
structuredClone(obj)
:最推薦的方式自己手寫物件深層遞迴,搭一些型別處理 lodash.cloneDeep(obj)
,背後也是對物件深層遞迴,並加上各種型別處理 (source code ),自己要寫一套常會無法考慮到各種型別的邊界條件。但使用套件的缺點會讓 bundle size 變大 (ref )。能複製可序列化的物件型別 無法被序列化或會導致問題的值或物件:會直接被移除的屬性: undefined
、Function、Symbol、DOM 節點(HTMLElement、document 等)等 會被變成空物件:Set、Map、RegExp、Error 等 會噴錯的:循環引用物件、BigInt Date 物件會被序列化為 ISO 8601 格式的字串(如 2024-12-09T00:00:00.000Z
)。 特色能正確處理多種資料格式的複製:Date
, Set
, Map
, Error
, RegExp
, ArrayBuffer
, Blob
, File
, ImageData
等 (ref) 可處理循環引用資料 (circular references) 支援度2022 年支援的 Web API (MDN ) 瀏覽器支援度目前較新版的主流瀏覽器都已支援 (ref ) 若需要支援低版本的瀏覽器(如 Safari 15.3 以下)需用 polyfill 仍有一些不支援的型別如 Functions、DOM nodes、Setters、Getters、Object Prototypes 等 (完整類型可參考這篇 ) 從這篇文章 的實驗分析看起來, structuredClone(obj)
的效能會比 JSON.parse(JSON.stringify(obj))
略差,但也是很合理的,畢竟多處理了更多類型的物件。 回到正題,如果面試時被問到「如何處理 JavaScript 物件複製」時,用前面的框架可以怎麼與對方互動呢?
輸入的物件資料是否為巢狀結構 (是否需要處理深拷貝)? 列舉處理深拷貝可以用的方法,確認是否需要實作手寫版本的 cloneDeep 或只要簡單用 structuredClone 就滿足期待? 輸入的物件資料是否包含不可序列化的資料型別 (如 undefined
, Function
, Symbol
等等) 輸入的物件資料是否可能會有循環引用 是否需要保留原物件的原型鏈 (prototype chain) 檢查複製出來的的每個屬性值是否相等 (deep equal) 以及位址是否不同單純的一層物件 巢狀的物件 (但不含特殊型別) 巢狀的物件 (含特殊型別) 包含 undefined
, Function
, Symbol
等等不可序列化的資料型別 循環引用物件不會噴錯 如果需要手寫版本的 cloneDeep 的話,主要的實作邏輯會需要依照各種傳入的資料型別回傳不同的值:
如果為基本型別則回傳當前的值 處理特殊型別判斷,像是 Date, RegExp, Function 若為物件與陣列對物件的 key 跑 for loop,並讓每個值做遞迴 用註解簡單列出以上的實作思路:
const cloneDeepWithRecursion = < T >( item : T ) => {
// handle primitive value and null, return the current value
// handle special objects such as Date, RegExp, Function
// handle array and object
// run a for loop with input object
// recursively run with each value
// return cloned object
};
把上面用註解寫的依序完成會像下面這個樣子,可以看到大方向就是依照各種型別處理並回傳值,遇到物件與陣列時做遞迴:
const cloneDeepWithRecursion = < T >( item : T ) => {
// handle primitive value and null
if ( item === null || typeof item !== ' object ' ) {
return item ;
}
// handle special objects such as Date, RegExp, Function
if ( item instanceof Date ) {
return new Date ( item );
}
if ( item instanceof RegExp ) {
return new RegExp ( item );
}
if ( typeof item === ' function ' ) {
return item ;
}
// handle array and object
const result = ( Array . isArray ( item ) ? [] : {}) as T ;
// run a for loop with input object
for ( const key of Object . keys ( item )) {
const value = item [ key ];
// recursively run with each value
result [ key ] = cloneDeepWithRecursion ( value );
}
// return cloned object
return result ;
};
這裡在實作時因為是使用 TypeScript 的關係遇到一些型別的問題,在 value = item[key]
這段會有以下錯誤提示:
Element implicitly has an 'any' type because expression of type 'string' can't be used to index type '{}'.
No index signature with a parameter of type 'string' was found on type '{}'.
這個錯誤的原因,研究後發現是因為當今天 item 含有更廣義 object 資料時,這個 key 可能不一定是 string
,因此在 TS 的型別推斷後覺得 item[key]
可能是不合法的操作。
而要解決這個錯誤可以用型別斷言:
// run a for loop with input object
for ( const key of Object . keys ( item )) {
const value = item [ key as keyof typeof item ];
// recursively run with each value
result [ key as keyof T ] = cloneDeepWithRecursion ( value );
}
先測試一下幾個測試案例:
import { describe , expect , it } from ' vitest ' ;
import { cloneDeepWithRecursion as cloneDeep } from ' ./cloneDeep ' ;
describe ( ' cloneDeep ' , () => {
it ( ' should deeply clone a simple object ' , () => {
const obj = {
id : ' codefarmer.tw ' ,
payload : {
name : ' Code Farmer ' ,
age : 18 ,
books : [ ' JavaScript ' , ' TypeScript ' ],
},
};
const newObj = cloneDeep ( obj );
expect ( newObj ). not . toBe ( obj );
expect ( newObj ). toEqual ( obj );
});
it ( ' should deeply clone a object with comprehensive properties ' , () => {
const obj = {
string : ' string ' ,
number : 123 ,
bool : false ,
date : new Date (),
infinity : Infinity ,
regexp : / abc / ,
nullValue : null ,
undefinedValue : undefined ,
nanValue : NaN ,
};
const newObj = cloneDeep ( obj );
expect ( newObj ). not . toBe ( obj );
expect ( newObj ). toEqual ( obj );
});
it ( ' should deeply clone a object with function ' , () => {
const obj = {
fn : function () {
return ' fn ' ;
},
};
const newObj = cloneDeep ( obj );
expect ( newObj ). not . toBe ( obj );
expect ( newObj ). toEqual ( obj );
});
it ( ' should deeply clone a object with Symbol ' , () => {
const obj = {
symbol : Symbol ( ' symbol ' ),
};
const newObj = cloneDeep ( obj );
expect ( newObj ). not . toBe ( obj );
expect ( newObj ). toEqual ( obj );
});
it ( ' should deeply clone a object with circular reference ' , () => {
const obj : any = { a : 1 };
obj . b = obj ;
const newObj = cloneDeep ( obj );
expect ( newObj ). not . toBe ( obj );
expect ( newObj ). toEqual ( obj );
});
});
跑完測試後會發現只有最後一個循環引用還沒辦法通過,接下來會繼續處理這部分。
要做循環引用的處理,要稍微改寫一個函式的結構,這裡多傳一個 WeakMap 來做紀錄:
const cloneDeepWithRecursion = < T >( item : T , cache = new WeakMap ()) : T => {
// handle primitive value and null
if ( item === null || typeof item !== ' object ' ) {
return item ;
}
// 如果 cache 中已經有處理過這個物件則略過
if ( cache . has ( item )) {
return cache . get ( item );
}
// ... 略 ...
// 將目前處理的物件記錄到快取中
cache . set ( item , result );
// run a for loop with input object
for ( const key of Object . keys ( item )) {
const value = item [ key as keyof typeof item ];
// 記得將目前 cache 傳入遞迴呼叫中,避免循環引用問題
result [ key as keyof T ] = cloneDeepWithRecursion ( value , cache );
}
return result ;
};
再進階一些,如果在前面的問題中,被追問需要保留物件的原型鏈,該怎麼處理?
先快速補上一個單元測試確認目前的版本確實有原型鏈問題:
it ( ' should deeply clone an object with prototype chain ' , () => {
// Define a custom prototype
function CustomType ( this : any ) {
this . name = ' CustomType ' ;
}
CustomType .prototype. greet = function () {
return `Hello, ${ this . name } ` ;
};
const obj = new ( CustomType as any )();
obj . age = 18 ;
const newObj = cloneDeep ( obj );
// Ensure the new object is not the same as the original
expect ( newObj ). not . toBe ( obj );
expect ( newObj ). toEqual ( obj );
// Ensure the prototype chain is preserved
expect ( Object . getPrototypeOf ( newObj )). toBe ( CustomType .prototype);
expect ( newObj . greet ()). toBe ( ' Hello, CustomType ' );
});
這裡把在建立物件的地方從 {}
改成 Object.create(Object.getPrototypeOf(item))
就可以完成此需求了:
// handle array and object
const result = (
Array . isArray ( item ) ? [] : Object . create ( Object . getPrototypeOf ( item ))
) as T ;
主要就是使用這兩個 method 來確保原本傳入的物件的原型鏈有被保存起來。