
壹 ❀ 引
在深拷貝與淺拷貝的區別,實現深拷貝的幾種方法一文中,我們闡述了深淺拷貝的概念與區別,普及了部分具有迷惑性的淺拷貝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
,且類型結果不是object
和function
,那說明這個參數一定不是對象類型,我們來定義一個更精准的對象類型判斷方法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
}
所以你看看,單純拿source
和flags
還不夠,我們還得把傳遞的正則的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
中很多基礎知識,大的有原型鏈與繼承,es6
的Map Set
,小的知識點甚至考慮到了正則的lastIndex
;所以站在我的角度,這篇文章也能作為js
綜合復習的一個入口。
寫到最后,若文章存在錯誤以及有所疑問,也歡迎留言討論,那么到這里本文結束。