iT邦幫忙

2024 iThome 鐵人賽

DAY 18
0
佛心分享-刷題不只是刷題

30 天克服前端面試系列 第 18

Day 18 - 深複製 deep copy 是什麼? 如何實踐?

  • 分享至 

  • xImage
  •  

Deep clone 又稱深複製,相對淺複製僅是將物件的第一層複製,深複製則是將物件的所有層級都複製一份,深複製當遇到巢狀物件或是陣列時,就會進行深層的遍歷,將每一次層的值都進行複製,如此一來複製出來的物件當被修改時就不會影響到原來的物件。

Deep clone 的方法

JSON.parse(JSON.stringify(...))

const memberInfo = {
  id: 120,
  name: "Andy",
  isVip: false,
  birthday: "1996/01/12",
  hobbies: ["Photography", "Cooking", "Painting"],
};

//先將 memberInfo 物件轉換成 JSON 字串,再將 JSON 字串轉換成物件
function deepCopy(item) {
  return JSON.parse(JSON.stringify(item));
}

const deepCopyByJSON = deepCopy(memberInfo);

console.log("deepCopyByJSON === memberInfo", deepCopyByJSON === memberInfo); //false
console.log(
  "deepCopyByJSON.hobbies === memberInfo.hobbies",
  deepCopyByJSON.hobbies === memberInfo.hobbies,
); //false

但是使用 JSON.parse(JSON.stringify(...)) 進行深複製時,如果物件內的屬性有不可以序列化的值,就會導致深複製失敗。ex: undefinedfunctionsymbolBigIntDateRegExpErrorMapSet

const memberInfo = {
  id: 120,
  name: "Andy",
  isVip: false,
  birthday: "1996/01/12",
  hobbies: ["Photography", "Cooking", "Painting"],
  getMoreInfo: function getMoreInfo() {
    return null;
  },
  createdTime: new Date("2024-06-10"),
};

function deepCopy(item) {
  return JSON.parse(JSON.stringify(item));
}

const deepCopyByJSON = deepCopy(memberInfo);


deepCopyByJSON.createdTimememberInfo.createdTime的值就不同了,因為Date物件無法被序列化。


deepCopyByJSON.getMoreInfo的值為 undefined,因為 function 也無法被序列化。

structuredClone(value)

const memberInfo = {
  id: 120,
  name: "Andy",
  isVip: false,
  birthday: "1996/01/12",
  hobbies: ["Photography", "Cooking", "Painting"],
};
//使用 structuredClone 深複製物件 memberInfo
const deepCopyByStructuredClone = structuredClone(memberInfo);

console.log(
  "deepCopyByStructuredClone.hobbies === memberInfo.hobbies",
  deepCopyByStructuredClone.hobbies === memberInfo.hobbies
);//false

`deepCopyByStructuredClone.hobbies` 跟 `memberInfo.hobbies` 的 reference 不同

但是同樣的 structuredClone 也無法處理 functionDateRegExpErrorMapSet 等不可序列化的值。

const memberInfo = {
  id: 120,
  name: "Andy",
  isVip: false,
  birthday: "1996/01/12",
  hobbies: ["Photography", "Cooking", "Painting"],
  getMoreInfo: function getMoreInfo() {
    return null;
  },
  createdTime: new Date("2024-06-10"),
};

const deepCopyByStructuredClone = structuredClone(memberInfo);


當物件內有不可序列化的值時,直接使用structuredClone 就會直接報錯。

手寫遞迴函式

//map(用於存儲已複製物件的 WeakMap),在 WeakMap 中,鍵必須是物件。

function cloneDeep(obj, map = new WeakMap()) {
  //如果 map 中已經有 obj 的複製,則直接返回該複製。這可以防止循環參照導致的無窮遞歸。
  if (map.has(obj)) {
    return map.get(obj);
  }

  //首先排除非物件類型的,檢查傳入的 obj 是否為 null 或是原始型別,這是因為這些類型的值在 JavaScript 中是按值傳遞的,所以不需要複製。
  if (obj === null || typeof obj !== "object" || typeof value === "function") {
    return obj;
  }

  //如果 obj 是 Date 的實例,則創建一個新的相同的 Date 實例並返回。
  if (obj instanceof Date) return new Date(obj);

  //如果 obj 是 RegExp 的實例,則創建一個新的相同的 RegExp 實例並返回。
  if (obj instanceof RegExp) return new RegExp(obj);

  //函式輸出 output 值,如果 obj 是陣列,則輸出為空陣列,如果 obj 是一個普通物件,則 output 的原型會被設置為 obj 的原型,以保留原型鏈。

  const output = Array.isArray(obj)
    ? []
    : Object.create(Object.getPrototypeOf(obj));

  //將 obj 和 output 的對應關係存入 map
  map.set(obj, output);

  //使用 Reflect.ownKeys(obj) 獲取 obj 的所有自有屬性鍵(包括符號和不可枚舉的屬性)
  //遍歷所有的鍵,對每個鍵對應的值進行深度複製,並將複製的結果存入 output。
  for (const key of Reflect.ownKeys(obj)) {
    const val = obj[key];
    //對當前鍵對應的值進行深度複製,並將複製的結果存入 output。
    output[key] = cloneDeep(val, map);
  }
  return output;
}

const memberInfo = {
  id: 120,
  name: "Andy",
  isVip: false,
  birthday: "1996/01/12",
  hobbies: ["Photography", "Cooking", "Painting"],
  getMoreInfo: function getMoreInfo() {
    return null;
  },
  createdTime: new Date("2024-06-10"),
};

const cloneDeepMemberInfo = cloneDeep(memberInfo);

console.log("cloneDeepMemberInfo.getMoreInfo", cloneDeepMemberInfo.getMoreInfo);
console.log("memberInfo.getMoreInfo", memberInfo.getMoreInfo);

console.log("cloneDeepMemberInfo.createdTime", cloneDeepMemberInfo.createdTime);
console.log("memberInfo.createdTime", memberInfo.createdTime);


為什麼以 deep clone 來進行物件或陣列資料的 immutable update 不是一個好方法?

簡單來說,使用 deep clone 操作物件資料會使 React 效能優化機制失效。

這裡就舉一個例子來進行說明

Edit immutable update shallow / deep clone (forked)

React.memo() 是一個效能優化的方法,它將 component 包裹起來,每當有 props 傳入 component 時會使用 Object.is() 檢查這次的 props 與前次的 props 是否相同,當 component 的 props 沒有變動時,就不會重新 render,這樣可以避免不必要的 re-render,提升效能。

在這個例子中,App component 中有一個被 memo() 包裹住的 Child component。

使用 deep clone 進行 state 更新

import { memo, useState } from "react";

function Child({ fooObj }) {
  console.log("render Child");
  return <h1>child: {fooObj.b}</h1>;
}

const MemoizedChild = memo(Child);

export default function App() {
  console.log("render App");
  const [data, setData] = useState({
    count: 0,
    foo: { b: 100 },
  });

  const updateCountWithDeepClone = () => {
    // 使用 deep clone 複製 data 物件並且賦值給 newData
    const newData = structuredClone(data);
    // 更新 newData 的 count 屬性
    newData.count += 1;
    // 更新 data 的 state
    setData(newData);
  };

  return (
    <div>
      <h2>count: {data.count} </h2>
      <button onClick={updateCountWithDeepClone}>
        Update Count With Deep Clone
      </button>
      <MemoizedChild fooObj={data.foo} />
    </div>
  );
}

當 component 第一次 mounted 掛載至瀏覽器後:

  1. 點擊 Update Count With Deep Clone button

  2. 會呼叫 updateCountWithDeepClone function

  3. updateCountWithDeepClone function 中會使用 structuredClone 進行 deep clone data 物件並且賦值給 newData

  4. 更新 newData 的 count 屬性

  5. 更新 data 的 state

  6. React 會執行 Object.is() 檢查發現 state 更新了

  7. 進入 component reconciliation 階段,執行 App function 產生以 props 和 state 描述 component 畫面的 react element,這時就會印出 render App

  8. 將新版本所產生的 react element 與上一次 render 的舊版 react element 進行樹狀結構的比較,找出差異就是 state,然後更新 <h2>count: {data.count} </h2>

  9. 當執行到 <MemoizedChild fooObj={data.foo} /> 時,雖然 data.foo 的值看似沒有改變,但是 React 發現 props 有變,當 App Component render 完成後,就會執行 memoized Child component function,這時會印出 render Child

這是因為使用了 deep Clone 更新了 state 資料,又 deep Clone 所將物件中每一層的每一個屬性都經過遍歷複製,即使沒發生更新的內層資料也會產生全新的參考,因此,此時當使用 object.is() 檢查傳入 <MemoizedChild fooObj={data.foo} /> 的 props 時,就會發現 props 有變動,所以會重新 re-render,進而印出 render Child

從上述可以知道,對於 React 來說,使用 deep clone 進行 state 更新會使得某些 React 效能優化的手段失效,導致每一次 state 的更新都是全新的物件、全新的參考,即使該屬性的值沒有變更,而使得效能優化機制失去參考相等性。

使用 shallow clone 進行 state 更新

import { memo, useState } from "react";

function Child({ fooObj }) {
  console.log("render Child");
  return <h1>child: {fooObj.b}</h1>;
}

const MemoizedChild = memo(Child);

export default function App() {
  console.log("render App");
  const [data, setData] = useState({
    count: 0,
    foo: { b: 100 },
  });

  const updateCountWithShallowClone = () => {
    setData({
      ...data,
      count: data.count + 1,
    });
  };

  return (
    <div>
      <h2>count: {data.count} </h2>
      <button onClick={updateCountWithShallowClone}>
        Update Count With Shallow Clone
      </button>
      <MemoizedChild fooObj={data.foo} />
    </div>
  );
}

當 component 第一次 mounted 掛載至瀏覽器後:

  1. 點擊 Update Count With Shallow Clone button

  2. 會呼叫 updateCountWithShallowClone function

  3. updateCountWithShallowClone function 中會展開運算符淺複製 data 物件,然後更新 count 屬性,最後 setState 更新 data 的 state。

  4. 更新 count 屬性

  5. 最後 setState 更新 data 的 state

  6. React 會執行 Object.is() 檢查發現 state 更新了

  7. 進入 component reconciliation 階段,執行 App function 產生以 props 和 state 描述 component 畫面的 react element,這時就會印出 render App

  8. 將新版本所產生的 react element 與上一次 render 的舊版 react element 進行樹狀結構的比較,找出差異就是 state,然後更新 <h2>count: {data.count} </h2>

最後的執行結果就是傳入 <MemoizedChild fooObj={data.foo} /> 的 props 並沒有變動,所以不會重新 re-render,也就不會印出 render Child,只有 App component 會重新 render。

對於 React 來說資料比較機制就是為了減少重複產生 react element 的次數,React 不需要知道實際的資料細節,檢查原始型別時直接互比值、檢查物件時只需要比對參考是否相同,當物件資料的參考相同,React 就會當作資料沒變,不管物件內容如何改變,React 都不會重新產生新的 react element 來 render,因此更新 state 時不應該 mutate 原始資料,而是應該產生新的物件這樣就會產生一個新的 Reference,透過 shallow clone 複製原始物件的屬性到新的物件,只要根據更新的部分去修改相對應的值,這樣既不會 mutate 到原始資料,維持物件 immutable,也可以提供 React 效能優化機制參考的相等性。


本文同步於此


上一篇
Day 17 - 淺複製 shallow copy 是什麼? 如何實踐?
下一篇
Day 19 - 為什麼實作 CSS 動畫位移效果使用 translate() 比 absolute 絕對定位更好?
系列文
30 天克服前端面試25
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言