JavaScript中的淺拷貝與深拷貝

蔣鵬飛發表於2020-01-15

JS中有兩種資料型別,值型別和引用型別,當我們需要把一個變數賦給另一個變數時,對於值型別很簡單:

let a = 1;
let b = a;
b = 10;
console.log(a, b); // 1, 10
複製程式碼

但是如果a是一個物件,這就有問題了

let a = {value: 1};
let b = a;
b.value = 10;
console.log(a.value, b.value); // 10, 10
複製程式碼

我們發現改變b.value的時候,a.value的值也跟著變了,這是因為JS裡面的物件是引用型別,我們在把變數a賦值給變數b的時候,賦值過去的其實是a的引用地址,b有了相同的引用地址,那a跟b指向的都是同一塊記憶體空間,操作b的屬性,其實就是操作了這塊記憶體,因為a也指向這塊記憶體,所以a的屬性也變了。這其實就是一個淺拷貝。

淺拷貝

上面這樣我們直接將一個引用變數賦值給另一個變數是一種淺拷貝,淺拷貝其實還有其他形式。這次我們需要拷貝的目標是

let target = {
    name: 'John',
  age: 20,
  friend: {
    name: 'Michel',
    age: 30
  }
}
複製程式碼

我們可以直接遍歷target物件,將它賦給一個新物件就行。

const shallowCopy = (obj) => {
  // 判斷引數是陣列還是物件
  const result = Array.isArray(obj) ? [] : {};
  for(let key in obj) {
    // 使用hasOwnProperty來判斷是否是自身屬性
    // 只拷貝自身屬性,不拷貝原型鏈上的屬性,即繼承屬性
    if(obj.hasOwnProperty(key)){
      result[key] = obj[key];
    }
  }

  return result;
}
複製程式碼

然後我們來用一下這個方法:

let newObj = shallowCopy(target);
newObj.age = 50;
console.log(target.age, newObj.age); //20, 50
複製程式碼

我們可以看到當我們改變newObj的屬性時,原物件的屬性並沒有受影響,但是如果我們改變newObj.friend呢?

newObj.friend.age = 50;
console.log(target.friend.age, newObj.friend.age); //50, 50
複製程式碼

我們發現當我們改變newObj.friend的屬性的時候,原物件的newObj.friend的屬性也改變了,這是因為target.friend本身也是一個物件,我們拷貝的時候只拷貝了他的引用地址,所以我們通過newObj操作他的時候也改變了原來的target

從上面可以看出我們的shallowCopy方法只拷貝了物件的一層,這也是一種淺拷貝。其實還有一些原生方法也是隻拷貝一層的,比如Object.assign...擴充套件運算子

let newObj = Object.assign({}, target); // 這是一層的淺拷貝
let newObj = {...target};  // 這也是一層的淺拷貝
複製程式碼

那深拷貝應該怎麼實現呢?

深拷貝

JSON

最簡單的實現方法就是用JSON.stringify先將物件轉換為字串,然後再用JSON.parse重新解析為JSON,這樣新生成的物件與原物件就完全沒有關係了,還是以前面的target為例:

let newObj = JSON.parse(JSON.stringify(target));

newObj.friend.age = 50;
console.log(target.friend.age, newObj.friend.age); //30, 50
複製程式碼

但是我們換一個target再來試試:

let target2 = {
  name: 'John',
  age: 20,
  drive: () => {},
  girlFriend: undefined
}

let newObj = JSON.parse(JSON.stringify(target2));
console.log(newObj);
複製程式碼

結果如下圖,我們發現drivegirlFriend兩個屬性都丟了,這是因為JSON.stringify不能將方法和undefined屬性轉化為字串,在轉換為字串過程中就丟了,再解析回來自然也沒有了

image-20200115153516776

遞迴遍歷

要解決上面的問題,我們還要自己動手,我們改造下上面的shallowCopy方法,讓他能夠遞迴複製。

const deepCopy = (obj) => {
  const result = Array.isArray(obj) ? [] : {};
  for(let key in obj) {
    if(obj.hasOwnProperty(key)){
      // 如果屬性也是物件,遞迴呼叫自身
      if(obj[key] && typeof obj[key] === 'object'){
        result[key] = deepCopy(obj[key])
      } else {
        result[key] = obj[key];
      }
    }
  }

  return result;
}
複製程式碼

來看下結果:

let newObj = deepCopy(target2);
console.log(newObj);
複製程式碼

image-20200115154213386

這下我們的drive方法和girlFriend屬性都複製過來了。

拷貝Symbol

那如果換一個帶有Symbol屬性的物件呢?

let target3 = {
  [Symbol('name')]: 'John',
  age: 20,
  drive: () => {},
  girlFriend: undefined
}
複製程式碼

我們來看看結果:

let newObj = deepCopy(target3);
console.log(newObj);
複製程式碼

image-20200115155047797

我們發現Symbol屬性丟了,那怎麼辦呢?這個原因是for...in...迴圈拿不到Symbol屬性,如果要拿Symbol屬性,我們可以用Object.getOwnPropertySymbolsReflect.ownKeysObject.getOwnPropertySymbols會返回物件的Symbol屬性列表:

image-20200115160547564

Reflect.ownKeys會返回物件的所有自有屬性,包括Symbol屬性和不可列舉屬性,但是不包括繼承屬性。所以我們的deepCopy方法改為:

const deepCopy = (obj) => {
  const result = Array.isArray(obj) ? [] : {};
  // 用 Reflect.ownKeys可以獲取Symbol屬性,用for...of來迴圈陣列
  for(let key of Reflect.ownKeys(obj)) {
    if(obj.hasOwnProperty(key)){
      if(obj[key] && typeof obj[key] === 'object'){
        result[key] = deepCopy(obj[key])
      } else {
        result[key] = obj[key];
      }
    }
  }

  return result;
}
複製程式碼

再來看看結果:

let newObj = deepCopy(target3);
console.log(newObj);
複製程式碼

image-20200115161745677

解決迴圈引用

我們來考慮一個新的目標物件

let target4 = {
  [Symbol('name')]: 'John',
  age: 20,
  drive: () => {},
  girlFriend: undefined
}

target4.target = target4;
複製程式碼

這個物件的target屬性又引用了自身,所以有了迴圈引用,用我們之前的深拷貝方法直接會報錯

image-20200115162212993

要解決這個問題,我們需要每次都將引用型別的鍵和值都記錄下來,由於Object的鍵不能是物件,所以我們不能用Object記錄,這裡採用了WeakMap來記錄:

const deepCopy2 = (originObj) => {
  // 全域性只能有一個記錄的map,所以裡面又嵌了一個方法
  const map = new WeakMap();
  function dp(obj){
    const result = Array.isArray(obj) ? [] : {};

    const existObj = map.get(obj);
    // 檢查map中是不是已經有這個物件了,有了就直接返回,不再遞迴
    if(existObj){
      return existObj;
    }

    // 沒有就記錄下來
    map.set(obj, result);

    for(let key of Reflect.ownKeys(obj)) {
      if(obj.hasOwnProperty(key)){
        if(obj[key] && typeof obj[key] === 'object'){
          result[key] = dp(obj[key])
        } else {
          result[key] = obj[key];
        }
      }
    }

    return result;
  }

  return dp(originObj);
}
複製程式碼

WeakMap的相容性不是很好,如果是老瀏覽器不支援WeakMap,我們可以用兩個陣列來模擬,一個陣列存鍵,一個陣列存值,每次都只在兩個陣列末尾新增值,這樣鍵和值在陣列中的索引就是一樣的,我們可以通過這個索引來進行鍵和值的匹配。

淺拷貝的應用:mixin--混合模式

直接看程式碼

const mixin = {
  // 注意:這裡的say和run不能寫成箭頭函式,因為箭頭函式拿不到正確的this
  say() {
    console.log(`${this.name}在說話`)
  },
  run() {
    console.log(`${this.name}在跑步`)
  }
}

class Student{
  constructor(name){
    this.name = name
  }
}

Object.assign(Student.prototype, mixin);

const student1 = new Student('Jhon');
student1.say();
複製程式碼

上面的程式碼我們沒有用繼承,而是用了拷貝的方式,讓Student類具有了mixin的方法,我們直接將mixin裡面的方法複製到了Student的原型鏈上。這種模式在很多地方都有應用,比如Vue:

image-20200115175945936

深拷貝應用:pick函式

在underscore裡面有一個pick函式,可以實現如下效果:

image-20200115180254369

上述程式碼的輸出是一個只包含age屬性的新物件{age: 30},下面讓我們自己來實現一個pick函式,實現在原理很簡單,把我們之前深拷貝的方法改一下就行,讓他只拷貝我們需要的屬性:

const pick = (originObj, property) => {
  const map = new WeakMap();
  function dp(obj){
    const result = Array.isArray(obj) ? [] : {};

    const existObj = map.get(obj);

    if(existObj){
      return existObj;
    }

    map.set(obj, result);

    for(let key of Reflect.ownKeys(obj)) {
      // 只需要加一個檢測,看看key是不是我們需要的屬性就行
      if(obj.hasOwnProperty(key) && key === property){
        if(obj[key] && typeof obj[key] === 'object'){
          result[key] = dp(obj[key])
        } else {
          result[key] = obj[key];
        }
      }
    }

    return result;
  }

  return dp(originObj);
}複製程式碼

原創不易,每篇文章都耗費了作者大量的時間和心血,如果本文對你有幫助,請點贊支援作者,也讓更多人看到本文~~

更多文章請看我的掘金文章彙總


相關文章