前言
本文要解決的問題:
- 為什么會有深拷貝(deep clone)和淺拷貝(shallow clone)的存在
- 理解 JavaScript 中深拷貝和淺拷貝的區(qū)別
- JavaScript 拷貝對象的注意事項(xiàng)
- JavaScript 拷貝對象和數(shù)組的實(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ù)類型是按值訪問的,因?yàn)榭梢灾苯硬僮鞅4嬖谧兞恐械膶?shí)際的值。引用類型的值是保存在內(nèi)存中的對象,與其他語言不同,JavaScript 不允許直接訪問內(nèi)存中的位置,也就是說不能直接操作對象的內(nèi)存空間。在操作對象時,實(shí)際上是在操作對象的引用而不是實(shí)際的對象。 為此,引用類型的值是按引用訪問的。
除了保存的方式不同之外,在從一個變量向另一個變量復(fù)制基本類型值和引用類型值時,也存在不同:
- 如果從一個變量向另一個變量復(fù)制基本類型的值,會在變量對象上創(chuàng)建一個新值,然后把該值復(fù)制到為新變量分配的位置上。
- 當(dāng)從一個變量向另一個變量復(fù)制引用類型的值時,同樣也會將存儲在變量對象中的值復(fù)制一份放到為新變量分配的空間中。不同的是,這個值的副本實(shí)際上是一個指針,而這個指針指向存儲在堆中的一個對象。復(fù)制操作結(jié)束后,兩個變量實(shí)際上將引用同一個對象。因此,改變其中一個變量,就會影響另一個變量。
看下面的代碼:
// 基本類型值復(fù)制
var string1 = 'base type';
var string2 = string1;
// 引用類型值復(fù)制
var object1 = {a: 1};
var object2 = object1;
下圖可以表示兩種類型的變量的復(fù)制結(jié)果:
至此,我們應(yīng)該理解:在 JavaScript 中直接復(fù)制對象實(shí)際上是對引用的復(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 的一個實(shí)例。 - 原對象從它的原型繼承的屬性也會被拷貝到新對象中,就像是原對象的屬性一樣,無法區(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)格模式則會賦值失?。ǖ粫箦e)。
我們調(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
。
通過上面的例子和簡短的說明,我們可以大致理解淺拷貝存在的一些問題,在實(shí)際使用過程中也能有自己的判斷。
深拷貝
深拷貝
就是將對象的屬性遞歸的拷貝到一個新的對象上,兩個對象有不同的地址,不同的引用,也包括對象里的對象屬性(如 object1 中的 obj 屬性),兩個變量之間完全獨(dú)立。
沒有銀彈 - 根據(jù)實(shí)際需求
既然淺拷貝有那么多問題,我們?yōu)槭裁催€要說淺拷貝?一來是深拷貝的完美實(shí)現(xiàn)不那么容易(甚至不存在),而且可能存在性能問題,二來是有些時候的確不需要深拷貝,那么我們也就沒必要糾結(jié)于與深拷貝和淺拷貝了,沒有必要跟自己過不去不是?
一句話:根據(jù)自己的實(shí)際需選擇不同的方法。
三, 實(shí)現(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ù)制達(dá)到淺拷貝的目的:
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 方法
slice()
方法將一個數(shù)組被選擇的部分(默認(rèn)情況下是全部元素)淺拷貝到一個新數(shù)組對象,并返回這個數(shù)組對象,原始數(shù)組不會被修改。 concat()
方法用于合并兩個或多個數(shù)組。此方法不會更改現(xiàn)有數(shù)組,而是返回一個新數(shù)組。
這兩個方法都可以達(dá)到拷貝數(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
四, 實(shí)現(xiàn)對象和數(shù)組深拷貝
實(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 值。這里被用來實(shí)現(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的方式進(jìn)行深拷貝就會出現(xiàn)問題。
在序列化 JavaScript 對象時,所有函數(shù)及原型成員
都會被有意忽略,不體現(xiàn)在結(jié)果中,也就是說這種方法不能拷貝對象中的函數(shù)。此外,值為 undefined 的任何屬性也都會被跳過。結(jié)果中最終都是值為有效 JSON 數(shù)據(jù)類型的實(shí)例屬性。
2. 使用遞歸
遞歸是一種常見的解決這種問題的方法:我么可以定義一個函數(shù),遍歷對象的屬性,當(dāng)對象的屬性是基本類型值得時候,直接拷貝;當(dāng)屬性是引用類型值的時候,再次調(diào)用這個函數(shù)進(jìn)行遞歸拷貝。這是基本的思想,下面看具體的實(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)存地址。因?yàn)楹瘮?shù)更多的是完成某些功能,對函數(shù)的更改可能就是直接重新賦值,一般情況下不考慮深拷貝。
上面的深拷貝只是比較簡單的實(shí)現(xiàn),沒有考慮很復(fù)雜的情況,比如:
- 其他引用類型:Function,Date,RegExp 的拷貝
- 對象中存在循環(huán)引用(Circular references)會導(dǎo)致調(diào)用棧溢出
- 通過閉包作用域來實(shí)現(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. 使用隊(duì)列
遞歸的做法雖然簡單,容易理解,但是存在一定的性能問題,對拷貝比較大的對象來說不是很好的選擇。
理論上來說,遞歸是可以轉(zhuǎn)化成循環(huán)的,我們可以嘗試著將深拷貝中的遞歸轉(zhuǎn)化成循環(huán)。我們需要遍歷對象的屬性,如果屬性是基本類型,直接復(fù)制,如果屬性是引用類型(對象或數(shù)組),需要再遍歷這個對象,對他的屬性進(jìn)行相同的操作。那么我們需要一個容器來存放需要進(jìn)行遍歷的對象,每次從容器中拿出一個對象進(jìn)行拷貝處理,如果處理過程中遇到新的對象,那么再把它放到這個容器中準(zhǔn)備進(jìn)行下一輪的處理,當(dāng)把容器中所有的對象都處理完成后,也就完成了對象的拷貝。
思想大致是這樣的,下面看具體的實(shí)現(xiàn):
// 利用隊(duì)列的思想優(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
}];
// 先進(jìn)先出,更接近于遞歸
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}}
(完)