深刻理解 JavaScript 對象和數組拷貝

前言

本文要解決的問題:javascript

  • 爲何會有深拷貝(deep clone)和淺拷貝(shallow clone)的存在
  • 理解 JavaScript 中深拷貝和淺拷貝的區別
  • JavaScript 拷貝對象的注意事項
  • JavaScript 拷貝對象和數組的實現方法

部分代碼可在這裏找到:Github。若是發現錯誤,歡迎指出。java

一, 理解問題緣由所在

JavaScript 中的數據類型能夠分爲兩種:基本類型值(Number, Boolean, String, NULL, Undefined)和引用類型值(Array, Object, Date, RegExp, Function)。 基本類型值指的是簡單的數據段,而引用類型值指那些可能由多個值構成的對象。git

基本數據類型是按值訪問的,由於能夠直接操做保存在變量中的實際的值。引用類型的值是保存在內存中的對象,與其餘語言不一樣,JavaScript 不容許直接訪問內存中的位置,也就是說不能直接操做對象的內存空間。在操做對象時,其實是在操做對象的引用而不是實際的對象。 爲此,引用類型的值是按引用訪問的。github

除了保存的方式不一樣以外,在從一個變量向另外一個變量複製基本類型值和引用類型值時,也存在不一樣:segmentfault

  • 若是從一個變量向另外一個變量複製基本類型的值,會在變量對象上建立一個新值,而後把該值複製到爲新變量分配的位置上。
  • 當從一個變量向另外一個變量複製引用類型的值時,一樣也會將存儲在變量對象中的值複製一份放到爲新變量分配的空間中。不一樣的是,這個值的副本其實是一個指針,而這個指針指向存儲在堆中的一個對象。複製操做結束後,兩個變量實際上將引用同一個對象。所以,改變其中一個變量,就會影響另外一個變量。

看下面的代碼:數組

// 基本類型值複製
var string1 = 'base type';
var string2 = string1;

// 引用類型值複製
var object1 = {a: 1};
var object2 = object1;
複製代碼

下圖能夠表示兩種類型的變量的複製結果:bash

至此,咱們應該理解:在 JavaScript 中直接複製對象其實是對引用的複製,會致使兩個變量引用同一個對象,對任一變量的修改都會反映到另外一個變量上,這是一切問題的緣由所在。閉包

二, 深拷貝和淺拷貝的區別

理解了 JavaScript 中拷貝對象的問題後,咱們就能夠講講深拷貝和淺拷貝的區別了。考慮這種狀況,你須要複製一個對象,這個對象的某個屬性仍是一個對象,好比這樣:函數

var object1 = {
  a: 1,
  obj: {
    b: 'string'
  }
}
複製代碼

淺拷貝

淺拷貝存在兩種狀況:性能

  • 直接拷貝對象,也就是拷貝引用,兩個變量object1object2 之間仍是會相互影響。
  • 只是簡單的拷貝對象的第一層屬性,基本類型值再也不相互影響,可是對其內部的引用類型值,拷貝的任然是是其引用,內部的引用類型值仍是會相互影響。
// 最簡單的淺拷貝
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 的一個實例。
  • 原對象從它的原型繼承的屬性也會被拷貝到新對象中,就像是原對象的屬性同樣,沒法區分。
  • 屬性的描述符(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 屬性的描述符已經發生了變化
// 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
複製代碼

上面的代碼經過構造函數 Child 構造一個對象 child1,這個對象的原型是 Parent。而且修改了 child1name 屬性的描述符,設置 writablefalse,也就是這個屬性不能再被修改。若是要直接給 child1.name 賦值,在嚴格模式下會報錯,在非嚴格模式則會賦值失敗(但不會報錯)。

咱們調用前面提到的淺拷貝函數 shallowClone 來拷貝 child1 對象,生成了新的對象 child2,輸出 child2name 屬性的描述符,咱們能夠發現 child2name 屬性的描述符與 child1 已經不同了(變成了可寫的)。在 VSCode 中開啓調試模式,查看 child1child2 的原型,咱們也會發現它們的原型也是不一樣的:

child1 的原型是 Parent,而 child2 的原型則是 Object

經過上面的例子和簡短的說明,咱們能夠大體理解淺拷貝存在的一些問題,在實際使用過程當中也能有本身的判斷。

深拷貝

深拷貝就是將對象的屬性遞歸的拷貝到一個新的對象上,兩個對象有不一樣的地址,不一樣的引用,也包括對象裏的對象屬性(如 object1 中的 obj 屬性),兩個變量之間徹底獨立。

沒有銀彈 - 根據實際需求

既然淺拷貝有那麼多問題,咱們爲何還要說淺拷貝?一來是深拷貝的完美實現不那麼容易(甚至不存在),並且可能存在性能問題,二來是有些時候的確不須要深拷貝,那麼咱們也就不必糾結於與深拷貝和淺拷貝了,沒有必要跟本身過不去不是?

一句話:根據本身的實際需選擇不一樣的方法。

三, 實現對象和數組淺拷貝

對象淺拷貝

前面已經介紹了對象的兩種淺拷貝方式,這裏就不作說明了。下面介紹其餘的幾種方式

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() 返回由對象屬性組成的一個數組,包括不可枚舉的屬性(除了使用 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 方法,根據原型和屬性的描述符建立新的對象和對象的屬性。

function shallowCopy( source ) {
    // 用 source 的原型建立一個對象
    var target = Object.create( Object.getPrototypeOf( source )) ;
    // 獲取對象的全部屬性
    var keys = Object.getOwnPropertyNames( source ) ;
    // 循環拷貝對象的全部屬性
    for ( var i = 0 ; i < keys.length ; i ++ ) {
        // 用原屬性的描述符建立新的屬性
        Object.defineProperty( target , keys[ i ] , Object.getOwnPropertyDescriptor( source , keys[ i ])) ;
    }
    return target ;
}
複製代碼

數組淺拷貝

同上,數組也能夠直接複製或者遍歷數組的元素直接複製達到淺拷貝的目的:

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];
}
// 改變原數組元素
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() 方法將一個數組被選擇的部分(默認狀況下是所有元素)淺拷貝到一個新數組對象,並返回這個數組對象,原始數組不會被修改。 concat() 方法用於合併兩個或多個數組。此方法不會更改現有數組,而是返回一個新數組。

這兩個方法均可以達到拷貝數組的目的,而且是淺拷貝,數組中的對象只是複製了引用:

var array = [1, 'string', {a: 1,b: 2, obj: {c: 3}}];
// slice()
var array1 = array.slice();
// concat()
var array2 = array.concat();
// 改變原數組元素
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
複製代碼

四, 實現對象和數組深拷貝

實現深拷貝的方法大體有兩種:

  • 利用 JSON.stringifyJSON.parse 方法
  • 遍歷對象的屬性(或數組的元素),分別拷貝

下面就兩種方法詳細說說

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

JSON.stringifyJSON.parse 是 JavaScript 內置對象 JSON 的兩個方法,主要是用來將 JavaScript 對象序列化爲 JSON 字符串和把 JSON 字符串解析爲原生 JavaScript 值。這裏被用來實現對象的拷貝也算是一種黑魔法吧:

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的方式進行深拷貝就會出現問題。

在序列化 JavaScript 對象時,全部函數及原型成員都會被有意忽略,不體如今結果中,也就是說這種方法不能拷貝對象中的函數。此外,值爲 undefined 的任何屬性也都會被跳過。結果中最終都是值爲有效 JSON 數據類型的實例屬性。

2. 使用遞歸

遞歸是一種常見的解決這種問題的方法:咱們能夠定義一個函數,遍歷對象的屬性,當對象的屬性是基本類型值得時候,直接拷貝;當屬性是引用類型值的時候,再次調用這個函數進行遞歸拷貝。這是基本的思想,下面看具體的實現(不考慮原型,描述符,不可枚舉屬性等,便於理解):

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 類型,這裏是直接複製的,任然是共享一個內存地址。由於函數更多的是完成某些功能,對函數的更改可能就是直接從新賦值,通常狀況下不考慮深拷貝。

上面的深拷貝只是比較簡單的實現,沒有考慮很複雜的狀況,好比:

  • 其餘引用類型:Function,Date,RegExp 的拷貝
  • 對象中存在循環引用(Circular references)會致使調用棧溢出
  • 經過閉包做用域來實現私有成員的這類對象不能真正的被拷貝

什麼是閉包做用域

function myConstructor() {
    var myPrivateVar = 'secret' ;
    return {
        myPublicVar: 'public!' ,
        getMyPrivateVar: function() {
            return myPrivateVar ;
        } ,
        setMyPrivateVar( value ) {
            myPrivateVar = value.toString() ;
        }
    };
}
var o = myContructor() ;
複製代碼

上面的代碼中,對象 o 有三個屬性,一個是字符串,另外兩個是方法。方法中用到一個變量 myPrivateVar,存在於 myConstructor() 的函數做用域中,當 myConstructor 構造函數調用時,就建立了這個變量 myPrivateVar,然而這個變量並非經過構造函數建立的對象 o 的屬性,可是它任然能夠被這兩個方法使用。

所以,若是嘗試深拷貝對象 o,那麼拷貝對象 clone 和被拷貝對象 original 中的方法都是引用相同的 myPrivateVar 變量。

可是,因爲並無方式改變閉包的做用域,因此這種模式建立的對象不能正常深拷貝是能夠接受的。

3. 使用隊列

遞歸的作法雖然簡單,容易理解,可是存在必定的性能問題,對拷貝比較大的對象來講不是很好的選擇。

理論上來講,遞歸是能夠轉化成循環的,咱們能夠嘗試着將深拷貝中的遞歸轉化成循環。咱們須要遍歷對象的屬性,若是屬性是基本類型,直接複製,若是屬性是引用類型(對象或數組),須要再遍歷這個對象,對他的屬性進行相同的操做。那麼咱們須要一個容器來存放須要進行遍歷的對象,每次從容器中拿出一個對象進行拷貝處理,若是處理過程當中遇到新的對象,那麼再把它放到這個容器中準備進行下一輪的處理,當把容器中全部的對象都處理完成後,也就完成了對象的拷貝。

思想大體是這樣的,下面看具體的實現:

// 利用隊列的思想優化遞歸
function deepClone(source) {
  if (!source || typeof source !== 'object') {
    return source;
  }
  var current;
  var target = source.constructor === Array ? [] : {};
  // 用數組做爲容器
  // 記錄被拷貝的原對象和目標
  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 高級程序設計》
  2. JavaScript中的淺拷貝和深拷貝
  3. 探究 JS 中的淺拷貝和深拷貝
  4. Understanding Object Cloning in Javascript - Part. I
  5. Understanding Object Cloning in Javascript - Part. II
相關文章
相關標籤/搜索