JavaScript 物件的深拷貝與淺拷貝
簡介
在 JavaScript 中,物件(Object)是最常見的資料結構,幾乎所有程式的狀態都會以物件的形式儲存。當我們需要 複製 一個物件時,若直接把變數指派給另一個變數,兩者其實共享同一塊記憶體,對其中一個的修改會同步影響到另一個。這種情況在開發大型應用程式、處理表單資料或是實作不可變(immutable)資料流時,極易成為 bug 的根源。
為了避免意外的副作用,我們必須了解 淺拷貝(shallow copy) 與 深拷貝(deep copy) 的差別,並選擇適合的複製方式。本文將從概念說明、實作範例、常見陷阱到最佳實踐,一步步帶你掌握物件拷貝的技巧,讓你在日常開發中更得心應手。
核心概念
1. 什麼是淺拷貝?
淺拷貝 只會複製第一層的屬性值,若屬性本身是參考型別(例如另一個物件、陣列、函式),拷貝得到的仍然是指向原始記憶體位置的參考。換句話說,外層物件是全新建立的,但裡面的子物件仍舊共享。
範例 1:使用 Object.assign
const original = { a: 1, b: { c: 2 } };
const shallowCopy = Object.assign({}, original);
// 修改子物件
shallowCopy.b.c = 99;
console.log(original.b.c); // **99**,原物件也被改變
Object.assign只會把 enumerable own properties 複製到目標物件,若屬性是物件或陣列,僅複製其參考。
範例 2:展開運算子(Spread)
const original = { name: 'Alice', scores: [80, 90] };
const shallowCopy = { ...original };
// 改變陣列內容
shallowCopy.scores.push(100);
console.log(original.scores); // **[80, 90, 100]**,同樣被影響
展開運算子在語意上與 Object.assign 等價,都是淺拷貝。
範例 3:陣列的淺拷貝
const arr = [{ id: 1 }, { id: 2 }];
const copy = arr.slice(); // 或 [...arr]
// 改變第一個元素的屬性
copy[0].id = 99;
console.log(arr[0].id); // **99**
slice、concat、Array.from 等方法同樣只做 表層 複製。
2. 什麼是深拷貝?
深拷貝 會遞迴地複製所有層級的屬性,確保新物件與原物件之間沒有任何共享的參考。無論原物件內部有多少層巢狀結構、陣列或函式(函式本身不會被深拷貝,仍為同一個引用),最終得到的都是全新、獨立的資料結構。
範例 4:JSON.parse(JSON.stringify(...))
const original = {
user: { name: 'Bob', address: { city: 'Taipei' } },
tags: ['js', 'web']
};
const deepCopy = JSON.parse(JSON.stringify(original));
// 修改子屬性
deepCopy.user.address.city = 'Kaohsiung';
deepCopy.tags.push('node');
console.log(original.user.address.city); // **'Taipei'**,未受影響
console.log(original.tags); // **['js', 'web']**,未受影響
此方法簡單快速,但無法處理
Date、RegExp、Map、Set、undefined、function等特殊型別,且會拋棄循環參考。
範例 5:原生 structuredClone(ES2021+)
const original = {
date: new Date(),
map: new Map([['key', 'value']]),
nested: { arr: [1, 2, 3] }
};
const deepCopy = structuredClone(original);
deepCopy.date.setFullYear(2000);
deepCopy.map.set('key', 'new');
console.log(original.date.getFullYear()); // **當前年份**,未變
console.log(original.map.get('key')); // **'value'**,未變
structuredClone 能正確處理大多數內建型別,且支援循環參考,是目前最推薦的原生深拷貝方式。
範例 6:自訂遞迴函式(手寫深拷貝)
function deepClone(obj, hash = new WeakMap()) {
// 基本類型直接回傳
if (Object(obj) !== obj) return obj;
// 循環參考檢查
if (hash.has(obj)) return hash.get(obj);
// 處理內建物件
const result = Array.isArray(obj) ? [] : obj.constructor ? new obj.constructor() : {};
// 記錄已遍歷的物件
hash.set(obj, result);
// 逐屬性遞迴拷貝
for (const key in obj) {
if (obj.hasOwnProperty(key)) {
result[key] = deepClone(obj[key], hash);
}
}
return result;
}
// 測試
const original = { a: 1, b: { c: [2, 3] } };
original.self = original; // 循環參考
const copy = deepClone(original);
copy.b.c[0] = 99;
console.log(original.b.c[0]); // **2**,不受影響
console.log(copy.self === copy); // **true**,循環參考仍正確
此實作利用 WeakMap 防止 循環參考 造成的無限遞迴,且能自行決定如何處理特殊型別。
範例 7:使用第三方函式庫(Lodash)
import _ from 'lodash';
const original = { x: [1, 2], y: { z: 3 } };
const deepCopy = _.cloneDeep(original);
deepCopy.y.z = 999;
console.log(original.y.z); // **3**,保持不變
Lodash 的 cloneDeep 功能完整、支援 Date、RegExp、Map、Set 等,但會增加套件大小,需視專案需求斟酌。
常見陷阱與最佳實踐
| 陷阱 | 說明 | 建議的解決方式 |
|---|---|---|
誤以為 Object.assign 為深拷貝 |
只拷貝第一層,子物件仍共享。 | 需要深拷貝時,使用 structuredClone、JSON 方法或自訂遞迴。 |
JSON.stringify 失去資料型別 |
Date 會變成字串、undefined 會被省略、函式會丟失。 |
若資料中包含上述型別,改用 structuredClone 或手寫遞迴。 |
| 循環參考導致錯誤 | JSON.parse(JSON.stringify(...)) 會拋出 TypeError。 |
使用 structuredClone(支援循環)或自行實作 WeakMap 版 deepClone。 |
| 大資料結構的效能問題 | 深拷貝會遍歷全部屬性,成本不菲。 | 僅在必要時才深拷貝,或採用 不可變資料結構(如 Immutable.js)減少拷貝頻率。 |
| 拷貝函式 | 函式本身無法被深拷貝,只會保留同一個引用。 | 若需要「複製」行為,通常不需要拷貝函式,只要保持同一個函式即可。 |
最佳實踐
- 先判斷是否真的需要深拷貝:如果只會讀取或只修改最外層屬性,淺拷貝已足夠。
- 使用原生
structuredClone:目前瀏覽器與 Node.js 均已支援,安全且效能佳。 - 避免在大型迴圈裡頻繁深拷貝:可考慮 immutable patterns 或 變更追蹤(如 Redux Toolkit 的
createSlice)。 - 對於跨執行環境(如 Web Worker)傳遞資料,
structuredClone能直接在不同執行緒間傳遞。 - 測試:加入單元測試,確保拷貝後的物件不會因意外共享而改變原始資料。
實際應用場景
表單編輯與取消
- 使用 深拷貝 把原始資料保存為快照,使用者編輯時改動的是副本,若點擊「取消」直接丟棄副本即可。
Redux 或其他狀態管理
- 為了保持 不可變 的狀態樹, reducer 必須回傳 新物件。在更新巢狀結構時,常會使用 淺拷貝 + 手動深層更新(如
...state, user: { ...state.user, name: newName })。
- 為了保持 不可變 的狀態樹, reducer 必須回傳 新物件。在更新巢狀結構時,常會使用 淺拷貝 + 手動深層更新(如
資料快取與重用
- 從 API 取得的資料若要在多個元件共享,應先 深拷貝 再交給各自的元件,避免其中一個元件的變更影響其他元件。
Web Worker 傳訊
postMessage內部會自動執行 結構化克隆(structured clone),因此傳遞的物件不會與主執行緒共享。了解這點可避免不必要的手動拷貝。
測試資料生成
- 單元測試常需要 純淨的測試樣本。使用
cloneDeep為每個測試案例產生獨立的資料,確保測試間不會互相污染。
- 單元測試常需要 純淨的測試樣本。使用
總結
- 淺拷貝 只複製第一層屬性,子物件仍共享參考;常見方法有
Object.assign、展開運算子...、Array.prototype.slice等。 - 深拷貝 會遞迴複製所有層級,確保新舊物件互不影響;可使用
JSON.parse(JSON.stringify(...))、structuredClone、手寫遞迴或第三方函式庫(如 LodashcloneDeep)。 - 在實務開發中,先判斷是否真的需要深拷貝,盡量利用原生
structuredClone以取得最佳效能與相容性。 - 注意循環參考、特殊型別以及效能成本,搭配單元測試與不可變資料模式,可大幅降低因意外共享導致的 bug。
掌握了深拷貝與淺拷貝的差異與正確用法,你就能在 JavaScript 專案中更安全地操作資料結構,寫出更健全、更易維護的程式碼。祝開發順利!