前言
本文要解決的問題:
- 為什么會有深拷貝(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)存空間。在操作對象時孙蒙,實際上是在操作對象的引用而不是實際的對象项棠。 為此,引用類型的值是按引用訪問的挎峦。
除了保存的方式不同之外香追,在從一個變量向另一個變量復制基本類型值和引用類型值時,也存在不同:
- 如果從一個變量向另一個變量復制基本類型的值坦胶,會在變量對象上創(chuàng)建一個新值透典,然后把該值復制到為新變量分配的位置上。
- 當從一個變量向另一個變量復制引用類型的值時迁央,同樣也會將存儲在變量對象中的值復制一份放到為新變量分配的空間中掷匠。不同的是,這個值的副本實際上是一個指針岖圈,而這個指針指向存儲在堆中的一個對象讹语。復制操作結(jié)束后,兩個變量實際上將引用同一個對象蜂科。因此顽决,改變其中一個變量,就會影響另一個變量导匣。
看下面的代碼:
// 基本類型值復制
var string1 = 'base type';
var string2 = string1;
// 引用類型值復制
var object1 = {a: 1};
var object2 = object1;
下圖可以表示兩種類型的變量的復制結(jié)果:
至此才菠,我們應該理解:在 JavaScript 中直接復制對象實際上是對引用的復制,會導致兩個變量引用同一個對象贡定,對任一變量的修改都會反映到另一個變量上赋访,這是一切問題的原因所在。
二缓待, 深拷貝和淺拷貝的區(qū)別
理解了 JavaScript 中拷貝對象的問題后蚓耽,我們就可以講講深拷貝和淺拷貝的區(qū)別了⌒矗考慮這種情況步悠,你需要復制一個對象,這個對象的某個屬性還是一個對象瘫镇,比如這樣:
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)無法被復制腌巾,一個只讀的屬性在拷貝對象中可能會是可寫的遂填。
- 如果屬性是對象的話,原對象的屬性會與拷貝對象的屬性會指向一個對象澈蝙,會彼此影響吓坚。
不能理解這些概念?可以看看下面的代碼:
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'; // 嚴格模式下報錯
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
賦值,在嚴格模式下會報錯厉斟,在非嚴格模式則會賦值失斨壳(但不會報錯)。
我們調(diào)用前面提到的淺拷貝函數(shù) shallowClone
來拷貝 child1
對象擦秽,生成了新的對象 child2
码荔,輸出 child2
的 name
屬性的描述符,我們可以發(fā)現(xiàn) child2
的 name
屬性的描述符與 child1
已經(jīng)不一樣了(變成了可寫的)感挥。通過開啟調(diào)試模式缩搅,查看 child1
和 child2
的原型,我們也會發(fā)現(xiàn)它們的原型也是不同的:
child1
的原型是 Parent
链快,而 child2
的原型則是 Object
誉己。
通過上面的例子和簡短的說明眉尸,我們可以大致理解淺拷貝存在的一些問題域蜗,在實際使用過程中也能有自己的判斷巨双。
深拷貝
深拷貝
就是將對象的屬性遞歸的拷貝到一個新的對象上,兩個對象有不同的地址霉祸,不同的引用筑累,也包括對象里的對象屬性(如 object1 中的 obj 屬性),兩個變量之間完全獨立丝蹭。
沒有銀彈 - 根據(jù)實際需求
既然淺拷貝有那么多問題慢宗,我們?yōu)槭裁催€要說淺拷貝?一來是深拷貝的完美實現(xiàn)不那么容易(甚至不存在)奔穿,而且可能存在性能問題镜沽,二來是有些時候的確不需要深拷貝,那么我們也就沒必要糾結(jié)于與深拷貝和淺拷貝了贱田,沒有必要跟自己過不去不是缅茉?
一句話:根據(jù)自己的實際需選擇不同的方法。
三男摧, 實現(xiàn)對象和數(shù)組淺拷貝
對象淺拷貝
前面已經(jīng)介紹了對象的兩種淺拷貝方式蔬墩,這里就不做說明了。下面介紹其他的幾種方式
1. 使用 Object.assign 方法
Object.assign()
用于將一個或多個源對象中的所有可枚舉的屬性
值復制到目標對象耗拓。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ù)組也可以直接復制或者遍歷數(shù)組的元素直接復制達到淺拷貝的目的:
var array = [1, 'string', {a: 1,b: 2, obj: {c: 3}}];
// 直接復制
var array1 = array;
// 遍歷直接復制
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 方法
slice()
方法將一個數(shù)組被選擇的部分(默認情況下是全部元素)淺拷貝到一個新數(shù)組對象,并返回這個數(shù)組對象监婶,原始數(shù)組不會被修改旅赢。 concat()
方法用于合并兩個或多個數(shù)組。此方法不會更改現(xiàn)有數(shù)組惑惶,而是返回一個新數(shù)組煮盼。
這兩個方法都可以達到拷貝數(shù)組的目的,并且是淺拷貝带污,數(shù)組中的對象只是復制了引用:
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ù)組的元素),分別拷貝
下面就兩種方法詳細說說
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的格式,當遇到層級較深充易,且序列化對象不完全符合JSON格式時梗脾,使用JSON的方式進行深拷貝就會出現(xiàn)問題。
在序列化 JavaScript 對象時盹靴,所有函數(shù)及原型成員
都會被有意忽略炸茧,不體現(xiàn)在結(jié)果中,也就是說這種方法不能拷貝對象中的函數(shù)稿静。此外梭冠,值為 undefined 的任何屬性也都會被跳過。結(jié)果中最終都是值為有效 JSON 數(shù)據(jù)類型的實例屬性改备。
2. 使用遞歸
遞歸是一種常見的解決這種問題的方法:我么可以定義一個函數(shù)妈嘹,遍歷對象的屬性,當對象的屬性是基本類型值得時候绍妨,直接拷貝润脸;當屬性是引用類型值的時候,再次調(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 類型,這里是直接復制的媳搪,任然是共享一個內(nèi)存地址铭段。因為函數(shù)更多的是完成某些功能,對函數(shù)的更改可能就是直接重新賦值秦爆,一般情況下不考慮深拷貝序愚。
上面的深拷貝只是比較簡單的實現(xiàn),沒有考慮很復雜的情況等限,比如:
- 其他引用類型:Function爸吮,Date,RegExp 的拷貝
- 對象中存在循環(huán)引用(Circular references)會導致調(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ù)作用域中,當 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)诅福。我們需要遍歷對象的屬性,如果屬性是基本類型拖叙,直接復制氓润,如果屬性是引用類型(對象或數(shù)組),需要再遍歷這個對象薯鳍,對他的屬性進行相同的操作咖气。那么我們需要一個容器來存放需要進行遍歷的對象,每次從容器中拿出一個對象進行拷貝處理挖滤,如果處理過程中遇到新的對象崩溪,那么再把它放到這個容器中準備進行下一輪的處理,當把容器中所有的對象都處理完成后斩松,也就完成了對象的拷貝伶唯。
思想大致是這樣的,下面看具體的實現(xiàn):
// 利用隊列的思想優(yōu)化遞歸
function deepClone(source) {
if (!source || typeof source !== 'object') {
return source;
}
var current;
var target = source.constructor === Array ? [] : {};
// 用數(shù)組作為容器
// 記錄被拷貝的原對象和目標
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}}
(完)