深入理解 JavaScript 物件和陣列拷貝

無亦情發表於2019-03-03

前言

本文要解決的問題:

  • 為什麼會有深拷貝(deep clone)和淺拷貝(shallow clone)的存在
  • 理解 JavaScript 中深拷貝和淺拷貝的區別
  • JavaScript 拷貝物件的注意事項
  • JavaScript 拷貝物件和陣列的實現方法

部分程式碼可在這裡找到:Github。如果發現錯誤,歡迎指出。

一, 理解問題原因所在

JavaScript 中的資料型別可以分為兩種:基本型別值(Number, Boolean, String, NULL, Undefined)和引用型別值(Array, Object, Date, RegExp, Function)。 基本型別值指的是簡單的資料段,而引用型別值指那些可能由多個值構成的物件。

基本資料型別是按值訪問的,因為可以直接操作儲存在變數中的實際的值。引用型別的值是儲存在記憶體中的物件,與其他語言不同,JavaScript 不允許直接訪問記憶體中的位置,也就是說不能直接操作物件的記憶體空間。在操作物件時,實際上是在操作物件的引用而不是實際的物件。 為此,引用型別的值是按引用訪問的。

除了儲存的方式不同之外,在從一個變數向另一個變數複製基本型別值和引用型別值時,也存在不同:

  • 如果從一個變數向另一個變數複製基本型別的值,會在變數物件上建立一個新值,然後把該值複製到為新變數分配的位置上。
  • 當從一個變數向另一個變數複製引用型別的值時,同樣也會將儲存在變數物件中的值複製一份放到為新變數分配的空間中。不同的是,這個值的副本實際上是一個指標,而這個指標指向儲存在堆中的一個物件。複製操作結束後,兩個變數實際上將引用同一個物件。因此,改變其中一個變數,就會影響另一個變數。

看下面的程式碼:

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

// 引用型別值複製
var object1 = {a: 1};
var object2 = object1;
複製程式碼

下圖可以表示兩種型別的變數的複製結果:

深入理解 JavaScript 物件和陣列拷貝

至此,我們應該理解:在 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 的原型,我們也會發現它們的原型也是不同的:

深入理解 JavaScript 物件和陣列拷貝

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

相關文章