js 深淺拷貝知多少

程式猿何大叔發表於2019-03-03

最近在 vue 框架下寫業務程式碼,不可避免地涉及到物件深淺拷貝的問題,趁機會總結記錄一下。

由於微信文章平臺只能再重新編輯一次,以後文章有更新的話,會更新到我自己的個人部落格,有興趣的可以圍觀下: 個人部落格地址:blog.ironmaxi.com

記憶體的堆區與棧區

首先要講一下大家耳熟能詳的「堆疊」,要區分一下資料結構和記憶體中的「堆疊」定義。

資料結構中的堆和棧是兩種不同的、資料項按序排列的資料結構。

而我們重點要講的是記憶體中的堆區與棧區。

在 C 語言中,棧區分配區域性變數空間,而堆區是地址向上增長的用於分配程式猿申請的記憶體空間,另外還有靜態區是分配靜態變數、全域性變數空間的;只讀區是分配常量和程式程式碼空間的。以下舉個簡單的例子:

int a = 0; // 全域性初始化區
char *p1; // 全域性未初始化區
main()
{
  int b; // 棧
  char s[] = "abc"; // 棧
  char *p2; // 棧
  char *p3 = "123456"; // 在常量區,p3在棧上。
  static int c =0; // 全域性(靜態)初始化區
  p1 = (char *)malloc(10); // 堆
  p2 = (char *)malloc(20); // 堆
}
複製程式碼

而 JavaScript 是高階語言,底層依舊依靠 C/C++ 來編譯實現,其變數劃分為基本資料型別和引用型別。 基本資料型別包括:

  • undefined
  • null
  • boolean
  • number
  • string

這些型別在記憶體中分別佔有固定大小的空間,他們的值儲存在棧空間,通過按值訪問、拷貝和比較。

引用型別包括:

  • object
  • array
  • function
  • error
  • date

這些型別的值大小不固定,棧記憶體中存放地址指向堆記憶體中的物件,是按引用訪問的,說白了就跟 C 語言的指標一樣的道理。

對於引用型別變數,棧記憶體中存放的知識該物件的訪問地址,在堆記憶體中為該值分配空間,由於這種值的大小不固定,因此不能把他們儲存到棧記憶體中;但記憶體地址大小是固定的,因此可以將堆記憶體地址儲存到棧記憶體中。這樣,當查詢引用型別的變數時,就先從棧中讀取堆記憶體地址,然後再根據該地址取出對應的值。

很顯而易見的一點就是,JavaScript 中所有引用型別建立例項時,都是顯式或隱式地 new 出對應型別的例項,實際上就是對應 C 語言的 malloc 分配記憶體函式。

JavaScript 中變數的賦值

js 中變數的賦值分為「傳值」與「傳址」。

給變數賦基本資料型別的值,就是「傳值」;而給變數賦引用資料型別的值,實際上是「傳址」。

基本資料型別變數的賦值、比較,只是值的賦值和比較,也即棧記憶體中的資料的拷貝和比較,參見如下直觀的程式碼:

var num1 = 123;
var num2 = 123;
var num3 = num1;
num1 === num2; // true
num1 === num3; // true
num1 = 456;
num1 === num2; // false
num1 === num3; // false
複製程式碼

引用資料型別變數的賦值、比較,只是存於棧記憶體中的堆記憶體地址的拷貝、比較,參加如下直觀的程式碼:

var arr1 = [1, 2, 3];
var arr2 = [1, 2, 3];
var arr3 = arr1;
arr1 === arr2; // false
arr1 === arr3; // true
arr1 = [1, 2, 3];
arr1 === arr2; // false
arr1 === arr3; // false
複製程式碼

再提及一個要點,js 中所有引用資料型別的頂級原型,都是 Object,也就都是物件。

JavaScript 中變數的拷貝

js 中的拷貝區分為「淺拷貝」與「深拷貝」。

淺拷貝

淺拷貝只會將物件的各個屬性進行依次複製,並不會進行遞迴複製,也就是說只會賦值目標物件的第一層屬性。

對於目標物件第一層為基本資料型別的資料,就是直接賦值,即「傳值」; 而對於目標物件第一層為引用資料型別的資料,就是直接賦存於棧記憶體中的堆記憶體地址,即「傳址」。

深拷貝

深拷貝不同於淺拷貝,它不只拷貝目標物件的第一層屬性,而是遞迴拷貝目標物件的所有屬性。

一般來說,在JavaScript中考慮複合型別的深層複製的時候,往往就是指對於 DateObjectArray 這三個複合型別的處理。我們能想到的最常用的方法就是先建立一個空的新物件,然後遞迴遍歷舊物件,直到發現基礎型別的子節點才賦予到新物件對應的位置。

不過這種方法會存在一個問題,就是 JavaScript 中存在著神奇的原型機制,並且這個原型會在遍歷的時候出現,然後需要考慮原型應不應該被賦予給新物件。那麼在遍歷的過程中,我們可以考慮使用 hasOwnProperty 方法來判斷是否過濾掉那些繼承自原型鏈上的屬性。

動手實現一份淺拷貝加擴充套件的函式

function _isPlainObject(target) {
  return (typeof target === 'object' && !!target && !Array.isArray(target));
}
function shallowExtend() {
  var args = Array.prototype.slice.call(arguments);
  // 第一個引數作為target
  var target = args[0];
  var src;

  target = _isPlainObject(target) ? target : {};
  for (var i=1;i<args.length;i++) {
    src = args[i];
    if (!_isPlainObject(src)) {
      continue;
    }
    for(var key in src) {
      if (src.hasOwnProperty(key)) {
        if (src[key] != undefined) {
          target[key] = src[key];
        }
      }
    }
  }

  return target;
}
複製程式碼

測試用例:

// 初始化引用資料型別變數
var target = {
  key: 'value',
  num: 1,
  bool: false,
  arr: [1, 2, 3],
  obj: {
    objKey: 'objValue'
  },
};
// 拷貝+擴充套件
var result = shallowExtend({}, target, {
  key: 'valueChanged',
  num: 2,
  bool: true,
});
// 對原引用型別資料做修改
target.arr.push(4);
target.obj['objKey2'] = 'objValue2';
// 比較基本資料型別的屬性值
result === target; // false
result.key === target.key;  // false
result.num === target.num;  // false
result.bool === target.bool;// false
// 比較引用資料型別的屬性值
result.arr === target.arr;  // true
result.obj === target.obj;  // true
複製程式碼

jQuery.extend 實現深淺拷貝加擴充套件功能

貼下 jQuery@3.3.1 中 jQuery.extend 的實現:


jQuery.extend = jQuery.fn.extend = function() {
  var options,
    name,
    src,
    copy,
    copyIsArray,
    clone,
    target = arguments[0] || {},
    i = 1,
    length = arguments.length,
    deep = false;

  // 如果第一個引數是布林值,則為判斷是否深拷貝的標誌變數
  if (typeof target === "boolean") {
    deep = target;
    // 跳過 deep 標誌變數,留意上面 i 的初始值為1
    target = arguments[i] || {};
    // i 自增1
    i++;
  }

  // 判斷 target 是否為 object / array / function 以外的型別變數
  if (typeof target !== "object" && !isFunction(target)) {
    // 如果是其它型別變數,則強制重新賦值為新的空物件
    target = {};
  }

  // 如果只傳入1個引數;或者是傳入2個引數,第一個引數為 deep 變數,第二個為 target
  // 所以 length 的值可能為 1 或 2,但無論是 1 或 2,下段 for 迴圈只會執行一次
  if (i === length) {
    // 將 jQuery 本身賦值給 target
    target = this;
    // i 自減1,可能的值為 0 或 1
    i--;
  }

  for (; i < length; i++) {
    // 以下拷貝操作,只針對非 null 或 undefined 的 arguments[i] 進行
    if ((options = arguments[i]) != null) {
      // Extend the base object
      for (name in options) {
        src = target[name];
        copy = options[name];
        // 避免死迴圈的情況
        if (target === copy) {
          continue;
        }
        // Recurse if we're merging plain objects or arrays
        // 如果是深拷貝,且copy值有效,且copy值為純object或純array
        if (deep && copy && (jQuery.isPlainObject(copy) || (copyIsArray = Array.isArray(copy)))) {
          if (copyIsArray) {
            // 陣列情況
            copyIsArray = false;
            clone = src && Array.isArray(src)
              ? src
              : [];
          } else {
            // 物件情況
            clone = src && jQuery.isPlainObject(src)
              ? src
              : {};
          }
          // 克隆copy物件到原物件並賦值回原屬性,而不是重新賦值
          // 遞迴呼叫
          target[name] = jQuery.extend(deep, clone, copy);

          // Don't bring in undefined values
        } else if (copy !== undefined) {
          target[name] = copy;
        }
      }
    }
  }
  // Return the modified object
  return target;
};
複製程式碼

該方法的作用是用一個或多個其他物件來擴充套件一個物件,返回被擴充套件的物件。

如果不指定target,則給jQuery名稱空間本身進行擴充套件。這有助於外掛作者為jQuery增加新方法。

如果第一個引數設定為true,則jQuery返回一個深層次的副本,遞迴地複製找到的任何物件;否則的話,副本會與原物件共享結構。 未定義的屬性將不會被複制,然而從物件的原型繼承的屬性將會被複制

ES6 實現深淺拷貝

Object.assign

Object.assign 方法可以把 任意多個的源物件所擁有的自身可列舉屬性 拷貝給目標物件,然後返回目標物件。

注意:

  1. 對於訪問器屬性,該方法會執行那個訪問器屬性的 getter 函式,然後把得到的值拷貝給目標物件,如果你想拷貝訪問器屬性本身,請使用 Object.getOwnPropertyDescriptor()Object.defineProperties() 方法;
  2. 字串型別和 symbol 型別的屬性都會被拷貝;
  3. 在屬性拷貝過程中可能會產生異常,比如目標物件的某個只讀屬性和源物件的某個屬性同名,這時該方法會丟擲一個 TypeError 異常,拷貝過程中斷,已經拷貝成功的屬性不會受到影響,還未拷貝的屬性將不會再被拷貝;
  4. 該方法會跳過那些值為 nullundefined 的源物件;

利用 JSON 進行忽略原型鏈的深拷貝

var dest = JSON.parse(JSON.stringify(target));
複製程式碼

同樣的它也有缺點: 該方法會忽略掉值為 undefined 的屬性以及函式表示式,但不會忽略值為 null 的屬性。

再談原型鏈屬性

在專案實踐中,發現有起碼有以下兩種方式可以來規避原型鏈屬性上的拷貝。

方式1

最常用的方式:

for (let key in targetObj) {
  if (targetObj.hasOwnProperty(key)) {
    // 相關操作
  }
}
複製程式碼

缺點:遍歷了原型鏈上的所有屬性,效率不高;

方式2

以下都是 ES6 的方式:

const keys = Object.keys(targetObj);
keys.map((key)=>{
  // 相關操作
});
複製程式碼

注意:只會返回引數物件自身的(不含繼承的)所有可遍歷(enumerable)屬性的鍵名所組成的陣列。

方式3

另闢蹊徑:

const obj = Object.create(null);
target.__proto__ = Object.create(null);
for (let key in target) {
  // 相關操作
}
複製程式碼

微信公眾號

相關文章