avatar
threadsinstagram

面試中如何更深入完整地回答 JavaScript 深拷貝這題

Table of Contents

題目描述

算是蠻常出現的經典面試題,有時候會簡單口頭提問「如何用 JavaScript 做拷貝」或「請實作 lodash 的 cloneDeep」,不管哪一種問法都可能會需要了解 JavaScript 中的深淺拷貝的觀念,這邊也簡單整理一下筆記。

淺拷貝 vs 深拷貝

JavaScript 中的資料型別主要分成 primitive valueobject 兩種,而在複製物件型別的資料時,又會有淺拷貝 (shallow copy) 和深拷貝 (deep copy) 的差異,簡單紀一下兩者差異:

  • 淺拷貝:只複製該物件的第一層屬性,而深層物件 (nested object 或 array 等) 仍會參考到原本對應的記憶體位置
  • 深拷貝:物件的每層都會被深層複製,具有不同的記憶體位置

淺拷貝的方式

在了解如何實作深拷貝前,先知道哪些常見的方式其實都只是淺拷貝,若資料較單純只有一層可直接使用:

深拷貝的方式

深拷貝的解法有以下幾種:

  1. JSON.parse(JSON.stringify(obj))
  2. structuredClone(obj):最推薦的方式
  3. 自己手寫物件深層遞迴,搭一些型別處理
  4. lodash.cloneDeep(obj),背後也是對物件深層遞迴,並加上各種型別處理 (source code),自己要寫一套常會無法考慮到各種型別的邊界條件。但使用套件的缺點會讓 bundle size 變大 (ref)。

JSON.parse(JSON.stringify(obj))

  • 能複製可序列化的物件型別
  • 無法被序列化或會導致問題的值或物件:
    • 會直接被移除的屬性: undefined、Function、Symbol、DOM 節點(HTMLElement、document 等)等
    • 會被變成空物件:Set、Map、RegExp、Error 等
    • 會噴錯的:循環引用物件、BigInt
    • Date 物件會被序列化為 ISO 8601 格式的字串(如 2024-12-09T00:00:00.000Z)。

structuredClone(obj)

  • 特色
    • 能正確處理多種資料格式的複製:Date, Set, Map, Error, RegExp, ArrayBuffer, Blob, File, ImageData(ref)
    • 可處理循環引用資料 (circular references)
  • 支援度
    • 2022 年支援的 Web API (MDN)
    • 瀏覽器支援度目前較新版的主流瀏覽器都已支援 (ref)
    • 若需要支援低版本的瀏覽器(如 Safari 15.3 以下)需用 polyfill
  • 仍有一些不支援的型別如 Functions、DOM nodes、Setters、Getters、Object Prototypes 等 (完整類型可參考這篇)
  • 這篇文章的實驗分析看起來, structuredClone(obj) 的效能會比 JSON.parse(JSON.stringify(obj)) 略差,但也是很合理的,畢竟多處理了更多類型的物件。

請實作 JavaScript 的物件複製

回到正題,如果面試時被問到「如何處理 JavaScript 物件複製」時,用前面的框架可以怎麼與對方互動呢?

分析與思路

問題釐清

  • 輸入的物件資料是否為巢狀結構 (是否需要處理深拷貝)?
  • 列舉處理深拷貝可以用的方法,確認是否需要實作手寫版本的 cloneDeep 或只要簡單用 structuredClone 就滿足期待?
  • 輸入的物件資料是否包含不可序列化的資料型別 (如 undefined, Function, Symbol 等等)
  • 輸入的物件資料是否可能會有循環引用
  • 是否需要保留原物件的原型鏈 (prototype chain)

提出測試案例

  • 檢查複製出來的的每個屬性值是否相等 (deep equal) 以及位址是否不同
    • 單純的一層物件
    • 巢狀的物件 (但不含特殊型別)
    • 巢狀的物件 (含特殊型別)
    • 包含 undefined, Function, Symbol 等等不可序列化的資料型別
    • 循環引用物件不會噴錯

提出思路

如果需要手寫版本的 cloneDeep 的話,主要的實作邏輯會需要依照各種傳入的資料型別回傳不同的值:

  • 如果為基本型別則回傳當前的值
  • 處理特殊型別判斷,像是 Date, RegExp, Function
  • 若為物件與陣列
    • 對物件的 key 跑 for loop,並讓每個值做遞迴

用註解簡單列出以上的實作思路:

const cloneDeepWithRecursion = <T>(item: T) => {
  // handle primitive value and null, return the current value
  // handle special objects such as Date, RegExp, Function
  // handle array and object
  // run a for loop with input object
  // recursively run with each value
  // return cloned object
};

實作

把上面用註解寫的依序完成會像下面這個樣子,可以看到大方向就是依照各種型別處理並回傳值,遇到物件與陣列時做遞迴:

const cloneDeepWithRecursion = <T>(item: T) => {
  // handle primitive value and null
  if (item === null || typeof item !== 'object') {
    return item;
  }
 
  // handle special objects such as Date, RegExp, Function
  if (item instanceof Date) {
    return new Date(item);
  }
 
  if (item instanceof RegExp) {
    return new RegExp(item);
  }
 
  if (typeof item === 'function') {
    return item;
  }
 
  // handle array and object
  const result = (Array.isArray(item) ? [] : {}) as T;
 
  // run a for loop with input object
  for (const key of Object.keys(item)) {
    const value = item[key];
 
    // recursively run with each value
    result[key] = cloneDeepWithRecursion(value);
  }
  // return cloned object
  return result;
};

這裡在實作時因為是使用 TypeScript 的關係遇到一些型別的問題,在 value = item[key] 這段會有以下錯誤提示:

Element implicitly has an 'any' type because expression of type 'string' can't be used to index type '{}'.
  No index signature with a parameter of type 'string' was found on type '{}'.

這個錯誤的原因,研究後發現是因為當今天 item 含有更廣義 object 資料時,這個 key 可能不一定是 string,因此在 TS 的型別推斷後覺得 item[key] 可能是不合法的操作。

而要解決這個錯誤可以用型別斷言:

// run a for loop with input object
for (const key of Object.keys(item)) {
  const value = item[key as keyof typeof item];
 
  // recursively run with each value
  result[key as keyof T] = cloneDeepWithRecursion(value);
}

實作測試

先測試一下幾個測試案例:

import { describe, expect, it } from 'vitest';
import { cloneDeepWithRecursion as cloneDeep } from './cloneDeep';
 
describe('cloneDeep', () => {
  it('should deeply clone a simple object', () => {
    const obj = {
      id: 'codefarmer.tw',
      payload: {
        name: 'Code Farmer',
        age: 18,
        books: ['JavaScript', 'TypeScript'],
      },
    };
    const newObj = cloneDeep(obj);
 
    expect(newObj).not.toBe(obj);
    expect(newObj).toEqual(obj);
  });
 
  it('should deeply clone a object with comprehensive properties', () => {
    const obj = {
      string: 'string',
      number: 123,
      bool: false,
      date: new Date(),
      infinity: Infinity,
      regexp: /abc/,
      nullValue: null,
      undefinedValue: undefined,
      nanValue: NaN,
    };
    const newObj = cloneDeep(obj);
 
    expect(newObj).not.toBe(obj);
    expect(newObj).toEqual(obj);
  });
 
  it('should deeply clone a object with function', () => {
    const obj = {
      fn: function () {
        return 'fn';
      },
    };
    const newObj = cloneDeep(obj);
 
    expect(newObj).not.toBe(obj);
    expect(newObj).toEqual(obj);
  });
 
  it('should deeply clone a object with Symbol', () => {
    const obj = {
      symbol: Symbol('symbol'),
    };
    const newObj = cloneDeep(obj);
 
    expect(newObj).not.toBe(obj);
    expect(newObj).toEqual(obj);
  });
 
  it('should deeply clone a object with circular reference', () => {
    const obj: any = { a: 1 };
    obj.b = obj;
 
    const newObj = cloneDeep(obj);
 
    expect(newObj).not.toBe(obj);
    expect(newObj).toEqual(obj);
  });
});

跑完測試後會發現只有最後一個循環引用還沒辦法通過,接下來會繼續處理這部分。

做循環引用的處理

要做循環引用的處理,要稍微改寫一個函式的結構,這裡多傳一個 WeakMap 來做紀錄:

const cloneDeepWithRecursion = <T>(item: T, cache = new WeakMap()): T => {
  // handle primitive value and null
  if (item === null || typeof item !== 'object') {
    return item;
  }
 
  // 如果 cache 中已經有處理過這個物件則略過
  if (cache.has(item)) {
    return cache.get(item);
  }
 
  // ... 略 ...
 
  // 將目前處理的物件記錄到快取中
  cache.set(item, result);
 
  // run a for loop with input object
  for (const key of Object.keys(item)) {
    const value = item[key as keyof typeof item];
 
    // 記得將目前 cache 傳入遞迴呼叫中,避免循環引用問題
    result[key as keyof T] = cloneDeepWithRecursion(value, cache);
  }
 
  return result;
};

進階:保留原物件的原型鏈

再進階一些,如果在前面的問題中,被追問需要保留物件的原型鏈,該怎麼處理?

先快速補上一個單元測試確認目前的版本確實有原型鏈問題:

it('should deeply clone an object with prototype chain', () => {
  // Define a custom prototype
  function CustomType(this: any) {
    this.name = 'CustomType';
  }
  CustomType.prototype.greet = function () {
    return `Hello, ${this.name}`;
  };
 
  const obj = new (CustomType as any)();
  obj.age = 18;
 
  const newObj = cloneDeep(obj);
 
  // Ensure the new object is not the same as the original
  expect(newObj).not.toBe(obj);
  expect(newObj).toEqual(obj);
 
  // Ensure the prototype chain is preserved
  expect(Object.getPrototypeOf(newObj)).toBe(CustomType.prototype);
  expect(newObj.greet()).toBe('Hello, CustomType');
});

這裡把在建立物件的地方從 {} 改成 Object.create(Object.getPrototypeOf(item)) 就可以完成此需求了:

// handle array and object
const result = (
  Array.isArray(item) ? [] : Object.create(Object.getPrototypeOf(item))
) as T;

主要就是使用這兩個 method 來確保原本傳入的物件的原型鏈有被保存起來。

References