對象與原始類型的根本區別之壹是,對象是“通過引用”存儲和複制的,而原始類型:字符串、數字、布爾值等 —— 總是“作爲壹個整體”複制。
如果我們深入了解複制值時會發生什麽,就很容易理解了。
讓我們從原始類型開始,例如壹個字符串。
這裏我們將 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; // 複制引用
現在我們有了兩個變量,它們保存的都是對同壹個對象的引用:
正如妳所看到的,這裏仍然只有壹個對象,但現在有兩個引用它的變量。
我們可以通過其中任意壹個變量來訪問該對象並修改它的內容:
let user = { name: 'John' }; let admin = user; admin.name = 'Pete'; // 通過 "admin" 引用來修改 alert(user.name); // 'Pete',修改能通過 "user" 引用看到
這就像我們有壹個帶有兩把鑰匙的櫃子,使用其中壹把鑰匙(admin
)打開櫃子並更改了裏面的東西。那麽,如果我們稍後用另壹把鑰匙(user
),我們仍然可以打開同壹個櫃子並且可以訪問更改的內容。
僅當兩個對象爲同壹對象時,兩者才相等。
例如,這裏 a
和 b
兩個變量都引用同壹個對象,所以它們相等:
let a = {}; let b = a; // 複制引用 alert( a == b ); // true,都引用同壹對象 alert( a === b ); // true
而這裏兩個獨立的對象則並不相等,即使它們看起來很像(都爲空):
let a = {}; let b = {}; // 兩個獨立的對象 alert( a == b ); // false
對于類似 obj1 > obj2
的比較,或者跟壹個原始類型值的比較 obj == 5
,對象都會被轉換爲原始值。我們很快就會學到對象是如何轉換的,但是說實話,很少需要進行這樣的比較 —— 通常是在編程錯誤的時候才會出現這種情況。
那麽,拷貝壹個對象變量會又創建壹個對相同對象的引用。
但是,如果我們想要複制壹個對象,那該怎麽做呢?
我們可以創建壹個新對象,通過遍曆已有對象的屬性,並在原始類型值的層面複制它們,以實現對已有對象結構的複制。
就像這樣:
let user = { name: "John", age: 30 }; let clone = {}; // 新的空對象 // 將 user 中所有的屬性拷貝到其中 for (let key in user) { clone[key] = user[key]; } // 現在 clone 是帶有相同內容的完全獨立的對象 clone.name = "Pete"; // 改變了其中的數據 alert( user.name ); // 原來的對象中的 name 屬性依然是 John
我們也可以使用 Object.assign 方法來達成同樣的效果。
語法是:
Object.assign(dest, [src1, src2, src3...])
第壹個參數 dest
是指目標對象。
更後面的參數 src1, ..., srcN
(可按需傳遞多個參數)是源對象。
該方法將所有源對象的屬性拷貝到目標對象 dest
中。換句話說,從第二個開始的所有參數的屬性都被拷貝到第壹個參數的對象中。
調用結果返回 dest
。
例如,我們可以用它來合並多個對象:
let user = { name: "John" }; let permissions1 = { canView: true }; let permissions2 = { canEdit: true }; // 將 permissions1 和 permissions2 中的所有屬性都拷貝到 user 中 Object.assign(user, permissions1, permissions2); // 現在 user = { name: "John", canView: true, canEdit: true }
如果被拷貝的屬性的屬性名已經存在,那麽它會被覆蓋:
let user = { name: "John" }; Object.assign(user, { name: "Pete" }); alert(user.name); // 現在 user = { name: "Pete" }
我們也可以用 Object.assign
代替 for..in
循環來進行簡單克隆:
let user = { name: "John", age: 30 }; let clone = Object.assign({}, user);
它將 user
中的所有屬性拷貝到了壹個空對象中,並返回這個新的對象。
還有其他克隆對象的方法,例如使用 spread 語法 clone = {...user}
,在後面的章節中我們會講到。
到現在爲止,我們都假設 user
的所有屬性均爲原始類型。但屬性可以是對其他對象的引用。
例如:
let user = { name: "John", sizes: { height: 182, width: 50 } }; alert( user.sizes.height ); // 182
現在這樣拷貝 clone.sizes = user.sizes
已經不足夠了,因爲 user.sizes
是個對象,它會以引用形式被拷貝。因此 clone
和 user
會共用壹個 sizes:
let user = { name: "John", sizes: { height: 182, width: 50 } }; let clone = Object.assign({}, user); alert( user.sizes === clone.sizes ); // true,同壹個對象 // user 和 clone 分享同壹個 sizes user.sizes.width++; // 通過其中壹個改變屬性值 alert(clone.sizes.width); // 51,能從另外壹個獲取到變更後的結果
爲了解決這個問題,並讓 user
和 clone
成爲兩個真正獨立的對象,我們應該使用壹個拷貝循環來檢查 user[key]
的每個值,如果它是壹個對象,那麽也複制它的結構。這就是所謂的“深拷貝”。
我們可以使用遞歸來實現它。或者爲了不重複造輪子,采用現有的實現,例如 lodash 庫的 _.cloneDeep(obj)。
使用 const 聲明的對象也是可以被修改的
通過引用對對象進行存儲的壹個重要的副作用是聲明爲 const
的對象 可以 被修改。
例如:
const user = { name: "John" }; user.name = "Pete"; // (*) alert(user.name); // Pete
看起來 (*)
行的代碼會觸發壹個錯誤,但實際並沒有。user
的值是壹個常量,它必須始終引用同壹個對象,但該對象的屬性可以被自由修改。
換句話說,只有當我們嘗試將 user=...
作爲壹個整體進行賦值時,const user
才會報錯。
也就是說,如果我們真的需要創建常量對象屬性,也是可以的,但使用的是完全不同的方法。我們將在 屬性標志和屬性描述符 壹章中學習它。
對象通過引用被賦值和拷貝。換句話說,壹個變量存儲的不是“對象的值”,而是壹個對值的“引用”(內存地址)。因此,拷貝此類變量或將其作爲函數參數傳遞時,所拷貝的是引用,而不是對象本身。
所有通過被拷貝的引用的操作(如添加、刪除屬性)都作用在同壹個對象上。
爲了創建“真正的拷貝”(壹個克隆),我們可以使用 Object.assign
來做所謂的“淺拷貝”(嵌套對象被通過引用進行拷貝)或者使用“深拷貝”函數,例如 _.cloneDeep(obj)。