面試中如何更深入完整地回答 JavaScript 深拷貝這題
目錄
題目描述
算是蠻常出現的經典面試題,有時候會簡單口頭提問「如何用 JavaScript 做拷貝」或「請實作 lodash 的 cloneDeep」,不管哪一種問法都可能會需要了解 JavaScript 中的深淺拷貝的觀念,這邊也簡單整理一下筆記。
淺拷貝 vs 深拷貝
JavaScript 中的資料型別主要分成 primitive value 與 object 兩種,而在複製物件型別的資料時,又會有淺拷貝 (shallow copy) 和深拷貝 (deep copy) 的差異,簡單紀一下兩者差異:
- 淺拷貝:只複製該物件的第一層屬性,而深層物件 (nested object 或 array 等) 仍會參考到原本對應的記憶體位置
- 深拷貝:物件的每層都會被深層複製,具有不同的記憶體位置
淺拷貝的方式
在了解如何實作深拷貝前,先知道哪些常見的方式其實都只是淺拷貝,若資料較單純只有一層可直接使用:
- 展開運算符 (Spread syntax)
- 解構賦值 (Destructuring assignment)
Object.assign
- 部分 Array method 如
slice
、concat
、filter
、map
等等
深拷貝的方式
深拷貝的解法有以下幾種:
JSON.parse(JSON.stringify(obj))
structuredClone(obj)
:最推薦的方式- 自己手寫物件深層遞迴,搭一些型別處理
lodash.cloneDeep(obj)
,背後也是對物件深層遞迴,並加上各種型別處理 (source code),自己要寫一套常會無法考慮到各種型別的邊界條件。但使用套件的缺點會讓 bundle size 變大 (ref)。
JSON.parse(JSON.stringify(obj))
- 能複製可序列化的物件型別
- 無法被序列化或會導致問題的值或物件:
- 會直接被移除的屬性:
undefined
、Function、Symbol、DOM 節點(HTMLElement、document 等)等 - 會被變成空物件:Set、Map、RegExp、Error 等
- 會噴錯的:循環引用物件、BigInt
- Date 物件會被序列化為 ISO 8601 格式的字串(如
2024-12-09T00:00:00.000Z
)。
- 會直接被移除的屬性:
structuredClone(obj)
- 特色
- 能正確處理多種資料格式的複製:
Date
,Set
,Map
,Error
,RegExp
,ArrayBuffer
,Blob
,File
,ImageData
等 (ref) - 可處理循環引用資料 (circular references)
- 能正確處理多種資料格式的複製:
- 支援度
- 仍有一些不支援的型別如 Functions、DOM nodes、Setters、Getters、Object Prototypes 等 (完整類型可參考這篇)
- 從這篇文章的實驗分析看起來,
structuredClone(obj)
的效能會比JSON.parse(JSON.stringify(obj))
略差,但也是很合理的,畢竟多處理了更多類型的物件。
請實作 JavaScript 的物件複製
回到正題,如果面試時被問到「如何處理 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 來確保原本傳入的物件的原型鏈有被保存起來。
再跑一次測試確認通過,並附上最終完整版:
const cloneDeepWithRecursion = <T>(item: T, cache = new WeakMap()): T => {
// handle primitive value and null
if (item === null || typeof item !== 'object') {
return item;
}
// handle circular references
if (cache.has(item)) {
return cache.get(item);
}
// handle special objects such as Date, RegExp, Function
if (item instanceof Date) {
return new Date(item) as T;
}
if (item instanceof RegExp) {
return new RegExp(item) as T;
}
if (typeof item === 'function') {
return item;
}
// handle array and object
const result = (
Array.isArray(item) ? [] : Object.create(Object.getPrototypeOf(item))
) as T;
// set current result into cache to prevent circular reference issue
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];
// recursively run with each value
result[key as keyof T] = cloneDeepWithRecursion(value, cache);
}
// return cloned object
return result;
};