本文 AI 產出,尚未審核

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**

sliceconcatArray.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']**,未受影響

此方法簡單快速,但無法處理 DateRegExpMapSetundefinedfunction 等特殊型別,且會拋棄循環參考。

範例 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 功能完整、支援 DateRegExpMapSet 等,但會增加套件大小,需視專案需求斟酌。


常見陷阱與最佳實踐

陷阱 說明 建議的解決方式
誤以為 Object.assign 為深拷貝 只拷貝第一層,子物件仍共享。 需要深拷貝時,使用 structuredCloneJSON 方法或自訂遞迴。
JSON.stringify 失去資料型別 Date 會變成字串、undefined 會被省略、函式會丟失。 若資料中包含上述型別,改用 structuredClone 或手寫遞迴。
循環參考導致錯誤 JSON.parse(JSON.stringify(...)) 會拋出 TypeError 使用 structuredClone(支援循環)或自行實作 WeakMap 版 deepClone。
大資料結構的效能問題 深拷貝會遍歷全部屬性,成本不菲。 僅在必要時才深拷貝,或採用 不可變資料結構(如 Immutable.js)減少拷貝頻率。
拷貝函式 函式本身無法被深拷貝,只會保留同一個引用。 若需要「複製」行為,通常不需要拷貝函式,只要保持同一個函式即可。

最佳實踐

  1. 先判斷是否真的需要深拷貝:如果只會讀取或只修改最外層屬性,淺拷貝已足夠。
  2. 使用原生 structuredClone:目前瀏覽器與 Node.js 均已支援,安全且效能佳。
  3. 避免在大型迴圈裡頻繁深拷貝:可考慮 immutable patterns變更追蹤(如 Redux Toolkit 的 createSlice)。
  4. 對於跨執行環境(如 Web Worker)傳遞資料structuredClone 能直接在不同執行緒間傳遞。
  5. 測試:加入單元測試,確保拷貝後的物件不會因意外共享而改變原始資料。

實際應用場景

  1. 表單編輯與取消

    • 使用 深拷貝 把原始資料保存為快照,使用者編輯時改動的是副本,若點擊「取消」直接丟棄副本即可。
  2. Redux 或其他狀態管理

    • 為了保持 不可變 的狀態樹, reducer 必須回傳 新物件。在更新巢狀結構時,常會使用 淺拷貝 + 手動深層更新(如 ...state, user: { ...state.user, name: newName })。
  3. 資料快取與重用

    • 從 API 取得的資料若要在多個元件共享,應先 深拷貝 再交給各自的元件,避免其中一個元件的變更影響其他元件。
  4. Web Worker 傳訊

    • postMessage 內部會自動執行 結構化克隆(structured clone),因此傳遞的物件不會與主執行緒共享。了解這點可避免不必要的手動拷貝。
  5. 測試資料生成

    • 單元測試常需要 純淨的測試樣本。使用 cloneDeep 為每個測試案例產生獨立的資料,確保測試間不會互相污染。

總結

  • 淺拷貝 只複製第一層屬性,子物件仍共享參考;常見方法有 Object.assign、展開運算子 ...Array.prototype.slice 等。
  • 深拷貝 會遞迴複製所有層級,確保新舊物件互不影響;可使用 JSON.parse(JSON.stringify(...))structuredClone、手寫遞迴或第三方函式庫(如 Lodash cloneDeep)。
  • 在實務開發中,先判斷是否真的需要深拷貝,盡量利用原生 structuredClone 以取得最佳效能與相容性。
  • 注意循環參考、特殊型別以及效能成本,搭配單元測試與不可變資料模式,可大幅降低因意外共享導致的 bug。

掌握了深拷貝與淺拷貝的差異與正確用法,你就能在 JavaScript 專案中更安全地操作資料結構,寫出更健全、更易維護的程式碼。祝開發順利!