本文要解決的問題:
- 為什么會有深拷貝(deep clone)和淺拷貝(shallow clone)的存在
- 理解 JavaScript 中深拷貝和淺拷貝的區(qū)別
- JavaScript 拷貝對象的注意事項
- JavaScript 拷貝對象和數(shù)組的實現(xiàn)方法
部分代碼可在這里找到:Github阅茶。如果發(fā)現(xiàn)錯誤掏觉,歡迎指出。
一腕侄, 理解問題原因所在
JavaScript 中的數(shù)據(jù)類型可以分為兩種:基本類型值(Number, Boolean, String, NULL, Undefined)和引用類型值(Array, Object, Date, RegExp, Function)按灶。 基本類型值指的是簡單的數(shù)據(jù)段寿桨,而引用類型值指那些可能由多個值構(gòu)成的對象汉柒。
基本數(shù)據(jù)類型是按值訪問的,因為可以直接操作保存在變量中的實際的值春叫。引用類型的值是保存在內(nèi)存中的對象肩钠,與其他語言不同,JavaScript 不允許直接訪問內(nèi)存中的位置暂殖,也就是說不能直接操作對象的內(nèi)存空間价匠。在操作對象時,實際上是在操作對象的引用而不是實際的對象央星。 為此霞怀,引用類型的值是按引用訪問的。
除了保存的方式不同之外莉给,在從一個變量向另一個變量復(fù)制基本類型值和引用類型值時毙石,也存在不同:
- 如果從一個變量向另一個變量復(fù)制基本類型的值廉沮,會在變量對象上創(chuàng)建一個新值,然后把該值復(fù)制到為新變量分配的位置上徐矩。
- 當(dāng)從一個變量向另一個變量復(fù)制引用類型的值時滞时,同樣也會將存儲在變量對象中的值復(fù)制一份放到為新變量分配的空間中。不同的是滤灯,這個值的副本實際上是一個指針坪稽,而這個指針指向存儲在堆中的一個對象。復(fù)制操作結(jié)束后鳞骤,兩個變量實際上將引用同一個對象窒百。因此,改變其中一個變量豫尽,就會影響另一個變量篙梢。
看下面的代碼:
// 基本類型值復(fù)制
var string1 = 'base type';
var string2 = string1;
// 引用類型值復(fù)制
var object1 = {a: 1};
var object2 = object1;
下圖可以表示兩種類型的變量的復(fù)制結(jié)果:
<figure style="display: block; margin: 2.7rem auto; text-align: center; color: rgb(51, 51, 51); font-family: -apple-system, "PingFang SC", "Hiragino Sans GB", Arial, "Microsoft YaHei", "Helvetica Neue", sans-serif; font-size: 16px; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: normal; letter-spacing: normal; orphans: 2; text-indent: 0px; text-transform: none; white-space: normal; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; background-color: rgb(255, 255, 255); text-decoration-style: initial; text-decoration-color: initial;"><figcaption style="display: block; text-align: center; font-size: 1rem; line-height: 2.7rem; color: rgb(144, 144, 144);"></figcaption>
</figure>
至此,我們應(yīng)該理解:在 JavaScript 中直接復(fù)制對象實際上是對引用的復(fù)制美旧,會導(dǎo)致兩個變量引用同一個對象渤滞,對任一變量的修改都會反映到另一個變量上,這是一切問題的原因所在榴嗅。
二妄呕, 深拷貝和淺拷貝的區(qū)別
理解了 JavaScript 中拷貝對象的問題后,我們就可以講講深拷貝和淺拷貝的區(qū)別了嗽测⌒骼考慮這種情況,你需要復(fù)制一個對象论咏,這個對象的某個屬性還是一個對象优炬,比如這樣:
var object1 = {
a: 1,
obj: {
b: 'string'
}
}
淺拷貝
淺拷貝
存在兩種情況:
-
直接拷貝對象,也就是拷貝引用厅贪,兩個變量
object1
和
object2
之間還是會相互影響。
只是簡單的拷貝對象的第一層屬性雅宾,基本類型值不再相互影響养涮,但是對其內(nèi)部的引用類型值,拷貝的任然是是其引用眉抬,內(nèi)部的引用類型值還是會相互影響贯吓。
// 最簡單的淺拷貝
var object2 = object1;
// 拷貝第一層屬性
function shallowClone(source) {
if (!source || typeof source !== 'object') {
return;
}
var targetObj = source.constructor === Array ? [] : {};
for (var keys in source) {
if (source.hasOwnProperty(keys)) {
// 簡單的拷貝屬性
targetObj[keys] = source[keys];
}
}
return targetObj;
}
var object3 = shallowClone(object1);
// 改變原對象的屬性
object1.a = 2;
object1.obj.b = 'newString';
// 比較
console.log(object2.a); // 2
console.log(object2.obj.b); // 'newString'
console.log(object3.a); // 1
console.log(object3.obj.b); // 'newString'
淺拷貝存在許多問題,需要我們注意:
- 只能拷貝可枚舉的屬性蜀变。
- 所生成的
拷貝對象
的原型與原對象
的原型不同悄谐,拷貝對象只是 Object 的一個實例。 - 原對象從它的原型繼承的屬性也會被拷貝到新對象中库北,就像是原對象的屬性一樣爬舰,無法區(qū)分。
- 屬性的描述符(descriptor)無法被復(fù)制,一個只讀的屬性在拷貝對象中可能會是可寫的迟几。
- 如果屬性是對象的話酿箭,原對象的屬性會與拷貝對象的屬性會指向一個對象,會彼此影響垃你。
不能理解這些概念椅文?可以看看下面的代碼:
function Parent() {
this.name = 'parent';
this.a = 1;
}
function Child() {
this.name = 'child';
this.b = 2;
}
Child.prototype = new Parent();
var child1 = new Child();
// 更改 child1 的 name 屬性的描述符
Object.defineProperty(child1, 'name', {
writable: false,
value: 'Mike'
});
// 拷貝對象
var child2 = shallowClone(child1);
// Object {value: "Nicholas", writable: false, enumerable: true, configurable: true}
console.log(Object.getOwnPropertyDescriptor(child1, 'name'));
// 這里新對象的 name 屬性的描述符已經(jīng)發(fā)生了變化
// Object {value: "Nicholas", writable: true, enumerable: true, configurable: true}
console.log(Object.getOwnPropertyDescriptor(child2, 'name'));
child1.name = 'newName'; // 嚴(yán)格模式下報錯
child2.name = 'newName'; // 可以賦值
console.log(child1.name); // Mike
console.log(child2.name); // newName
上面的代碼通過構(gòu)造函數(shù)
Child
構(gòu)造一個對象
child1
,這個對象的原型是
Parent
惜颇。并且修改了
child1
的
name
屬性的描述符皆刺,設(shè)置
writable
為
false
,也就是這個屬性不能再被修改凌摄。如果要直接給
child1.name
賦值芹橡,在嚴(yán)格模式下會報錯,在非嚴(yán)格模式則會賦值失斖住(但不會報錯)林说。
我們調(diào)用前面提到的淺拷貝函數(shù)
shallowClone
來拷貝
child1
對象,生成了新的對象
child2
屯伞,輸出
child2
的
name
屬性的描述符腿箩,我們可以發(fā)現(xiàn)
child2
的
name
屬性的描述符與
child1
已經(jīng)不一樣了(變成了可寫的)。在 VSCode 中開啟調(diào)試模式劣摇,查看
child1
和
child2
的原型珠移,我們也會發(fā)現(xiàn)它們的原型也是不同的:
<figure style="display: block; margin: 2.7rem auto; text-align: center; color: rgb(51, 51, 51); font-family: -apple-system, "PingFang SC", "Hiragino Sans GB", Arial, "Microsoft YaHei", "Helvetica Neue", sans-serif; font-size: 16px; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: normal; letter-spacing: normal; orphans: 2; text-indent: 0px; text-transform: none; white-space: normal; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; background-color: rgb(255, 255, 255); text-decoration-style: initial; text-decoration-color: initial;"><figcaption style="display: block; text-align: center; font-size: 1rem; line-height: 2.7rem; color: rgb(144, 144, 144);"></figcaption>
</figure>
child1
的原型是
Parent
,而
child2
的原型則是
Object
末融。
通過上面的例子和簡短的說明钧惧,我們可以大致理解淺拷貝存在的一些問題,在實際使用過程中也能有自己的判斷勾习。
深拷貝
深拷貝
就是將對象的屬性遞歸的拷貝到一個新的對象上浓瞪,兩個對象有不同的地址,不同的引用巧婶,也包括對象里的對象屬性(如 object1 中的 obj 屬性)乾颁,兩個變量之間完全獨立。
沒有銀彈 - 根據(jù)實際需求
既然淺拷貝有那么多問題艺栈,我們?yōu)槭裁催€要說淺拷貝英岭?一來是深拷貝的完美實現(xiàn)不那么容易(甚至不存在),而且可能存在性能問題湿右,二來是有些時候的確不需要深拷貝诅妹,那么我們也就沒必要糾結(jié)于與深拷貝和淺拷貝了,沒有必要跟自己過不去不是毅人?
一句話:根據(jù)自己的實際需選擇不同的方法吭狡。
三尖殃, 實現(xiàn)對象和數(shù)組淺拷貝
對象淺拷貝
前面已經(jīng)介紹了對象的兩種淺拷貝方式,這里就不做說明了赵刑。下面介紹其他的幾種方式
1. 使用 Object.assign 方法
Object.assign()
用于將一個或多個源對象中的所有可枚舉的屬性
值復(fù)制到目標(biāo)對象分衫。Object.assign()
只是淺拷貝,類似上文提到的
shallowClone
方法般此。
var object1 = {
a: 1,
obj: {
b: 'string'
}
};
// 淺拷貝
var copy = Object.assign({}, object1);
// 改變原對象屬性
object1.a = 2;
object1.obj.b = 'newString';
console.log(copy.a); // 1
console.log(copy.obj.b); // `newString`
2. 使用 Object.getOwnPropertyNames 拷貝不可枚舉的屬性
Object.getOwnPropertyNames()
返回由對象屬性組成的一個數(shù)組蚪战,包括不可枚舉的屬性(除了使用 Symbol 的屬性)。
function shallowCopyOwnProperties( source )
{
var target = {} ;
var keys = Object.getOwnPropertyNames( original ) ;
for ( var i = 0 ; i < keys.length ; i ++ ) {
target[ keys[ i ] ] = source[ keys[ i ] ] ;
}
return target ;
}
3. 使用 Object.getPrototypeOf 和 Object.getOwnPropertyDescriptor 拷貝原型與描述符
如果我們需要拷貝原對象的原型和描述符铐懊,我們可以使用
Object.getPrototypeOf
和
Object.getOwnPropertyDescriptor
方法分別獲取原對象的原型和描述符邀桑,然后使用
Object.create
和
Object.defineProperty
方法,根據(jù)原型和屬性的描述符創(chuàng)建新的對象和對象的屬性科乎。
function shallowCopy( source ) {
// 用 source 的原型創(chuàng)建一個對象
var target = Object.create( Object.getPrototypeOf( source )) ;
// 獲取對象的所有屬性
var keys = Object.getOwnPropertyNames( source ) ;
// 循環(huán)拷貝對象的所有屬性
for ( var i = 0 ; i < keys.length ; i ++ ) {
// 用原屬性的描述符創(chuàng)建新的屬性
Object.defineProperty( target , keys[ i ] , Object.getOwnPropertyDescriptor( source , keys[ i ])) ;
}
return target ;
}
數(shù)組淺拷貝
同上壁畸,數(shù)組也可以直接復(fù)制或者遍歷數(shù)組的元素直接復(fù)制達到淺拷貝的目的:
var array = [1, 'string', {a: 1,b: 2, obj: {c: 3}}];
// 直接復(fù)制
var array1 = array;
// 遍歷直接復(fù)制
var array2 = [];
for(var key in array) {
array2[key] = array[key];
}
// 改變原數(shù)組元素
array[1] = 'newString';
array[2].c = 4;
console.log(array1[1]); // newString
console.log(array1[2].c); // 4
console.log(array2[1]); // string
console.log(array2[2].c); // 4
這沒有什么需要特別說明的,我們說些其他方法
使用 slice 和 concat 方法
方法將一個數(shù)組被選擇的部分(默認(rèn)情況下是全部元素)淺拷貝到一個新數(shù)組對象茅茂,并返回這個數(shù)組對象捏萍,原始數(shù)組不會被修改。
方法用于合并兩個或多個數(shù)組空闲。此方法不會更改現(xiàn)有數(shù)組令杈,而是返回一個新數(shù)組。
這兩個方法都可以達到拷貝數(shù)組的目的碴倾,并且是淺拷貝逗噩,數(shù)組中的對象只是復(fù)制了引用:
var array = [1, 'string', {a: 1,b: 2, obj: {c: 3}}];
// slice()
var array1 = array.slice();
// concat()
var array2 = array.concat();
// 改變原數(shù)組元素
array[1] = 'newString';
array[2].c = 4;
console.log(array1[1]); // string
console.log(array1[2].c); // 4
console.log(array2[1]); // string
console.log(array2[2].c); // 4
四, 實現(xiàn)對象和數(shù)組深拷貝
實現(xiàn)深拷貝的方法大致有兩種:
-
利用
JSON.stringify
和
JSON.parse
方法
遍歷對象的屬性(或數(shù)組的元素)跌榔,分別拷貝
下面就兩種方法詳細(xì)說說
1. 使用 JSON.stringify 和 JSON.parse 方法
JSON.stringify
和JSON.parse
是 JavaScript 內(nèi)置對象 JSON 的兩個方法异雁,主要是用來將 JavaScript 對象序列化為 JSON 字符串和把 JSON 字符串解析為原生 JavaScript 值。這里被用來實現(xiàn)對象的拷貝也算是一種黑魔法吧:
var obj = { a: 1, b: { c: 2 }};
// 深拷貝
var newObj = JSON.parse(JSON.stringify(obj));
// 改變原對象的屬性
obj.b.c = 20;
console.log(obj); // { a: 1, b: { c: 20 } }
console.log(newObj); // { a: 1, b: { c: 2 } }
但是這種方式有一定的局限性僧须,就是對象必須遵從JSON的格式纲刀,當(dāng)遇到層級較深,且序列化對象不完全符合JSON格式時皆辽,使用JSON的方式進行深拷貝就會出現(xiàn)問題柑蛇。
在序列化 JavaScript 對象時,所有函數(shù)及原型成員
都會被有意忽略驱闷,不體現(xiàn)在結(jié)果中,也就是說這種方法不能拷貝對象中的函數(shù)空免。此外空另,值為 undefined 的任何屬性也都會被跳過。結(jié)果中最終都是值為有效 JSON 數(shù)據(jù)類型的實例屬性蹋砚。
2. 使用遞歸
遞歸是一種常見的解決這種問題的方法:我么可以定義一個函數(shù)扼菠,遍歷對象的屬性摄杂,當(dāng)對象的屬性是基本類型值得時候,直接拷貝循榆;當(dāng)屬性是引用類型值的時候析恢,再次調(diào)用這個函數(shù)進行遞歸拷貝。這是基本的思想秧饮,下面看具體的實現(xiàn)(不考慮原型映挂,描述符,不可枚舉屬性等盗尸,便于理解):
function deepClone(source) {
// 遞歸終止條件
if (!source || typeof source !== 'object') {
return source;
}
var targetObj = source.constructor === Array ? [] : {};
for (var key in source) {
if (Object.prototype.hasOwnProperty.call(source, key) {
if (source[key] && typeof source[key] === 'object') {
targetObj[key] = deepClone(source[key]);
} else {
targetObj[key] = source[key];
}
}
}
return targetObj;
}
var object1 = {arr: [1, 2, 3], obj: {key: 'value' }, func: function(){return 1;}};
// 深拷貝
var newObj= deepClone(object1);
// 改變原對象屬性
object1.arr.push(4);
console.log(object1.arr); // [1, 2, 3, 4]
console.log(newObj.arr); // [1, 2, 3]
對于 Function 類型柑船,這里是直接復(fù)制的,任然是共享一個內(nèi)存地址泼各。因為函數(shù)更多的是完成某些功能鞍时,對函數(shù)的更改可能就是直接重新賦值,一般情況下不考慮深拷貝扣蜻。
上面的深拷貝只是比較簡單的實現(xiàn)逆巍,沒有考慮很復(fù)雜的情況,比如:
- 其他引用類型:Function莽使,Date锐极,RegExp 的拷貝
- 對象中存在循環(huán)引用(Circular references)會導(dǎo)致調(diào)用棧溢出
- 通過閉包作用域來實現(xiàn)私有成員的這類對象不能真正的被拷貝
什么是閉包作用域
function myConstructor()
{
var myPrivateVar = 'secret' ;
return {
myPublicVar: 'public!' ,
getMyPrivateVar: function() {
return myPrivateVar ;
} ,
setMyPrivateVar( value ) {
myPrivateVar = value.toString() ;
}
};
}
var o = myContructor() ;
上面的代碼中,對象 o 有三個屬性吮旅,一個是字符串溪烤,另外兩個是方法。方法中用到一個變量
myPrivateVar
庇勃,存在于
myConstructor()
的函數(shù)作用域中檬嘀,當(dāng)
myConstructor
構(gòu)造函數(shù)調(diào)用時,就創(chuàng)建了這個變量
myPrivateVar
责嚷,然而這個變量并不是通過構(gòu)造函數(shù)創(chuàng)建的對象
o
的屬性鸳兽,但是它任然可以被這兩個方法使用。
因此罕拂,如果嘗試深拷貝對象
o
揍异,那么拷貝對象
clone
和被拷貝對象
original
中的方法都是引用相同的
myPrivateVar
變量。
但是爆班,由于并沒有方式改變閉包的作用域衷掷,所以這種模式創(chuàng)建的對象不能正常深拷貝是可以接受的。
3. 使用隊列
遞歸的做法雖然簡單柿菩,容易理解戚嗅,但是存在一定的性能問題,對拷貝比較大的對象來說不是很好的選擇。
理論上來說懦胞,遞歸是可以轉(zhuǎn)化成循環(huán)的替久,我們可以嘗試著將深拷貝中的遞歸轉(zhuǎn)化成循環(huán)。我們需要遍歷對象的屬性躏尉,如果屬性是基本類型蚯根,直接復(fù)制,如果屬性是引用類型(對象或數(shù)組)胀糜,需要再遍歷這個對象颅拦,對他的屬性進行相同的操作。那么我們需要一個容器來存放需要進行遍歷的對象僚纷,每次從容器中拿出一個對象進行拷貝處理矩距,如果處理過程中遇到新的對象,那么再把它放到這個容器中準(zhǔn)備進行下一輪的處理怖竭,當(dāng)把容器中所有的對象都處理完成后锥债,也就完成了對象的拷貝。
思想大致是這樣的痊臭,下面看具體的實現(xiàn):
// 利用隊列的思想優(yōu)化遞歸
function deepClone(source) {
if (!source || typeof source !== 'object') {
return source;
}
var current;
var target = source.constructor === Array ? [] : {};
// 用數(shù)組作為容器
// 記錄被拷貝的原對象和目標(biāo)
var cloneQueue = [{
source,
target
}];
// 先進先出哮肚,更接近于遞歸
while (current = cloneQueue.shift()) {
for (var key in current.source) {
if (Object.prototype.hasOwnProperty.call(current.source, key)) {
if (current.source[key] && typeof current.source[key] === 'object') {
current.target[key] = current.source[key].constructor === Array ? [] : {};
cloneQueue.push({
source: current.source[key],
target: current.target[key]
});
} else {
current.target[key] = current.source[key];
}
}
}
}
return target;
}
var object1 = {a: 1, b: {c: 2, d: 3}};
var object2 = deepClone(object1);
console.log(object2); // {a: 1, b: {c: 2, d: 3}}