用「點點點」複製物件,改了一個屬性,原本的物件也莫名的變了,還變得跟複製品一樣⋯!
你踩到大家總是在說的「淺拷貝」的坑了。
拷貝就拷貝,還分深淺啊!
不僅有,還是很重要的概念呢!
先備知識
本篇文章建立在 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 反人性的地方可多了…咳咳、不過這邊其實是電腦科學的慣例,背後有兩個原因
- 效能
物件可能很大很深,每次複製都深拷貝,記憶體跟運算的代價很昂貴。 - 其實你不一定想要深拷貝
有時候共享 reference 是刻意的,深拷貝反而容易出錯。
像是:當 navbar 跟購物車頁面都需要顯示購物車狀態時,更新一次資料、兩邊畫面都同步,才是我們 RD 想要做的(開發上有效率、省事又完美符合需求),這時候兩個地方共享同一個 reference 才是合理的。
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); // 30PS. 幾乎所有主流語言,只要語言裡有 reference type 這個概念,深淺拷貝的概念也就一定存在。
怎麼做淺拷貝?
假設要拷貝的是以下物件:
const user = {
profile: { name: 'Yuzu' },
preference: { mode: 'dark', locale: 'zh-TW' },
};以下三種語法、寫法,都是淺拷貝。
直接手寫
const copiedUser = {
profile: user.profile,
preference: user.preference,
};但屬性一多,這樣寫就很耗時、工人智慧感,照理說應該沒人會這樣寫,也不應該這樣寫。
Spread Operator
就是人稱的三個點,它是個語法糖,能做的事情不少,在這裡是把物件的所有屬性展開、複製進一個新物件的意思。
const copiedUser = { ...user };Object Assign
這是 JS 在 ES6(ES2015)內建的方法。
const copiedUser = Object.assign({}, user);Object.assign(target, source) 會把 source 的所有屬性複製進 target,並回傳 target。傳一個空物件 {} 當 target,就等於建了一個新格子再把屬性複製進去。
怎麼做深拷貝?
JSON.parse(JSON.stringify())
早期最常用、最會被先想到的應該就是這招:先把物件序列化成字串,再解析回來。字串是 primitive type,因此解析回來的物件會有一個新地址,變成全新的物件,所以是深拷貝。
const copiedUser = JSON.parse(JSON.stringify(user));不過,這招只適合可被序列化為文字的資料,假如 user 今天多了幾個無法被序列化為文字的屬性:
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' },
};那用這招可能會出一些岔子:
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
const _ = require('lodash');
const copiedUser = _.cloneDeep(user);好用,但就是多了個依賴,且 lodash 早已因為 JS 近年來不斷的進步以及 TS 的興起而退流行,甚至會被視為多餘。
structuredClone()
目前最推薦的方法,JS 在 ES2022 終於內建的。
const copiedUser = structuredClone(user);但還是有限制,function 不能被 clone,硬做的話會報錯。還有,因為是較新的語法,舊版 node.js 或舊瀏覽器有跑不動的風險。
structuredClone(user); // Uncaught DataCloneError …雖然實務上幾乎不會需要複製帶有 function 的物件。
總結
下次再遇到東西一起被改掉的情況,就可以先朝深淺拷貝這方向來 debug 啦!
加映:如何自己手寫實作 deep clone?
看到這裡,是不是有點領悟到了:難怪會一直在面試題庫看到這題呀!
以前想要無痛 deep clone,基本上就得自己手寫實作,考這題還能順便測 candidate 對 reference 和遞迴的理解。
不過都 2025 年了,如果沒有前提直接被問這題,個人認為用 structuredClone() 來作答就可以了。
除非一開始就限制不能用內建方法,或是被追問不使用內建方法怎麼辦?,那才搬出遞迴跟 WeakMap 來解。可以這樣寫:
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 物件如下:
const comment = { text: '好文!' };
const reply = { text: '謝謝!', parent: comment };
comment.replies = [reply];注意到了嗎?每個留言都可能有 replies,每個 reply 又都要有 parent 指回上層。
所以這是一個有循環引用的物件:comment.replies[0] 指向 reply,reply.parent 指回 comment。
這時如果要做編輯留言的功能,可能就會需要深拷貝讓 user 修改這個物件,而不影響原本正在顯示的資料,等 user 按下儲存才更新。
沒有做 cache 的單純遞迴,跑這個物件就會變無窮遞迴、stack overflow。
但做了 cache 後呢,萬一留言又多又大串,那就太費記憶體空間啦!該怎麼辦?只好用 不會霸佔記憶體空間的 WeakMap 來做啦(當物件不再被使用時,cache 也會自動被 JS 的垃圾回收機制清理掉,這就是跟 Map 的差異)。
好了,拆解一下呼叫 deepClone(comment) 會發生什麼事:
deepClone(comment),不在 cache,建 copy,存進 cache。- 走到
comment.replies→ 遞迴deepClone(reply)。 deepClone(reply),不在 cache,建 copy,存進 cache。- 走到
reply.parent→ 遞迴deepClone(comment)。 comment已在 cache → 直接回傳,停止遞迴。