深入理解 JavaScript 對象和數(shù)組拷貝

前言

本文要解決的問題:

  • 為什么會有深拷貝(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'
  }
}

淺拷貝

淺拷貝存在兩種情況:

  • 直接拷貝對象鼎兽,也就是拷貝引用答姥,兩個變量object1object2 之間還是會相互影響。
  • 只是簡單的拷貝對象的第一層屬性谚咬,基本類型值不再相互影響鹦付,但是對其內(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礁击。并且修改了 child1name 屬性的描述符,設(shè)置 writablefalse逗载,也就是這個屬性不能再被修改哆窿。如果要直接給 child1.name 賦值,在嚴格模式下會報錯厉斟,在非嚴格模式則會賦值失斨壳(但不會報錯)。

我們調(diào)用前面提到的淺拷貝函數(shù) shallowClone 來拷貝 child1 對象擦秽,生成了新的對象 child2码荔,輸出 child2name 屬性的描述符,我們可以發(fā)現(xiàn) child2name 屬性的描述符與 child1 已經(jīng)不一樣了(變成了可寫的)感挥。通過開啟調(diào)試模式缩搅,查看 child1child2 的原型,我們也會發(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.getPrototypeOfObject.getOwnPropertyDescriptor 方法分別獲取原對象的原型和描述符哥谷,然后使用 Object.createObject.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.stringifyJSON.parse 方法
  • 遍歷對象的屬性(或數(shù)組的元素),分別拷貝

下面就兩種方法詳細說說

1. 使用 JSON.stringify 和 JSON.parse 方法

JSON.stringifyJSON.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}}

(完)

參考

  1. 《JavaScript 高級程序設(shè)計》
  2. JavaScript中的淺拷貝和深拷貝
  3. 探究 JS 中的淺拷貝和深拷貝
  4. Understanding Object Cloning in Javascript - Part. I
  5. Understanding Object Cloning in Javascript - Part. II
最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末抵怎,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子岭参,更是在濱河造成了極大的恐慌反惕,老刑警劉巖,帶你破解...
    沈念sama閱讀 218,941評論 6 508
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件演侯,死亡現(xiàn)場離奇詭異姿染,居然都是意外死亡,警方通過查閱死者的電腦和手機,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 93,397評論 3 395
  • 文/潘曉璐 我一進店門悬赏,熙熙樓的掌柜王于貴愁眉苦臉地迎上來狡汉,“玉大人,你說我怎么就攤上這事闽颇《艽鳎” “怎么了?”我有些...
    開封第一講書人閱讀 165,345評論 0 356
  • 文/不壞的土叔 我叫張陵兵多,是天一觀的道長尖啡。 經(jīng)常有香客問我,道長剩膘,這世上最難降的妖魔是什么衅斩? 我笑而不...
    開封第一講書人閱讀 58,851評論 1 295
  • 正文 為了忘掉前任,我火速辦了婚禮怠褐,結(jié)果婚禮上畏梆,老公的妹妹穿的比我還像新娘。我一直安慰自己奈懒,他們只是感情好奠涌,可當我...
    茶點故事閱讀 67,868評論 6 392
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著磷杏,像睡著了一般铣猩。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上茴丰,一...
    開封第一講書人閱讀 51,688評論 1 305
  • 那天达皿,我揣著相機與錄音,去河邊找鬼贿肩。 笑死峦椰,一個胖子當著我的面吹牛,可吹牛的內(nèi)容都是我干的汰规。 我是一名探鬼主播汤功,決...
    沈念sama閱讀 40,414評論 3 418
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼溜哮!你這毒婦竟也來了滔金?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 39,319評論 0 276
  • 序言:老撾萬榮一對情侶失蹤茂嗓,失蹤者是張志新(化名)和其女友劉穎餐茵,沒想到半個月后,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體述吸,經(jīng)...
    沈念sama閱讀 45,775評論 1 315
  • 正文 獨居荒郊野嶺守林人離奇死亡忿族,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 37,945評論 3 336
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片道批。...
    茶點故事閱讀 40,096評論 1 350
  • 序言:一個原本活蹦亂跳的男人離奇死亡错英,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出隆豹,到底是詐尸還是另有隱情椭岩,我是刑警寧澤移迫,帶...
    沈念sama閱讀 35,789評論 5 346
  • 正文 年R本政府宣布宿接,位于F島的核電站,受9級特大地震影響褒侧,放射性物質(zhì)發(fā)生泄漏鉴吹。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 41,437評論 3 331
  • 文/蒙蒙 一惩琉、第九天 我趴在偏房一處隱蔽的房頂上張望豆励。 院中可真熱鬧,春花似錦瞒渠、人聲如沸良蒸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,993評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽嫩痰。三九已至,卻和暖如春窍箍,著一層夾襖步出監(jiān)牢的瞬間串纺,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 33,107評論 1 271
  • 我被黑心中介騙來泰國打工椰棘, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留纺棺,地道東北人。 一個月前我還...
    沈念sama閱讀 48,308評論 3 372
  • 正文 我出身青樓邪狞,卻偏偏與公主長得像祷蝌,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子帆卓,可洞房花燭夜當晚...
    茶點故事閱讀 45,037評論 2 355

推薦閱讀更多精彩內(nèi)容