logo-yuki

Yuki

Post

とうこう

什麼是深拷貝、淺拷貝?以 JavaScript 為例。

複製一個物件,改了複製品結果原本的物件也被改——這個坑你也踩過嗎?

9 分鐘
✦values-references-immutability

用「點點點」複製物件,改了一個屬性,原本的物件也莫名的變了,還變得跟複製品一樣⋯!

你踩到大家總是在說的「淺拷貝」的坑了。

拷貝就拷貝,還分深淺啊!

不僅有,還是很重要的概念呢!

先備知識

本篇文章建立在 by reference 的概念上,還不熟的話可以先看 JavaScript 什麼是 by value 跟 by reference?

Q1:為什麼拷貝要分深、淺?

這不是要分不分的問題,而是基於 JS 處理變數的方式,本來就會有的一種自然現象。

延續 by value / by reference 的概念

  • 當我們請 JS 複製一份物件時,就是要創一個新變數,只不過變數內要放得是複製品。
  • JS 理所當然的會去開一個新格子。
  • primitive type data 複製品直接塞入新格子內。
  • reference type data 原本的格子內就是地址,JS 不過就是複製了地址後,放進新格子內罷了。

Q2:這跟我遇到的東西都一起被改掉的問題有什麼關係?

地址被複製,真正的內容物也有被複製嗎? 沒有。

兩個格子存著一樣的地址,指向同一塊記憶體,也就是同一份內容物,這代表不論改哪一邊都改到同樣的東西。人們就稱此現象為淺拷貝

如果想要做到的是兩個地址,兩份一模一樣的內容物,人們就稱之為深拷貝

Q3:今天我想複製東西,直覺當然是要深拷貝呀!JS 有點反人性?

JS 反人性的地方可多了…咳咳、不過這邊其實是電腦科學的慣例,背後有兩個原因

  1. 效能
    物件可能很大很深,每次複製都深拷貝,記憶體跟運算的代價很昂貴。
  2. 其實你不一定想要深拷貝
    有時候共享 reference 是刻意的,深拷貝反而容易出錯。

像是:當 navbar 跟購物車頁面都需要顯示購物車狀態時,更新一次資料、兩邊畫面都同步,才是我們 RD 想要做的(開發上有效率、省事又完美符合需求),這時候兩個地方共享同一個 reference 才是合理的。

js
const cartState = { items: [], total: 0 };

const navbarStates = { cart: cartState };
const cartPageStates = { cart: cartState };

cartState.total = 30;

// 因共享 reference 不費吹灰之力就同步了
console.log(navbarStates.cart.total); // 30
console.log(cartPageStates.cart.total); // 30

PS. 幾乎所有主流語言,只要語言裡有 reference type 這個概念,深淺拷貝的概念也就一定存在。

怎麼做淺拷貝?

假設要拷貝的是以下物件:

js
const user = {
  profile: { name: 'Yuzu' },
  preference: { mode: 'dark', locale: 'zh-TW' },
};

以下三種語法、寫法,都是淺拷貝。

直接手寫

js
const copiedUser = {
  profile: user.profile,
  preference: user.preference,
};

但屬性一多,這樣寫就很耗時、工人智慧感,照理說應該沒人會這樣寫,也不應該這樣寫。

Spread Operator

就是人稱的三個點,它是個語法糖,能做的事情不少,在這裡是把物件的所有屬性展開、複製進一個新物件的意思

js
const copiedUser = { ...user };

Object Assign

這是 JS 在 ES6(ES2015)內建的方法。

js
const copiedUser = Object.assign({}, user);

Object.assign(target, source) 會把 source 的所有屬性複製進 target,並回傳 target。傳一個空物件 {} 當 target,就等於建了一個新格子再把屬性複製進去。

怎麼做深拷貝?

JSON.parse(JSON.stringify())

早期最常用、最會被先想到的應該就是這招:先把物件序列化成字串,再解析回來。字串是 primitive type,因此解析回來的物件會有一個新地址,變成全新的物件,所以是深拷貝。

js
const copiedUser = JSON.parse(JSON.stringify(user));

不過,這招只適合可被序列化為文字的資料,假如 user 今天多了幾個無法被序列化為文字的屬性:

js
const user = {
  profile: {
    name: 'Yuzu',
    nickname: undefined, // undefined
    registeredAt: new Date('2015-04-18'), // Date 物件
    mood: () => 'Hello world!', // function
  },
  preference: { mode: 'dark', locale: 'zh-TW' },
};

那用這招可能會出一些岔子:

js
const copiedUser = JSON.parse(JSON.stringify(user));

console.log(copiedUser.profile.registeredAt); // '2015-04-18T00:00:00.000Z' — Date 物件會變成 ISO string
console.log(copiedUser.profile.mood); // undefined — function 消失
console.log(copiedUser.profile.nickname); // undefined — 就是消失

所以要自己注意一下。

第三方套件

像是:Lodash

js
const _ = require('lodash');
const copiedUser = _.cloneDeep(user);

好用,但就是多了個依賴,且 lodash 早已因為 JS 近年來不斷的進步以及 TS 的興起而退流行,甚至會被視為多餘。

structuredClone()

目前最推薦的方法,JS 在 ES2022 終於內建的。

js
const copiedUser = structuredClone(user);

但還是有限制,function 不能被 clone,硬做的話會報錯。還有,因為是較新的語法,舊版 node.js 或舊瀏覽器有跑不動的風險。

js
structuredClone(user); // Uncaught DataCloneError …

雖然實務上幾乎不會需要複製帶有 function 的物件。

總結

下次再遇到東西一起被改掉的情況,就可以先朝深淺拷貝這方向來 debug 啦!

加映:如何自己手寫實作 deep clone?

看到這裡,是不是有點領悟到了:難怪會一直在面試題庫看到這題呀!

以前想要無痛 deep clone,基本上就得自己手寫實作,考這題還能順便測 candidate 對 reference 和遞迴的理解。

不過都 2025 年了,如果沒有前提直接被問這題,個人認為用 structuredClone() 來作答就可以了。

除非一開始就限制不能用內建方法,或是被追問不使用內建方法怎麼辦?,那才搬出遞迴跟 WeakMap 來解。可以這樣寫:

js
function deepClone(obj, cache = new WeakMap()) {
  if (obj === null || typeof obj !== 'object') return obj;
  if (cache.has(obj)) return cache.get(obj);

  const copy = Array.isArray(obj) ? [] : {};
  cache.set(obj, copy);

  for (const key in obj) {
    if (Object.prototype.hasOwnProperty.call(obj, key)) {
      copy[key] = deepClone(obj[key], cache);
    }
  }
  return copy;
}

為什麼要用 WeakMap?跟 Map 有何不同?

好的,你一定想,啊不是遞迴走訪物件的每一層、複製起來就好了嗎?怎麼突然要 cache,而且用的還不是大家比較熟悉的 Map?

是這樣的,假設我們現在有 comment thread 物件如下:

js
const comment = { text: '好文!' };
const reply = { text: '謝謝!', parent: comment };
comment.replies = [reply];

注意到了嗎?每個留言都可能有 replies,每個 reply 又都要有 parent 指回上層。

所以這是一個有循環引用的物件:comment.replies[0] 指向 replyreply.parent 指回 comment

這時如果要做編輯留言的功能,可能就會需要深拷貝讓 user 修改這個物件,而不影響原本正在顯示的資料,等 user 按下儲存才更新。

沒有做 cache 的單純遞迴,跑這個物件就會變無窮遞迴、stack overflow。

但做了 cache 後呢,萬一留言又多又大串,那就太費記憶體空間啦!該怎麼辦?只好用 不會霸佔記憶體空間的 WeakMap 來做啦(當物件不再被使用時,cache 也會自動被 JS 的垃圾回收機制清理掉,這就是跟 Map 的差異)。

關鍵字:強引用、弱引用。

好了,拆解一下呼叫 deepClone(comment) 會發生什麼事:

  1. deepClone(comment),不在 cache,建 copy,存進 cache。
  2. 走到 comment.replies → 遞迴 deepClone(reply)
  3. deepClone(reply),不在 cache,建 copy,存進 cache。
  4. 走到 reply.parent → 遞迴 deepClone(comment)
  5. comment 已在 cache → 直接回傳,停止遞迴。

延伸閱讀