JS 的數據類型#
我們知道,在 JavaScript
語言中,有基本數據類型和引用數據類型之分。
基本數據類型的變量名和值都是儲存在棧內存當中的,每次聲明一個基本數據類型的變量時,會在棧內存裡重新開辟一塊空間進行變量名和值的儲存,彼此之間不會產生任何影響。
但是引用類型就不一樣了,引用類型的變量名與值是儲存在棧內存當中的,這點沒錯,但是這個值儲存的是對應堆內存的地址,真正的值是被放在了堆內存當中,棧內存只是存放了變量名與堆內存的地址信息。
上述引用數據類型的值分配方式看著貌似沒啥問題,但是在實際使用中會有奇怪的情況:
const obj = { a: 123, b: 456 }
const obj2 = obj
obj.a = 789
console.log(obj2.a)
猜猜打印出的是什么?
在沒了解相關的知識前,你可能下意識的以為是 123
,但其實真正的答案是 789
,你沒看錯,是 789
,不信可以自己按 F12
到控制台試試。
但是為什麼是 789
呢?為什麼不是 123
?
淺拷貝 與 深拷貝#
原因在於,在我們通過賦值運算符 =
對引用數據類型的變量進行賦值的時候,由於 JavaScript
語言的特性,實際賦值的是 obj
這個變量的地址。(即 { a: 123, b: 456}
這個對象存放在堆內存中的地址)
可以理解為:當你使用 =
的時候,其實只是拷貝了這個對象的一個 “引用”,obj
與 obj2
共用同一個堆內存的地址(存放的地址指向同一塊堆內存),只簡單拷貝了引用地址,而實際仍指向同一塊內存,我們把這種方式叫做賦值。
為了解決上述問題,我們需要將對象進行拷貝,根據拷貝的程度不同,分為淺拷貝和深拷貝兩種方式。
淺拷貝,創建一個新的對象,如果屬性是基本類型,拷貝的就是基本類型的值;如果屬性是內存地址(引用類型),拷貝的就是內存地址,對深層(超過一層)的引用類型依然僅僅拷貝了內存地址。
相對的,我們把能夠使源對象與拷貝對象互相獨立,其中任何一個對象的改動都不會對另外一個對象造成影響的拷貝方式叫做深拷貝。
淺拷貝#
以下情況屬於淺拷貝(未徹底拷貝完全,新舊對象相互影響)
- 對引用數據類型的變量直接使用賦值運算符
=
進行賦值 - 使用數組原生的
slice
、concat
等方法處理二維及以上數組 - 使用
Object.assign
方法拷貝對象,且對象的屬性值包含引用類型 - 使用擴展運算符
...
拷貝兩層及以上引用類型的數據(對象或數組)
深拷貝#
以下情況屬於深拷貝(徹底拷貝完全,新舊對象互不影響)
- 創建新的對象,並使用遞歸對每一層的基本數據類型重新拷貝
JSON.parse(JSON.stringify(obj))
(不推薦)jquery
、lodash
等自帶的深拷貝函數
使用 JSON.parse(JSON.stringify(obj))
有很多弊端:
- 值為
undefined
、函數、Symbol
的屬性會被忽略 - 值為
NaN
、Infinity
、-Infinity
的屬性會被置為null
Error
、RegExp
、Set
、Map
等內置對象將會轉為空對象Date
對象會被強制轉換為字符串- 原型鏈上的屬性也會被拷貝
實現一個 深拷貝 函數#
為了解決上述的奇怪的表現,我們需要一個函數 deepClone
,來實現以下效果:
const obj = { a: 123, b: 456 }
const obj2 = deepClone(obj)
obj.a = 789
console.log(obj2.a) // 輸出 123 而不是 789
第一版代碼:
function deepClone(obj, useJSON = false) {
// 過濾基本數據類型
if (typeof obj !== 'object' || obj === null) return obj
// JSON 方式的深拷貝實現
if (useJSON) return JSON.parse(JSON.stringify(obj))
let _obj = Array.isArray(obj) ? [] : {}
for (const key in obj) {
// 原型鏈上的屬性不拷貝
if (!obj.hasOwnProperty(key)) return
if (typeof obj[key] === 'object' && obj[key] !== null) {
// 如果屬性值為引用類型,遞歸調用 deepClone 函數進行深拷貝處理
_obj[key] = deepClone(obj[key])
} else {
// 基本數據類型直接使用賦值運算符進行拷貝
_obj[key] = obj[key]
}
}
return _obj
}
測試一下:
const obj = { a: 123, b: { c: 456 } }
const obj2 = deepClone(obj)
obj.b.c = 789
console.log(obj2.b.c) // 輸出 456,測試通過
上述 deepClone
的實現確實能搞定一般情況的深拷貝,但是對於一些屬性值為內置的 JavaScript
對象的對象,情況可能就不這麼樂觀了,看下面的例子:
const obj = {
a: new Date(),
b: { c: new Set([1]), d: new Map([['a', 1]]) },
e: { f: new Error('msg'), g: new RegExp('^obj$') },
h: e => console.log(e)
}
// 這裡我們採用上述的 deepClone 函數
const obj2 = deepClone(obj)
obj.b.c = e => console.log(e)
console.log(obj2)
// 輸出結果如下:
// {
// a: {},
// b: {
// c: {},
// d: {},
// },
// e: { f: {}, g: {} },
// h: [Function: h],
// };
可以直觀的看到,對於 Set
、Map
、Error
、ExpReg
、Date
等內置的 JavaScript
對象而言,這個函數並沒有按照我們預期的方式進行工作,它會將這些內置的 JavaScript
對象當作普通對象來處理,我們再來改進一下這個函數,使他還原 JavaScript
內置對象。
第二版代碼(也是最終版):
function deepClone(obj) {
// 定義獲取類型的函數,Object.prototype.toString 會返回詳細類型描述
function getType(value) {
return Object.prototype.toString.call(value).slice(8, -1)
}
// 過濾基本數據類型和內置對象(Error、RegExp、Date 等)
if (getType(obj) !== 'Object' && getType(obj) !== 'Array') return obj
let _obj = Array.isArray(obj) ? [] : {}
for (const key in obj) {
// 原型鏈上的屬性不拷貝
if (!obj.hasOwnProperty(key)) continue
if (getType(obj) === 'Object' || getType(obj) === 'Array') {
// 如果屬性值為數組或對象,則遞歸調用 deepClone 函數進行深拷貝處理
_obj[key] = deepClone(obj[key])
} else {
// 基本數據類型與內置的 JavaScript 對象直接使用賦值運算符進行拷貝
_obj[key] = obj[key]
}
}
return _obj
}
我們再來測試一下:
const obj = {
a: new Date(),
b: { c: new Set([1]), d: new Map([['a', 1]]) },
e: { f: new Error('msg'), g: new RegExp('^obj$') },
h: e => console.log(e)
}
const obj2 = deepClone(obj)
obj.b.c = e => console.log(e)
console.log(obj2)
// 輸出結果如下:
// {
// a: 2022-03-06T05:11:33.838Z,
// b: { c: Set(1) { 1 }, d: Map(1) { 'a' => 1 } },
// e: {
// f: Error: msg,
// g: /^obj$/
// },
// h: [Function: h]
// }
可以看到,第二版代碼的處理更加科學和準確,它採用 Object.prototype.toString
來判斷類型,並對內置的 JavaScript
對象進行了還原,使它能更加精準的拷貝對象的屬性。
但是這版也只是實現了普通對象的拷貝,針對複雜類型的對象(如 Map、Set 等),還是請使用社區的解決方案,如 lodash、clone 等。
2023/3/24 更新#
實際生產環境下建議使用 rfdc,這是社區的一個純粹的、專門處理深拷貝的、優化性能可觀的 npm 包。