城市碼農

JS 中的淺拷貝 (Shallow copy) 與深拷貝 (Deep copy) 原理與實作

前言

在上篇文章 JS 變數傳遞探討:pass by value 、 pass by reference 還是 pass by sharing? 中有提到: JS 中,資料的型別主要有分為「基本型別 (Primitive type)」 以及「物件 (Object)」,兩者最大的差異在於:

  • Primitive type data 複製變數時,會直接「 複製值 (value) 」。像是:stringnumberbooleanundefinednullsymbol 的變數資料。
  • Object data 複製變數時,會「 複製地址 (address) 」。像是:objectarrayfunction 的變數資料。

概念如下圖:

Primitive type data and Object data

轉換成程式中的表現行為如下:

/*** 基本型別 ***/
let a = 5;
let b = a; // 複製 primitive data 變數

console.log(a); // 5
console.log(b); // 5

b = 10;

console.log(a); // 5 => 沒被改變,因為 a 與 b 的值不同
console.log(b); // 10

/*** 物件型別,改變內容 ***/
let a = { number: 5 };
let b = a; // 複製 object data 變數

console.log(a); // { number : 5 }
console.log(b); // { number : 5 }

b.number = 10;

console.log(a); // { number : 10 } => 跟著改變,因 a 與 b 地址相同,指向同個值
console.log(b); // { number : 10 }

可以發現到 Primitive type data 的複製是 real copy,意思是將值(value)真實地複製一份,所以最終結果不會互相影響。

相對地,Object data 則是僅複製地址(address),因此值可能互相影響,這種複製出的變數與原來的變數間會互相影響的複製方法,稱為淺拷貝(shallow copy)

難道 Object data 就無法複製一份「全新的地址和值」的變數嗎?有的,透過一些方法,還是能複製完全不會互相影響的兩個 Object data,這種方法就稱為深拷貝(Deep Copy)

p.s 本文的附圖都是便於理解抽象原理所做,實際在記憶體運作往往有更多細節。


淺拷貝的原理與實作

Original Object dataCloned Object data 中,有任何一層的資料地址相同,背後指向的值相同,兩個物件的操作會互相影響,就為淺拷貝(shallow copy)

shallow copy 1

/*** 淺拷貝:直接複製 ***/
const originalData = {
  firstLayerNum: 10,
  obj: {
    secondLayerNum: 100,
  },
};
const clonedData = originalData;

clonedData.firstLayerNum = 20;
clonedData.obj.secondLayerNum = 200;

console.log(originalData.firstLayerNum);
// 20 => 第一層有被 clonedData 影響而改變
console.log(originalData.obj.secondLayerNum);
// 200 => 第二層有被 clonedData 影響而改變

以上的範例可看出 originalDataclonedData 兩者間會互相影響。

需要注意的是「只要有任何一層的資料地址相同」,換句話說就是「只要並非兩個完完全全獨立的 Object data」,就依然是淺拷貝。

shallow copy 2 p.s 概念示意圖,地址並非直接存在 obj 中,而是 obj 變數會對應一個地址

上面看完原理概念圖,接著就在程式中,實踐這種「第一層不會互相影響,但第二層後會互相影響」的淺拷貝吧!

一、手動複製第一層的物件值

/*** 淺拷貝:手動複製第一層的物件值 ***/
const originalData = {
  firstLayerNum: 10,
  obj: {
    secondLayerNum: 100,
  },
};
const clonedData = {
  firstLayerNum: originalData.firstLayerNum,
  obj: originalData.obj,
};

clonedData.firstLayerNum = 20;
clonedData.obj.secondLayerNum = 200;

console.log(originalData.firstLayerNum);
// 10 => 第一層沒有被 clonedData 影響
console.log(originalData.obj.secondLayerNum);
// 200 => 第二層被 clonedData 影響而改變

二、自建 shallowCopy 函式,複製第一層物件值

/*** 淺拷貝:自建 shallowCopy 函式,複製第一層物件值 ***/
function shallowCopy(originalObj) {
  let clonedObj = {};
  for (const key in originalObj) {
    clonedObj[key] = originalObj[key];
  }
  return clonedObj;
}

const originalData = {
  firstLayerNum: 10,
  obj: {
    secondLayerNum: 100,
  },
};
const clonedData = shallowCopy(originalData);

clonedData.firstLayerNum = 20;
clonedData.obj.secondLayerNum = 200;

console.log(originalData.firstLayerNum);
// 10 => 第一層沒有被 clonedData 影響
console.log(originalData.obj.secondLayerNum);
// 200 => 第二層被 clonedData 影響而改變

三、Object.assign(target, ...sources)

/*** 淺拷貝:Object.assign ***/
const originalData = {
  firstLayerNum: 10,
  obj: {
    secondLayerNum: 100,
  },
};
const clonedData = Object.assign({}, originalData);

clonedData.firstLayerNum = 20;
clonedData.obj.secondLayerNum = 200;

console.log(originalData.firstLayerNum);
// 10 => 第一層沒有被 clonedData 影響
console.log(originalData.obj.secondLayerNum);
// 200 => 第二層被 clonedData 影響而改變

四、Spread operator

/*** 淺拷貝:Spread operator ***/
const originalData = {
  firstLayerNum: 10,
  obj: {
    secondLayerNum: 100,
  },
};
const clonedData = { ...originalData };

clonedData.firstLayerNum = 20;
clonedData.obj.secondLayerNum = 200;

console.log(originalData.firstLayerNum);
// 10 => 第一層沒有被 clonedData 影響
console.log(originalData.obj.secondLayerNum);
// 200 => 第二層被 clonedData 影響而改變

五、部分 Array 方法,如:slice()、from() 等

/*** 淺拷貝:Array.prototype.slice() ***/
const originalData = [10, { secondLayerNum: 100 }];
const clonedData = originalData.slice();

clonedData[0] = 20;
clonedData[1].secondLayerNum = 200;

console.log(originalData[0]);
// 10 => 第一層沒有被 clonedData 影響
console.log(originalData[1].secondLayerNum);
// 200 => 第二層被 clonedData 影響而改變

深拷貝的原理與實作

看完淺拷貝,緊接著繼續了解深拷貝。

Original Object dataCloned Object data是兩個完全獨立,每一層的資料地址都不同,相互不影響的深層物件,就為深拷貝(deep copy)

deep copy p.s 概念示意圖,地址並非直接存在 obj 中,而是 obj 變數會對應一個地址

有哪些方式可以達成深拷貝呢?

一、JSON.stringify/parse

JSON.stringify/parse 常見於處理 Local Storge、Session Storage 等 Storage 的儲存操作,其實也可以用來實踐深拷貝。

主要是用 JSON.stringify 先把物件轉字串,再用 JSON.parse 把字串轉物件即可。

/*** 深拷貝:JSON.stringify/parse ***/
const originalData = {
  firstLayerNum: 10,
  obj: {
    secondLayerNum: 100,
  },
};
const clonedData = JSON.parse(JSON.stringify(originalData));

clonedData.firstLayerNum = 20;
clonedData.obj.secondLayerNum = 200;

console.log(originalData.firstLayerNum);
// 10 => 第一層「沒有」被 clonedData 影響
console.log(originalData.obj.secondLayerNum);
// 100 => 第二層「沒有」被 clonedData 影響

但需要特別注意有些值經過 JSON.stringify/parse 處理後,會產生變化,導致非預期的結果發生:

  • undefined : 會連同 key 一起消失
  • NaN : 會被轉成 null
  • Infinity :會被轉成 null
  • regExp : 會被轉乘 空 {}
  • Date : 型別會由 Data 轉成 string
const originalData = {
  undefined: undefined, // undefined values will be completely lost, including the key containing the undefined value
  notANumber: NaN, // will be forced to null
  infinity: Infinity, // will be forced to null
  regExp: /.*/, // will be forced to an empty object {}
  date: new Date('1999-12-31T23:59:59'), // Date will get stringified
};
const faultyClonedData = JSON.parse(JSON.stringify(originalData));

console.log(faultyClonedData.undefined); // undefined
console.log(faultyClonedData.notANumber); // null
console.log(faultyClonedData.infinity); // null
console.log(faultyClonedData.regExp); // {}
console.log(faultyClonedData.date); // "1999-12-31T15:59:59.000Z"

二、Lodash cloneDeep()

Loadash library 本身有提供深拷貝 cloneDeep() 的方法,且不會遇到 JSON.stringify/parse 部分值會非預期改變的問題。

/*** 深拷貝:Lodash cloneDeep() ***/
import { clone, cloneDeep } from 'lodash';

const nestedArr = [['1'], ['2'], ['3']];

const shallowCopyWithLodash = clone(nestedArr); // 實踐「淺」拷貝
console.log(nestedArr[0] === shallowCopyWithLodash[0]);
// true => Shallow copy (same reference address)

const deepCopyWithLodash = cloneDeep(nestedArr); // 實踐「深」拷貝
console.log(nestedArr[0] === deepCopyWithLodash[0]);
// false => Deep copy (different reference address)

三、Recursive deepCopyFunction

可自建簡單的「遞迴函式」,遍歷 Object data 每一層級資料,將其完全複製新的一份。

/*** 深拷貝:Recursive deepCopyFunction ***/
function deepCopyFunction(inputObject) {
  // Return the value if inputObject is not an Object data
  // Need to notice typeof null is 'object'
  if (typeof inputObject !== 'object' || inputObject === null) {
    return inputObject;
  }

  // Create an array or object to hold the values
  const outputObject = Array.isArray(inputObject) ? [] : {};

  // Recursively deep copy for nested objects, including arrays
  for (let key in inputObject) {
    const value = inputObject[key];
    outputObject[key] = deepCopyFunction(value);
  }

  return outputObject;
}

const originalData = {
  firstLayerNum: 10,
  obj: {
    secondLayerNum: 100,
  },
};
const clonedData = deepCopyFunction(originalData);

clonedData.firstLayerNum = 20;
clonedData.obj.secondLayerNum = 200;

console.log(originalData.firstLayerNum);
// 10 => 第一層「沒有」被 clonedData 影響
console.log(originalData.obj.secondLayerNum);
// 100 => 第二層「沒有」被 clonedData 影響

這是簡單的做法,然而要深入的製作 deep copy function 還有很多可以調整與探討的,可參考這篇:如何写出一个惊艳面试官的深拷贝?


淺深拷貝總結

用一張圖簡易地總結淺深拷貝的概念:

shallow copy and deep copy

  • 淺拷貝(shallow copy) : 原始物件資料與複製物件資料「並非完全獨立」,可能第一層就有指向相同地址的資料,也可能第二層才有指向相同地址的資料。彼此資料內容的改變可能會互相影響。
  • 深拷貝(deep copy) : 原始物件資料與複製物件資料「完全獨立」,沒有任何一層資料指向相同的地址。彼此資內容料的改變不會互相影響。

從上篇談 pass by valuepass by reference、pass by sharing 開始,到這篇談 shallow copydeep copy,都可以發現其實最重要的觀念在於理解「 Primitive type dataObject data 複製的過程 」,如果腦中有他們複製時變數表運作的過程,那理解起來就相對容易,而且也不用特別死背囉。

如果還有些困惑,建議和前一篇一起再讀一讀,會更有機會理解整個運作機制囉。


後記,在開發中遇到的 Bug

最後補充紀錄實際在開發時,有看到的 Bug,也是因此才會想整理整套 Object data 複製的過程和淺深拷貝。

這個 Bug 是在 Reactcustom hook 遇到的,已將其簡化很多,僅放上關鍵之處:

const initialOrderData = {
  totalPayment: 0, // 這是 Primitive type data,複製時是複製值
  orderList: [], // 這是 Object data,複製時是複製地址,要特別注意
};

const useOrderDataHandler = () => {
  const [orderData, setOrderData] = useState(initialOrderData);

  const addOrder = newOrder => {
    setOrderData(prev => {
      const newTotalPayment = prev.totalPayment + newOrder.DiscountedTotalPrice;
      // 這邊的操作會造成下面 resetOrderData 的 bug
      const newOrderList = prev.orderList;
      newOrderList.unshift(newOrder);

      return {
        totalPayment: newTotalPayment,
        orderList: newOrderList,
      };
    });
  };

  const resetOrderData = () => {
    setOrderData(initialOrderData);
    // 這邊會有 Bug!!!
    // 因為 initialOrderData 中的 orderList
    // 已經被 addOrder 的 newOrderList.unshift(newOrder) 操作給改變了
    // 導致 orderList 無法被 reset 回  []
  };
  ......

  return {
    addOrder,
    resetOrderData,
    ......
  };
};

上面這段 code 最需要注意的是 initialOrderData 中,有 orderList: [] 的物件型別資料,因此 addOrder 中這段會有問題:

const addOrder = newOrder => {
  setOrderData(prev => {
    const newTotalPayment = prev.totalPayment + newOrder.DiscountedTotalPrice;
    // 這層複製,讓 newOrderList 與 initialOrderData.orderList 有同樣的地址
    const newOrderList = prev.orderList;
    // 因此對 newOrderList 的內容改動,會影響到 initialOrderData.orderList
    // 導致 initialOrderData 被非預期地改變
    newOrderList.unshift(newOrder);

    return {
      totalPayment: newTotalPayment,
      orderList: newOrderList,
    };
  });
};

由於確定 orderList 會是「只有一層」的結構,不會有第二層的物件型別資料,因此可以透過「確保第一層不會互相影響」的淺拷貝方法修改即可:

const addOrder = newOrder => {
  setOrderData(prev => {
    const newTotalPayment = prev.totalPayment + newOrder.DiscountedTotalPrice;
    //第一種:透過 Spread operator 淺拷貝,讓第一層的資料改變不會互相影響
    const newOrderList = [newOrder, ...prev.orderList];

    //第二種:透過 concat() 淺拷貝,讓第一層的資料改變不會互相影響
    const newOrderList = [newOrder].concat(prev.orderList);

    return {
      totalPayment: newTotalPayment,
      orderList: newOrderList,
    };
  });
};

也可以在一開始就完全地複製一份 initialOrderData,但必須注意 initialOrderData 中的 orderList 是第二層的深層物件型別資料,因此要用深拷貝:

const initialOrderData = {
  totalPayment: 0,
  orderList: [],
};

const useOrderDataHandler = () => {
  // 深拷貝,確保 initialOrderData 中的 orderList 被完全複製
  // 不會與原始來源互相影響
  const clonedInitialOrderData = JSON.parse(JSON.stringify(initialOrderData))
  const [orderData, setOrderData] = useState(clonedInitialOrderData);
 ......
}

以上就是與 Object data 拷貝有關的 Bug 紀錄和分享,藉此未來謹記之。


參考資料

特別感謝

  • 感謝 zacharyptt 在這則 issue 中,提醒我圖片中的 shadow copy 應為 shallow copy。

如果分享的內容對你有幫助,歡迎 點此小額贊助請我喝杯咖啡 😌 ; 如果發現部落格文章內容有誤,或有想進一步討論的內容,歡迎點此前往開 Issues 討論,感謝!