JS 從零手寫一個深拷貝(進階篇)


壹 ❀ 引

深拷貝與淺拷貝的區別,實現深拷貝的幾種方法一文中,我們闡述了深淺拷貝的概念與區別,普及了部分具有迷惑性的淺拷貝api。當然,我們也實現了乞丐版的深拷貝方法,能解決部分拷貝場景,雖然它仍有很多缺陷。那么這一篇文章我們將從零手寫一個強大的深拷貝方法,在方法逐漸升級的過程中,我們也能親身感受深拷貝中需要考慮的邊界問題,那么本文開始。

貳 ❀ 從零手寫深拷貝

貳 ❀ 壹 從基礎對象復制開始

在上文中,我們已經得知深拷貝主要還是考慮引用數據類型,假定現在有一個deepClone方法,假設我們傳遞的拷貝參數是基礎類型,我們完全可以原封不動返回即可。而引用數據類型類型繁多,可以猜到這個方法要做的事應該集中在類型判斷,以及針對不同對象類型如何去復制。

先不說深拷貝,我們由淺至深,如何復制一個{}呢?你可能毫不猶豫能寫出如下代碼:

const deepClone = (obj) => {
  // 創建一個新對象
  const obj_ = {};
  for (let i in obj) {
    // 按key依次拷貝
    obj_[i] = obj[i];
  };
  return obj_;
};

我們可以使用for...in遍歷對象的所有key,從而達到復制所有屬性的目的。但這個實現只能滿足復制value是基本數據類型的例子:

const obj = {
  name: '聽風',
  age: 29
}

一旦屬性的value有引用數據類型,上述方法就只能達到淺拷貝的作用了,比如:

const obj = {
  name: '聽風',
  age: 29,
  other: {
    gender: 'male'
  }
};
const o = deepClone(obj);
// 修改原對象引用數據類型的值
obj.other.gender = null;
// 可以看到簡單的復制沒辦法解決深拷貝的問題
console.log(obj, o)

貳 ❀ 貳 增加對象判斷與遞歸深度復制

前文我們已經說過了,假設拷貝傳遞的是基本類型的值,我們只需原封不動返回即可;其次,考慮到對象的某個值可能還是對象,比如上面的other,我們繼續遍歷{gender: 'male'}復制,這樣依次把gender屬性拿過來,就不可能有引用的問題了,因此這里我們將方法升級:

const deepClone = (obj) => {
  // 是對象嗎?是就新建對象開始復制
  if (typeof obj === 'object') {
    const obj_ = {};
    for (let i in obj) {
      // 不管是不是對象,直接遞歸,外面的typeof會幫我們做判斷是否要繼續遍歷
      obj_[i] = deepClone(obj[i]);
    };
    return obj_;
    // 不是對象?直接返回
  } else {
    return obj;
  };
};

有同學第一眼看上去可能就比較奇怪,為什么obj_[i] = deepClone(obj[i]);這一句不繼續加一個是否是對象的判斷?不是對象沒必要遞歸啊。其實遞歸的訣竅就是,想好當前要做什么以及什么時候跳出遞歸,之后遞歸會重復幫你做好你預期的操作。

比如當遇到name: '聽風'這個屬性后,再次調用deepClone后因為typeof的判斷不是對象,會直接原封不動返回,所以並不會有什么大的性能浪費。

貳 ❀ 叄 兼容數組類型

除了{}類型,數組也是我們打交道非常多的引用類型,很明顯上述代碼並不滿足數組的拷貝,我們需要對obj的類型進一步細化,如下:

const deepClone = (obj) => {
  // 是對象嗎?是就新建對象開始復制
  if (typeof obj === 'object') {
    // 是對象,我們進一步確定是數組還是{}
    const obj_ = Array.isArray(obj) ? [] : {};
    for (let i in obj) {
      // 不管是不是對象,直接遞歸,外面的typeof會幫我們做判斷是否要繼續遍歷
      obj_[i] = deepClone(obj[i]);
    };
    return obj_;
    // 不是對象?直接返回
  } else {
    return obj;
  };
};

現在我們再來測試對象值是對象以及數組的情況,可以看到此時已經滿足了預期。

const obj = {
  name: '聽風',
  age: 29,
  other: {
    gender: 'male',
    arr: [1, 2, 3]
  }
};
const o = deepClone(obj);
obj.other.gender = null;
obj.other.arr[0] = 1;
console.log(obj, o)

貳 ❀ 肆 解決typeof類型判斷誤差

上述的實現我們都依賴了typeof來判斷參數是不是一個對象,如果不是對象哪來的for...in呢?但typeof一直有一個javascript遺留bug,那就是typeof null的類型是object,所以如果參數傳遞null,我們的深拷貝方法會返回一個{}而不是null,這里得優化下。

這里我查閱了typeof MDN,具體類型如下:

類型 結果
Undefined "undefined"
Null "Object"
Boolean "boolean"
Number "Number"
String "String"
Symbol "Symbol"
Bigint "bigint"
Function "Function"
其它任何對象 "object"

我們總結下,只要參數不是null,且類型結果不是objectfunction,那說明這個參數一定不是對象類型,我們來定義一個更精准的對象類型判斷方法isObject,同時優化之前的代碼:

const isObject = (obj) => {
  const type = typeof obj;
  return obj !== null && (type === 'object' || type === 'function');
};

const deepClone = (obj) => {
  // 如果不是對象直接返回
  if (!isObject(obj)) {
    return obj;
  };
  // 是對象,我們進一步確定是數組還是{}
  const obj_ = Array.isArray(obj) ? [] : {};
  for (let i in obj) {
    // 不管是不是對象,直接遞歸,外面的typeof會幫我們做判斷是否要繼續遍歷
    obj_[i] = deepClone(obj[i]);
  };
  return obj_;
};

貳 ❀ 伍 解決對象循環引用

雖然不常見,但對象其實可以將自己設置成自己的屬性,比如:

const obj = {
  name: '聽風',
  age: 29,
  other: {
    gender: 'male',
    arr: [1, 2, 3]
  }
};
obj.obj = obj;

我們現在來使用自己定義的深拷貝方法拷貝上述對象,你會發現直接爆棧:

為什么?我們來打印一下obj的結構就清楚了:

key遇到obj時,因為是對象類型,繼續遞歸,結果發現這個key可以無限遞歸下去,直接爆棧。

那怎么解決呢?我們可以想想被拷貝的原對象是怎么誕生的,將對象的key指向自己即可。也就是在拷貝時,我們只要保證執行一次obj['obj'] = obj即可,只要讓自己指向自己,這個循環引用自然就會誕生,並不需要我們無限遞歸來模擬這個循環引用。

怎么跳出這個遞歸呢?設想下,obj在第一次傳入后,開始第一次遞歸,然后把自己又作為參數傳了下去,后續做的事情完全是相同的,那我們是不是可以記錄我們要拷貝的obj以及它拷貝的后的結果,當下次遇到相同的obj跳出遞歸,直接返回之前的結果就好了。

考慮到我們要記錄的參數可能是對象類型,使用普通的對象肯定不行,而es6新增的Map數據類型key就可以使用對象:

const deepClone = (obj, map = new Map()) => {
  // 如果不是對象直接返回
  if (!isObject(obj)) {
    return obj;
  };
  // 之前有拷貝過嗎?
  if (map.has(obj)) {
    return map.get(obj);
  };
  // 是對象,我們進一步確定是數組還是{}
  const obj_ = Array.isArray(obj) ? [] : {};
  // 存儲當前拷貝的對象,以及我們要返回的對象
  map.set(obj, obj_);
  for (let i in obj) {
    // 不管是不是對象,直接遞歸,外面的typeof會幫我們做判斷是否要繼續遍歷
    obj_[i] = deepClone(obj[i], map);
  };
  return obj_;
};

此時再拷貝上述的循環引用的對象,你會發現爆棧的問題已經得到解決,我們成功拷貝了一個循環引用的問題。

貳 ❀ 陸 兼容其它可遍歷引用數據類型

雖然上文我們做了isObject的判斷,但事實上我們也只做了{}[]兩種數據的拷貝,像正則,日期以及new Number(1)這種對象其實都未兼顧。

說直白點就是,isObject只是告訴了我們這個參數是不是對象,是對象后我們得進一步細化,看看它到底是什么類型,畢竟有些對象根本不可遍歷,那我們現在的代碼就無法拷貝這類對象。

我們可以通過如下代碼來精准獲取當前參數的對象類型:

const getObjectType = (obj) => {
  return Object.prototype.toString.call(obj);
}

舉個例子,比如當傳入一個函數或者一個正則就能精准得到它的對象類型:

Object.prototype.toString.call(function (){});// '[object Function]'
Object.prototype.toString.call(/1/);// '[object RegExp]'

我們可以列舉常見的部分對象類型,將其定義成常量便於后續使用:

// 可遍歷類型
const arrType = '[object Array]';
const objType = '[object Object]';
const mapType = '[object Map]';
const setType = '[object Set]';
const argType = '[object Arguments]';

// 不可遍歷
const boolType = '[object Boolean]';
const numType = '[object Number]';
const strType = '[object String]';
const dateType = '[object Date]';
const errType = '[object Error]';
const regexpType = '[object Regexp]';
const symbolType = '[object Symbol]';
const funType = '[object Function]';

// 將可遍歷類型做個集合
const traverseTypes = [arrType, objType, mapType, setType, argType];

其實初略做個分類,大家雖然都是對象,但並不是所有對象都可以遍歷,比如日期,數字對象,這種我們都不太好直接遍歷。

而數組,普通對象{}arguments以及新增的Map Set雖然都可以遍歷,但像Map添加屬性通過add方法,並不是傳統的key-value賦值形式,所以並不能通過for...in一招通吃。

有同學這里可能就已經有疑問了,不是數字,字符串直接返回嗎?怎么對象還考慮這些呢?這是因為我們創建數字習慣使用對象字面量的創建方式,比如:

const s = '聽風';

但我們依然可以通過構造器String來創建一個字符串對象:

const s = new String('聽風');

OK,讓我們回到上文已實現的深拷貝,目前我們根據isArray來判斷是否是一個數組,從而初始化obj_是一個[]或者{},很顯然這種做法沒辦法滿足需求,當時一個對象時,我們希望直接創建一個同類型的空對象,然后再往這個空對象上復制屬性。

怎么做呢?其實這里我們可以借用傳遞參數的constructor屬性,訪問到該參數的原始構造函數,舉個例子:

const num = 1;
const arr = [];
const bool = true;

num.__proto__.constructor === Number;// true
arr.__proto__.constructor === Array;// true
bool.__proto__.constructor === Boolean;// true

因此只要當前可以認定這是一個值得深拷貝的對象,我們直接通過.constructor訪問到構造器,然后執行new操作即可。

若對於這一步有疑惑,說明你對於javascript中的原型掌握不是很扎實,這里可以閱讀博主JS 疫情宅在家,學習不能停,七千字長文助你徹底弄懂原型與原型鏈一文,這里就不多贅述了。

讓我們改寫深拷貝方法,讓它能根據任意對象類型創建對應空對象:

const deepClone = (obj, map = new Map()) => {
  // 如果不是對象直接返回
  if (!isObject(obj)) {
    return obj;
  };

  // 獲取當前參數的對象類型
  const objType = getObjectType(obj);
  // 根據constructor找到原始構造器,創建初始化對象
  let obj_ = new obj.constructor();

  // 解決循環引用問題
  if (map.has(obj)) {
    return map.get(obj);
  };
  // 存儲當前拷貝的對象,以及我們要返回的對象
  map.set(obj, obj_);

  // 拷貝Set
  if (objType === setType) {
    obj.forEach((val, key) => {
      obj_.add(deepClone(val, map));
    });
    return obj_;
  };

  // 拷貝Map
  if (objType === mapType) {
    obj.forEach((val, key) => {
      obj_.set(key, deepClone(val, map));
    });
    return obj_;
  };

  // 如果是數組或者{}
  for (let i in obj) {
    // 不管是不是對象,直接遞歸,外面的typeof會幫我們做判斷是否要繼續遍歷
    obj_[i] = deepClone(obj[i], map);
  };
  return obj_;
};

const obj = {
  name: '聽風',
  arr: [1, 2, 3],
  set: new Set([1, 2, 3]),
  map: new Map([
    ['age', 29]
  ])
};

const o = deepClone(obj);
obj.name = '1';
obj.set.add(4);
obj.map.set('sex', 'male');
console.log(obj, o);

上述代碼我們兼容了五種可遍歷的對象類型,Set Map需要特有的復制的方式,除此之外的對象,數組以及arguments均可通過for...in復制,運行了例子發現非常順利。

題外話,我在查閱深拷貝資料時,發現有不少評論說使用Object.create(obj.constructor.prototype)來取代new obj.constructor()的做法,因為前者是直接使用你要復制對象的原型來創建空對象,這要比后者再次調用構造器性能要好,這個說法是有問題的,我們來看個例子:

const arr1 = Object.create([].constructor.prototype);
const arr2 = new [].constructor();

arr1[0] = 1;;
arr2[0] = 1;
console.log(arr1.length, arr2.length); // 0 1

可以看到,通過以數組原型創建的空數組,它自身居然沒有帶length屬性,假設我們以此拷貝出了一個數組,你會發現雖然它有元素,但因為缺少自己的length從而無法成功遍歷。

貳 ❀ 柒 兼容不可遍歷類型

OK,讓我們繼續分析剩余不可遍歷或者說不便於遍歷的對象類型,像布爾值,數字,字符串以及日期這類對象,我們要拷貝比較簡單,我們可以通過valueOf訪問到對象的原始值,舉個例子:

const num = new Number(1);
console.log(num.vauleOf());// 1

但需要注意的是Symbol這個類型它不能使用new調用,且Symbole本身就是基礎數據類型,一般情況下我們讓它跟普通的數字一樣,傳入原封不動返回,但我們需要額外考慮包裝對象形式的Symbol,比如:

const s = Object(Symbol(1));
Object.prototype.toString.call(s);// '[object Symbol]'

這種形式的對象,我們可以也利用Object(obj.valueOf())進行返回即可。

現在,我們將這些不方便遍歷的類型單獨做個抽離,能遍歷的對象還是用使用上述實現,具體實現如下:

// 拷貝不便於遍歷的對象類型
const cloneOtherType = (obj, type) => {
  switch (type) {
    case boolType:
    case numType:
    case strType:
    case dateType:
      return new obj.constructor(obj.valueOf());
    case symbolType:
      return Object(obj.valueOf());
    case regexpType:
      // 待實現
    case funType:
      // 待實現
  }
};

const deepClone = (obj, map = new Map()) => {
  // 如果不是對象直接返回
  if (!isObject(obj)) {
    return obj;
  };

  // 獲取當前參數的對象類型
  const objType = getObjectType(obj);

  // 根據constructor找到原始構造器,創建初始化對象
  let obj_;
  if (traverseTypes.includes(objType)) {
    // 如果是可遍歷類型,直接創建空對象
    obj_ = new obj.constructor();
  } else {
    // 若不是,則走額外的處理
    return cloneOtherType(obj, objType);
  }
	// 相同代碼省略.....
};

現在我們只差正則和函數的實現了,先來說說正則。

貳 ❀ 捌 實現正則深拷貝

聲明一個正則一般有兩種寫法,常見的正則字面量直接創建,或者使用new結合正則構造器來創建,如下:

cosnt reg1 = /123/g;
const reg2 = new Regexp(/123/,'g');

當我們接受一個正則時,肯定也是希望通過new然后傳入參數來得到一個新的正則對象,這里我們就得提取兩部分,一部分是正則的匹配文本(123),一部分是正則的修飾符(g)。至於前者,我們能通過source屬性訪問,后者則可以通過flags訪問,舉個例子:

const reg = /1/ig;
const {source, flags} = reg;
console.log(source, flags);// 1  gi

所以我們很容易寫出如下的代碼:

return new Regexp(obj.source, obj.flags);

但需要注意的是,正則表達式有個lastIndex屬性,用於指定下次匹配從什么時候開始,舉個例子:

const reg = /echo/g;
const str = 'echo echo echo';
let res;
// exec匹配到會返回一個數組,匹配不到返回null
while (res = reg.exec(str) !== null) {
  console.log(reg.lastIndex); // 4 9 14
}

第一匹配到echo后,下一次匹配的位置很明顯是第一個空格處,所以索引肯定是4,第二次匹配成功后lastIndex就變成9了,以此類推。而lastIndex這個東西是可以手動設置的,我們改改上面的例子:

const reg = /echo/g;
const str = 'echo echo echo';
reg.lastIndex = 9;
let res;
// exec匹配到會返回一個數組,匹配不到返回null
while (res = reg.exec(str) !== null) {
  console.log(reg.lastIndex); // 14
}

所以你看看,單純拿sourceflags還不夠,我們還得把傳遞的正則的lastIndex也抄過來,不然匹配的行為可能跟原正則不一致,因此完整的正則拷貝應該是:

// 克隆正則
const cloneRegexp = (obj) => {
  const {
    resource,
    flags,
    lastIndex
  } = obj;
  const obj_ = new Regexp(resource, flags);
  obj_.lastIndex = lastIndex;
  return obj_;
}

貳 ❀ 玖 實現函數克隆

實話實說,一般針對函數的拷貝,我們都是原封不動的返回,即便博主工作了5年,說實話也沒遇到要拷貝函數的場景。這里我也查閱了網上一些拷貝函數的思路,簡單說說。

第一種思路與正則一樣,把函數轉為字符串,然后正則匹配函數的參數,函數體,最后通過new Function()的形式創建一個新函數,但考慮到函數柯里化,閉包,等等復雜的場景,以及函數還存在自調用函數,箭頭函數,匿名函數等復雜因素,目前我能找到的此類實現其實都有問題,所以這里我就不做代碼補充了。

第二種,借用eval,看個例子:

const foo = x => console.log(x + 1);
const fn = eval(foo.toString())
fn(1);// 2

通過toString將函數轉為字符串后,借用eval執行再次得到函數,看似可以,但只要函數是函數聲明,這種做法就完全行不通了:

function foo(x) {
  console.log(x + 1);
};
console.log(foo.toString())
const fn = eval(foo.toString());
fn(1);// 報錯,fn是undefined

第三種,借用bind返回一個boundFn,也算是投機取巧的一種做法,並不符合我們心中的函數深拷貝,所以綜合來說,不如不拷貝,畢竟本身就沒這個需求在,如果面試真的問到,可以闡述以上三種做法,其中復雜性我想面試官自己也能體會。

叄 ❀ 總

那么總結上述所有改寫,我們直接貼上完整版代碼:

// 可遍歷類型
const arrType = '[object Array]';
const objType = '[object Object]';
const mapType = '[object Map]';
const setType = '[object Set]';
const argType = '[object Arguments]';

// 不可遍歷
const boolType = '[object Boolean]';
const numType = '[object Number]';
const strType = '[object String]';
const dateType = '[object Date]';
const errType = '[object Error]';
const regexpType = '[object Regexp]';
const symbolType = '[object Symbol]';
const funType = '[object Function]';

// 將可遍歷類型做個集合
const traverseTypes = [arrType, objType, mapType, setType, argType];


const isObject = (obj) => {
  const type = typeof obj;
  return obj !== null && (type === 'object' || type === 'function');
};

const getObjectType = (obj) => {
  return Object.prototype.toString.call(obj);
};

// 克隆正則
const cloneRegexp = (obj) => {
  const {
    resource,
    flags,
    lastIndex
  } = obj;
  const obj_ = new Regexp(resource, flags);
  obj_.lastIndex = lastIndex;
  return obj_;
}

// 拷貝不便於遍歷的對象類型
const cloneOtherType = (obj, type) => {
  switch (type) {
    case boolType:
    case numType:
    case strType:
    case dateType:
      return new obj.constructor(obj.valueOf());
    case symbolType:
      return Object(obj.valueOf());
    case regexpType:
      return cloneRegexp(obj);
    case funType:
      return obj;
  }
}

const deepClone = (obj, map = new Map()) => {
  // 如果不是對象直接返回
  if (!isObject(obj)) {
    return obj;
  };

  // 獲取當前參數的對象類型
  const objType = getObjectType(obj);

  // 根據constructor找到原始構造器,創建初始化對象
  let obj_;
  if (traverseTypes.includes(objType)) {
    // 如果是可遍歷類型,直接創建空對象
    obj_ = new obj.constructor();
  } else {
    // 若不是,則走額外的處理
    return cloneOtherType(obj, objType);
  }

  // 解決循環引用問題
  if (map.has(obj)) {
    return map.get(obj);
  };
  // 存儲當前拷貝的對象,以及我們要返回的對象
  map.set(obj, obj_);

  // 拷貝Set
  if (objType === setType) {
    obj.forEach((val, key) => {
      obj_.add(deepClone(val, map));
    });
    return obj_;
  };

  // 拷貝Map
  if (objType === mapType) {
    obj.forEach((val, key) => {
      obj_.set(key, deepClone(val, map));
    });
    return obj_;
  };

  // 如果是數組或者{}
  for (let i in obj) {
    // 不管是不是對象,直接遞歸,外面的typeof會幫我們做判斷是否要繼續遍歷
    obj_[i] = deepClone(obj[i], map);
  };
  return obj_;
};

如果你是跟着本文思路一步步走下來,上述代碼理論上來說不存在難以理解的點,簡單測試下例子,也暫未發現有什么問題,不過再怎么說,上述實現也不能用於生產環境,畢竟當下就有更專業的三方庫來幫我解決深拷貝的問題。

你也許會想,面試真的會讓你手寫一個深拷貝嗎?我沒遇到過手寫,但確實遇到過講解實現思路以及有哪些邊界情況需要考慮的問題,若真手寫,能將上述思路實現到七七八八,就已經非常優秀了,看似在實現深拷貝,其實這段實現中,我想大家也發現考核了javascript中很多基礎知識,大的有原型鏈與繼承,es6Map Set,小的知識點甚至考慮到了正則的lastIndex;所以站在我的角度,這篇文章也能作為js綜合復習的一個入口。

寫到最后,若文章存在錯誤以及有所疑問,也歡迎留言討論,那么到這里本文結束。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM