Hi 大家 2025 新年快樂!
今天是 CodeFarmer 技術週報的第一篇,今年的目標是期待可以往全端的職位走,所以最近會開始研究一些比較基礎的後端、devops 等知識,並補強一些前端基本功,以及前後端系統設計與演算法等等,但因為其他內容還在持續累積中,就先從一個經典的前端面試題開始吧。
程式好讀版也可以到部落格上的《面試中如何更深入完整地回答 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 物件複製」時,參考之前在 ExplainThis 打卡群時建議的框架可以怎麼與對方互動呢?
分析與思路
問題釐清
輸入的物件資料是否為巢狀結構 (是否需要處理深拷貝)?
列舉處理深拷貝可以用的方法,確認是否需要實作手寫版本的 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;
};
以上就是本期技術週報的內容了,對 Substack 還不是很熟悉許多設定還在調整中,若有發現什麼問題歡迎來訊討論,那我們就下週三見了:
Email:codefarmer.tw@gmail.com