2022 年 10 月 1 日

物件參考和複製

物件與基本型態之間的其中一個基本差異,在於物件是「透過參考」儲存和複製,而基本型態值:字串、數字、布林值等,則總是「以整體值」複製。

如果我們深入了解複製值時發生的情況,這一點很容易理解。

讓我們從基本型態開始,例如字串。

在這裡,我們將 message 的副本放入 phrase

let message = "Hello!";
let phrase = message;

結果,我們有兩個獨立的變數,每個變數都儲存著字串 "Hello!"

相當明顯的結果,對吧?

物件並非如此。

指定給物件的變數,儲存的並非物件本身,而是其「記憶體位址」—換句話說,就是對它的「參考」。

讓我們來看一個這樣的變數範例

let user = {
  name: "John"
};

而這是它實際儲存在記憶體中的方式

物件儲存在記憶體中的某個地方(在圖片的右方),而 user 變數(在左方)有一個對它的「參考」。

我們可以將物件變數,例如 user,想像成一張寫有物件地址的紙。

當我們對物件執行動作,例如取得屬性 user.name,JavaScript 引擎會查看那個地址處的內容,並對實際物件執行操作。

現在來說明為什麼這很重要。

當複製一個物件變數時,會複製參考,但物件本身不會被複製。

例如

let user = { name: "John" };

let admin = user; // copy the reference

現在我們有兩個變數,每個變數都儲存對同一個物件的參考

如你所見,仍然只有一個物件,但現在有兩個變數參考它。

我們可以使用任一個變數來存取物件並修改其內容

let user = { name: 'John' };

let admin = user;

admin.name = 'Pete'; // changed by the "admin" reference

alert(user.name); // 'Pete', changes are seen from the "user" reference

這就像我們有一個有兩個鑰匙的櫃子,並使用其中一個(admin)進入並進行變更。然後,如果我們稍後使用另一個鑰匙(user),我們仍然會開啟同一個櫃子,並且可以存取已變更的內容。

透過參考進行比較

兩個物件只有在它們是同一個物件時才相等。

例如,這裡的 ab 參考同一個物件,因此它們相等

let a = {};
let b = a; // copy the reference

alert( a == b ); // true, both variables reference the same object
alert( a === b ); // true

而這裡的兩個獨立物件不相等,即使它們看起來很像(都是空的)

let a = {};
let b = {}; // two independent objects

alert( a == b ); // false

對於像 obj1 > obj2 這樣的比較,或與基本型別 obj == 5 的比較,物件會轉換成基本型別。我們很快就會研究物件轉換是如何運作的,但老實說,這種比較很少需要用到 - 通常它們是程式設計錯誤的結果。

常數物件可以被修改

將物件儲存為參考的一個重要副作用是,宣告為 const 的物件可以被修改。

例如

const user = {
  name: "John"
};

user.name = "Pete"; // (*)

alert(user.name); // Pete

(*) 這行看起來可能會造成錯誤,但它並不會。user 的值是常數,它必須始終參考同一個物件,但該物件的屬性可以自由變更。

換句話說,只有當我們嘗試將 user=... 設定為一個整體時,const user 才會產生錯誤。

話雖如此,如果我們真的需要建立常數物件屬性,也是可以的,但使用完全不同的方法。我們將在 屬性旗標和描述子 章節中提到這一點。

複製和合併,Object.assign

因此,複製一個物件變數會建立另一個對同一個物件的參考。

但是,如果我們需要複製一個物件怎麼辦?

我們可以建立一個新物件,並透過反覆運算其屬性並在原始層級複製它們,來複製現有物件的結構。

就像這樣

let user = {
  name: "John",
  age: 30
};

let clone = {}; // the new empty object

// let's copy all user properties into it
for (let key in user) {
  clone[key] = user[key];
}

// now clone is a fully independent object with the same content
clone.name = "Pete"; // changed the data in it

alert( user.name ); // still John in the original object

我們也可以使用 Object.assign 方法。

語法為

Object.assign(dest, ...sources)
  • 第一個參數 dest 是目標物件。
  • 後續參數是來源物件清單。

它會將所有來源物件的屬性複製到目標 dest 中,然後回傳它作為結果。

例如,我們有一個 user 物件,讓我們新增幾個權限給它

let user = { name: "John" };

let permissions1 = { canView: true };
let permissions2 = { canEdit: true };

// copies all properties from permissions1 and permissions2 into user
Object.assign(user, permissions1, permissions2);

// now user = { name: "John", canView: true, canEdit: true }
alert(user.name); // John
alert(user.canView); // true
alert(user.canEdit); // true

如果已複製的屬性名稱已存在,它會被覆寫

let user = { name: "John" };

Object.assign(user, { name: "Pete" });

alert(user.name); // now user = { name: "Pete" }

我們也可以使用 Object.assign 來執行簡單的物件複製

let user = {
  name: "John",
  age: 30
};

let clone = Object.assign({}, user);

alert(clone.name); // John
alert(clone.age); // 30

這裡它會將 user 的所有屬性複製到空物件中,並回傳它。

還有其他複製物件的方法,例如使用 展開語法 clone = {...user},會在後面的教學中介紹。

巢狀複製

到目前為止,我們假設 user 的所有屬性都是原始值。但屬性可以是其他物件的參考。

就像這樣

let user = {
  name: "John",
  sizes: {
    height: 182,
    width: 50
  }
};

alert( user.sizes.height ); // 182

現在複製 clone.sizes = user.sizes 不夠了,因為 user.sizes 是個物件,而且會透過參考複製,所以 cloneuser 會共用同一個 sizes

let user = {
  name: "John",
  sizes: {
    height: 182,
    width: 50
  }
};

let clone = Object.assign({}, user);

alert( user.sizes === clone.sizes ); // true, same object

// user and clone share sizes
user.sizes.width = 60;    // change a property from one place
alert(clone.sizes.width); // 60, get the result from the other one

要修正這個問題,並讓 userclone 真正成為獨立的物件,我們應該使用複製迴圈來檢查 user[key] 的每個值,如果它是個物件,則複製它的結構。這稱為「深度複製」或「結構化複製」。有一個 structuredClone 方法可以執行深度複製。

structuredClone

呼叫 structuredClone(object) 會複製 object 和所有巢狀屬性。

以下是我們如何在範例中使用它

let user = {
  name: "John",
  sizes: {
    height: 182,
    width: 50
  }
};

let clone = structuredClone(user);

alert( user.sizes === clone.sizes ); // false, different objects

// user and clone are totally unrelated now
user.sizes.width = 60;    // change a property from one place
alert(clone.sizes.width); // 50, not related

structuredClone 方法可以複製大多數資料類型,例如物件、陣列、原始值。

它也支援循環參考,也就是當物件屬性參考物件本身(直接或透過參考鏈)時。

例如

let user = {};
// let's create a circular reference:
// user.me references the user itself
user.me = user;

let clone = structuredClone(user);
alert(clone.me === clone); // true

如你所見,clone.me 參考 clone,而不是 user!所以循環參考也正確地複製了。

儘管如此,還是有 structuredClone 會失敗的情況。

例如,當物件有一個函式屬性時

// error
structuredClone({
  f: function() {}
});

函式屬性不受支援。

要處理這種複雜情況,我們可能需要使用複製方法的組合、撰寫自訂程式碼,或是不重覆造輪子,採用現有的實作,例如 JavaScript 函式庫 lodash 中的 _.cloneDeep(obj)

摘要

物件會透過參考指派和複製。換句話說,變數儲存的不是「物件值」,而是該值的「參考」(記憶體中的位址)。所以複製這樣的變數或將它作為函式參數傳遞,會複製該參考,而不是物件本身。

透過複製參考的所有操作(例如新增/移除屬性)都對同一個物件執行。

要建立「真實的複製」(複製)我們可以使用 Object.assign 來進行所謂的「淺層複製」(巢狀物件會透過參考複製)或「深度複製」函式 structuredClone,或使用自訂複製實作,例如 _.cloneDeep(obj)

教學課程地圖

留言

留言前請先閱讀…
  • 如果你有建議要改進 - 請 提交 GitHub 議題 或發起 Pull Request,而不是留言。
  • 如果你看不懂文章中的內容 - 請說明。
  • 要插入幾行程式碼,請使用 <code> 標籤,要插入多行程式碼 - 請用 <pre> 標籤包起來,要插入超過 10 行程式碼 - 請使用沙盒 (plnkrjsbincodepen…)