前言
在今年的四月份我写了一篇有关深拷贝的博客文章 我与深拷贝_radash 深拷贝-CSDN博客。在该文章中有一个令我感到遗憾的点就是我没有实现一个自己手写的深拷贝。如今我想弥补当初的遗憾,在这篇文章中详细的讲述一下如何手写一个深拷贝方法。
lodash中是如何实现深拷贝的?
代码整理
我首先参考了lodash中的深拷贝方法,对它的代码进行了整理,整理后的代码如下所示。可以看到经过我整理后的代码依旧十分复杂,并且这个深拷贝方法中还调用了许多的其它方法,这些被调用的方法我还都没展示出来,在后续详细分析深拷贝的实现方式时,我再一 一介绍这些方法。
/** `Object#toString` result references. */
const argsTag = "[object Arguments]",arrayTag = "[object Array]",boolTag = "[object Boolean]",dateTag = "[object Date]",errorTag = "[object Error]",funcTag = "[object Function]",genTag = "[object GeneratorFunction]",mapTag = "[object Map]",numberTag = "[object Number]",objectTag = "[object Object]",regexpTag = "[object RegExp]",setTag = "[object Set]",stringTag = "[object String]",symbolTag = "[object Symbol]",weakMapTag = "[object WeakMap]";const arrayBufferTag = "[object ArrayBuffer]",dataViewTag = "[object DataView]",float32Tag = "[object Float32Array]",float64Tag = "[object Float64Array]",int8Tag = "[object Int8Array]",int16Tag = "[object Int16Array]",int32Tag = "[object Int32Array]",uint8Tag = "[object Uint8Array]",uint8ClampedTag = "[object Uint8ClampedArray]",uint16Tag = "[object Uint16Array]",uint32Tag = "[object Uint32Array]";/** Used to identify `toStringTag` values supported by `_.clone`. */
const cloneableTags = {};
cloneableTags[argsTag] =cloneableTags[arrayTag] =cloneableTags[arrayBufferTag] =cloneableTags[dataViewTag] =cloneableTags[boolTag] =cloneableTags[dateTag] =cloneableTags[float32Tag] =cloneableTags[float64Tag] =cloneableTags[int8Tag] =cloneableTags[int16Tag] =cloneableTags[int32Tag] =cloneableTags[mapTag] =cloneableTags[numberTag] =cloneableTags[objectTag] =cloneableTags[regexpTag] =cloneableTags[setTag] =cloneableTags[stringTag] =cloneableTags[symbolTag] =cloneableTags[uint8Tag] =cloneableTags[uint8ClampedTag] =cloneableTags[uint16Tag] =cloneableTags[uint32Tag] =true;
cloneableTags[errorTag] =cloneableTags[funcTag] =cloneableTags[weakMapTag] =false;// T-深拷贝方法
function cloneDeep(value, object, stack) {let result;if (!isObject(value)) {return value;}// 获取数据类型标签const tag = getTag(value);// 做克隆的初始化准备工作if (Array.isArray(value)) {result = initCloneArray(value);} else {// value是否为函数const isFunc = tag == funcTag || tag == genTag;if (tag == objectTag || tag == argsTag || (isFunc && !object)) {result = isFunc ? {} : initCloneObject(value);} else {if (!cloneableTags[tag]) {return object ? value : {};}result = initCloneByTag(value, tag);}}// 检查循环引用并返回它相关的克隆副本stack = stack || new Map();const stacked = stack.get(value);if (stacked) {return stacked;}stack.set(value, result);// 拷贝Setif (tag == setTag) {value.forEach(subValue => {result.add(cloneDeep(subValue, value, stack));});}// 拷贝Mapelse if (tag == mapTag) {value.forEach((subValue, key) => {result.set(key, cloneDeep(subValue, value, stack));});}if (tag == objectTag || tag == arrayTag || tag == argsTag) {const props = getAllKeys(value);props.forEach(prop => {result[prop] = cloneDeep(value[prop], value, stack);});}return result;
}
代码结构分析
下面开始分析介绍cloneDeep
方法的内容。首先看方法的参数,共有三个分别为value
、object
和stack
。其中value
是需要进行深拷贝的值,object
是需要拷贝的值的父级对象,stack
是用于防止循环引用的缓存栈。当然我们只需要关注第一个参数value
,后两个参数是在递归调用的时候才会起作用。
处理普通类型
接着开始处理value
,首先第一步就可以根据value
的数据类型大致分为两种情况:
要知道普通类型的数据不存在拷贝的问题,所以如果“value
是普通类型数据”我们直接return value
就可以了,对应到cloneDeep
方法中 就是下面的这部分代码:
其中使用isObject
方法识别数据是普通类型还是引用类型。
// 数据是否为类对象
function isObject(value) {return ((typeof value == "object" || typeof value == "function") && value !== null);
}
处理引用类型
下面第二步就要开始考虑“value
是引用数据类型”的情况了。是不是直接就可以开始深拷贝啦?
当然不是,接下来cloneDeep
中没有开始进行深拷贝,而是执行了一段复杂的代码,我将其称为“深拷贝前的过渡工作”。
为什么要先进行过渡工作呢?主要是因为其实不是所有的引用数据类型都需要深拷贝的。引用类型可以分为以下的几种情况:
- 需要进行深拷贝
在lodash的cloneDeep
方法中就只会对Object
、Array
、Map
、Set
、Arguments
五种类型进行深拷贝,因为这五种类型的数据中还有可能会存储其它的引用类型数据,这五种类型就是需要进行深拷贝的情况。
如果value
是需要进行深拷贝的引用类型数据,那么在“过渡阶段”会进行一个克隆容器的准备工作,例如要克隆一个对象就要先准备一个空对象作为克隆容器,要克隆数组就要准备一个空数组作为克隆容器。
- 如果
value
是Object
或Arguments
类型,就会调用initCloneObject
方法创建空对象。
在initCloneObject
方法中有一个需要注意的点,在创建克隆容器对象的时候,保证了它的原型与原对象是一致的。
// 初始化一个对象克隆
function initCloneObject(object) {return typeof object.constructor == "function"? Object.create(Object.getPrototypeOf(object)) //保证对象克隆的原型不变: {};
}
- 如果
value
是Array
类型则会调用initCloneArray
方法创建克隆容器数组。
// 初始化一个数组克隆
function initCloneArray(array) {const length = array.length;const result = Array(length);//添加由正则表达式(RegExp)对象的 exec 方法所赋予的相关属性if (length && typeof array[0] === "string" && Reflect.has(array, "index")) {result.index = array.index;result.input = array.input;}return result;
}
- 如果
value
是Map
或Set
类型,就会在initCloneByTag
方法中完成对克隆容器的准备。
- 只需要进行浅拷贝
有一些引用类型只会存储普通类型的数据或者就不会存储其它数据,对它们来说只需要进行浅拷贝就行了,例如TypedArray
类型只会存储二进制数据所以根本就不需要深拷贝。在lodash的cloneDeep
方法中Date
、RegExp
、ArrayBuffer
、DataView
、TypedArray
这几种类型就会被归在这类情况中。
怎么对这些类型进行浅拷贝呢?具体的实现方式各不相同,但大致的思路都是用构造函数重新创建一个与原数据一模一样的克隆数据,主要是在initCloneByTag
方法中实现。
// 基于tag初始化一个对象克隆
function initCloneByTag(value, tag) {const Ctor = value.constructor; // 获取构造函数switch (tag) {case "[object ArrayBuffer]":return cloneArrayBuffer(value);case "[object Boolean]":case "[object Date]":return new Ctor(+value);case "[object DataView]":return cloneDataView(value);case "[object Float32Array]":case "[object Float64Array]":case "[object Int8Array]":case "[object Int16Array]":case "[object Int32Array]":case "[object Uint8Array]":case "[object Uint8ClampedArray]":case "[object Uint16Array]":case "[object Uint32Array]":return cloneTypedArray(value);case "[object Map]":return new Ctor();case "[object Number]":case "[object String]":return new Ctor(value);case "[object RegExp]":return cloneRegExp(value);case "[object Set]":return new Ctor();case "[object Symbol]":return cloneSymbol(value);}
}
// t-克隆方法
// 克隆ArrayBuffer
function cloneArrayBuffer(arrayBuffer) {// 创建一个与arrayBuffer相同大小的ArrayBufferconst duplicateArrayBuffer = new ArrayBuffer(arrayBuffer.byteLength);// 将arrayBuffer的数据复制到duplicateArrayBuffer中new Uint8Array(duplicateArrayBuffer).set(new Uint8Array(arrayBuffer));return duplicateArrayBuffer;
}//克隆DataView
function cloneDataView(dataView) {const buffer = cloneArrayBuffer(dataView.buffer);return new DataView(buffer, dataView.byteOffset, dataView.byteLength);
}// 克隆TypedArray
function cloneTypedArray(typedArray) {const buffer = cloneArrayBuffer(typedArray.buffer);return new typedArray.constructor(buffer,typedArray.byteOffset,typedArray.length);
}// 克隆RegExp
function cloneRegExp(regExp) {const result = new RegExp(regExp.source, regExp.flags);result.lastIndex = regExp.lastIndex;return result;
}// 克隆Symbol
function cloneSymbol(symbol) {return Symbol(symbol.description);
}
- 无法进行拷贝
在lodash的实现中标定了Function
、GeneratorFunction
、Error
、WeakMap
这几种类型是无法进行拷贝的。
对于这类数据如果它没有父级对象会返回一个空对象,如果它有父级对象则会返回它本身。我也不清楚为什么lodash中要这样设计为两种情况返回不同的值。
我猜测可能是这样,比如说直接将一个函数传入cloneDeep
方法中会返回{}
,目的可能是为了提醒使用者该数据是无法深拷贝的。而当我把函数放到一个对象里,再把对象传入cloneDeep
中,此时其实是在克隆对象,为了保证克隆后对象内的成员完整,所以要返回函数本身。
const func = ()=>{}
cloneDeep(func) // => {}
cloneDeep({a:func}) // => {a:()=>{}}
- 没有考虑
还有一些类型可能在封装深拷贝方法的时候都没有考虑到,例如克隆DOM元素等。这种情况与“无法进行拷贝”走同样的处理逻辑。
通过递归实现深拷贝
cloneDeep
中剩余的代码就是深拷贝的部分了,它们是整个深拷贝函数中最核心的代码,这部分代码可以划分为下图中的四个部分:
这部分代码中的一些基础的部分(深拷贝的原理、循环引用等)我也就不详细讲了,不懂的可以去查阅其它的资料。这部分代码有两个值得提一下的点:
- 对象数组共用同一套代码进行深拷贝
在lodash的深拷贝实现中,对象和数组使用同一套代码进行深拷贝。基本的思路是先通过一个getAllKeys
方法获取对象/数组的键数组,然后遍历键数组实现深拷贝。
// 获取对象的所有属性名
function getAllKeys(obj) {return getTag(obj) === "[object Object]"? Object.getOwnPropertyNames(obj).concat(Object.getOwnPropertySymbols(obj)): Object.keys(obj);
}
- 如何拷贝Arguments
在lodash的深拷贝实现中支持对Arguments
的拷贝,在具体实现的时候是将Arguments
类型的数据看做一个对象进行拷贝的。
const testArgsClone = function (a, b) {const argsClone = cloneDeep(arguments);console.log(arguments); // [Arguments] { '0': 1, '1': 2 }console.log(getTag(arguments)); //[object Arguments]console.log(argsClone); //{ '0': 1, '1': 2 }console.log(getTag(argsClone)); //[object Object]
};
testArgsClone(1, 2);
对各种类型数据的处理方式总结
类型 | 处理方式 |
数字、字符串、布尔值 | 不克隆:返回自身 |
Symbol | 不克隆:返回自身 |
null、undefined | 不克隆:返回自身 |
日期 | 专门方法克隆 |
正则 | 专门方法克隆 |
ArrayBuffer | 专门方法克隆 |
DataView | 专门方法克隆 |
TypeArray | 专门方法克隆 |
数组 | 递归克隆 |
对象 | 递归克隆 |
Map | 递归克隆 |
Set | 递归克隆 |
Arguments | 递归克隆,但是注意,得到的克隆副本是一个存储着参数的普通对象 |
函数、生成器函数 | 无法克隆:
|
错误 | 无法克隆:
|
WeakMap | 无法克隆:
|
其他相关问题
1.对象属性的特性是否会被克隆
经过测试后发现并不会克隆属性的描述符(属性特性)
const obj = {};
Object.defineProperty(obj, "prop", {value: 1,writable: false,enumerable: true,configurable: true,
});const objClone = cloneDeep(obj,);
console.log(Object.getOwnPropertyDescriptor(obj, "prop")); // { value: 1, writable: false, enumerable: true, configurable: true }
console.log(Object.getOwnPropertyDescriptor(objClone, "prop")); // { value: 1, writable: true, enumerable: true, configurable: true }
我的深拷贝
我的实现方式
根据上面总结的经验,我封装了自己的深拷贝方法,与lodash的实现方式不同的地方主要有几点:
- 对所有不需要克隆、无法克隆和没有考虑到的值都直接返回;
- 对象和数组的克隆不在放在一起进行了;
- Arguments视为数组进行处理。
const getType = value => {return Object.prototype.toString.call(value).match(/(?<=\[object )(\w+)(?=\])/)[0];
};function cloneDeep(value, stack = new Map()) {const type = getType(value);// 需要特殊处理的类型if (type === "Date") {return new Date(value.getTime());} else if (type === "RegExp") {return new RegExp(value.source, value.flags);} else if (type === "ArrayBuffer") {return value.slice();} else if (type === "DataView") {const buffer = value.buffer.slice();return new DataView(buffer, value.byteOffset, value.byteLength);} else if (["Int8Array","Uint8Array","Uint8ClampedArray","Int16Array","Uint16Array","Int32Array","Uint32Array","Float32Array","Float64Array",].includes(type)) {const buffer = value.buffer.slice();return new value.constructor(buffer, value.byteOffset, value.length);}// 递归进行深拷贝let result = stack.get(value);if (result) return result;if (type === "Set") {result = new Set();stack.set(value, result);value.forEach(item => {result.add(cloneDeep(item, stack));});return result;} else if (type === "Map") {result = new Map();stack.set(value, result);value.forEach((item, key) => {result.set(key, cloneDeep(item, stack));});return result;} else if (type === "Object") {result = Object.create(Object.getPrototypeOf(value));stack.set(value, result);const keys = Reflect.ownKeys(value);keys.forEach(key => {result[key] = cloneDeep(value[key], stack);});return result;} else if (type === "Array" || type === "Arguments") {result = [];stack.set(value, result);for (let i = 0; i < value.length; i++) {result[i] = cloneDeep(value[i], stack);}return result;}return value;
}
对于一些问题的探讨
1.ArrayBuffer、DataView、TypedArray的克隆方式
lodash中克隆这三种类型的数据的方法如下:
// 克隆ArrayBuffer
function cloneArrayBuffer(arrayBuffer) {// 创建一个与arrayBuffer相同大小的ArrayBufferconst duplicateArrayBuffer = new ArrayBuffer(arrayBuffer.byteLength);// 将arrayBuffer的数据复制到duplicateArrayBuffer中new Uint8Array(duplicateArrayBuffer).set(new Uint8Array(arrayBuffer));return duplicateArrayBuffer;
}//克隆DataView
function cloneDataView(dataView) {const buffer = cloneArrayBuffer(dataView.buffer);return new DataView(buffer, dataView.byteOffset, dataView.byteLength);
}// 克隆TypedArray
function cloneTypedArray(typedArray) {const buffer = cloneArrayBuffer(typedArray.buffer);return new typedArray.constructor(buffer,typedArray.byteOffset,typedArray.length);
}
我在实现的时候有一些不同,简单来说就是使用ArrayBuffer
的slice
方法来对其进行克隆。这样就不用手动去拷贝字节了,简化了代码。
新的实现:
// 深拷贝ArrayBuffer
function cloneDeepArrayBuffer(arrayBuffer) {return arrayBuffer.slice();
}
// 深拷贝DataView
function cloneDeepDataView(dataView) {const buffer = dataView.buffer.slice();return new DataView(buffer, dataView.byteOffset, dataView.byteLength);
}// 深拷贝TypedArray
function cloneDeepTypedArray(typedArray) {const buffer = typedArray.buffer.slice();return new typedArray.constructor(buffer,typedArray.byteOffset,typedArray.length);
}
2.Symbol类型是否需要深拷贝?
这是一个让我感到十分疑惑的问题,我在阅读《如何写出一个惊艳面试官的深拷贝?》这篇文章时就发现,作者在实现深拷贝的过程中还写了对Symbol
、String
、Boolean
等普通类型拷贝的逻辑。
并且lodash中的initCloneByTag
方法中也是有类似的逻辑的:
只是有一个小问题,像Symbol
等普通类型的数据,应该在下面的这一步就被处理了,根本到不了initCloneByTag
这个就很奇怪了,后来我看到了那篇文章的评论区中有如下的解释:
我进行了测试,发现好像并不是想他说的那样有所谓的基本类型和包装类型的区别。
const n1 = 10;
const n2 = Number(10);console.log(typeof n1, Object.prototype.toString.call(n1));//number [object Number]
console.log(typeof n2, Object.prototype.toString.call(n2));//number [object Number]
但是不对,我好像写错了应该是new Number(10)
而不是Number(10)
:
const n1 = 10;
const n2 = new Number(10);console.log(typeof n1, Object.prototype.toString.call(n1)); //number [object Number]
console.log(typeof n2, Object.prototype.toString.call(n2)); //object [object Number]
这样就对了,所以用构造函数创建出来的普通类型数据typeof
是无法识别它们真正的类型的,并且由于它们也是object
,所以也要对它们进行克隆以保证引用不同。
当然我并不打算修改我的深拷贝方法,因为首先我觉得使用字面量的方式创建还是用构造函数创建其实并没有区别,并不会存在深拷贝的问题,其次想要对这些包装类型进行处理得大规模的修改我的代码结构,我并不想这样做,并且由于在我的代码中并没有使用typeof
去判断数据类型,所以也不存在误判的情况。
3.使用weakMap替代Map进行缓存
在我们的实现当中是使用一个Map
作为缓存栈来解决循环引用的问题的,而在《如何写出一个惊艳面试官的深拷贝?》中作者提到可以用WeakMap
替代Map
,按照作者的意思使用Map
会对内存造成非常大的额外消耗,而且我们需要手动清除Map
的属性才能释放这块内存,而WeakMap
会帮我们巧妙化解这个问题,因为WeakMap
是弱引用,当没用其它地方引用WeakMap
中的对象时,这些对象就会被垃圾回收机制所回收。
然而实际上这种观点在评论区中被很多读者所反对,认为这么做就是画蛇添足。因为在作者(包括我)的实现中Map
都是作为函数内的一个参数的,参数是函数内的局部变量,在函数执行完毕后就会被回收。所以根本不需要使用WeakMap
。
不过我发现这个东西可能对我来说还真有用处,因为我现在不想将stack
作为cloneDeep
方法的参数,因为cloneDeep
方法我将来是要导出去使用的,万一在使用的时候不小心传了第二个参数,那么cloneDeep
方法岂不就要出问题,所以我想将stack
提出来使用:
const stack = new Map()function cloneDeep(value){......
}
如果像上面这样写,那么stack
里面的值就真的回收不了了,这个时候就应该使用WeakMap
来实现这样一个缓存栈的功能:
const cacheStack = new WeakMap(); //缓存栈function cloneDeep(value) {const type = getType(value);// 需要特殊处理的类型if (type === "Date") {return new Date(value.getTime());} else if (type === "RegExp") {return new RegExp(value.source, value.flags);} else if (type === "ArrayBuffer") {return value.slice();} else if (type === "DataView") {const buffer = value.buffer.slice();return new DataView(buffer, value.byteOffset, value.byteLength);} else if (["Int8Array","Uint8Array","Uint8ClampedArray","Int16Array","Uint16Array","Int32Array","Uint32Array","Float32Array","Float64Array",].includes(type)) {const buffer = value.buffer.slice();return new value.constructor(buffer, value.byteOffset, value.length);}// 递归进行深拷贝let result = cacheStack.get(value);if (result) return result;if (type === "Set") {result = new Set();cacheStack.set(value, result);value.forEach(item => {result.add(cloneDeep(item));});return result;} else if (type === "Map") {result = new Map();cacheStack.set(value, result);value.forEach((item, key) => {result.set(key, cloneDeep(item));});return result;} else if (type === "Object") {result = Object.create(Object.getPrototypeOf(value));cacheStack.set(value, result);const keys = Reflect.ownKeys(value);keys.forEach(key => {result[key] = cloneDeep(value[key]);});return result;} else if (type === "Array" || type === "Arguments") {result = [];cacheStack.set(value, result);for (let i = 0; i < value.length; i++) {result[i] = cloneDeep(value[i]);}return result;}return value;
}
4.递归转循环
我在文章在《如何写出一个惊艳面试官的深拷贝?》的评论区还看到很多读者提到可以用循环代替递归,我对这个很感兴趣,于是也自己尝试实现了一下。
"递归转循环"的原理其实是就是模拟函数的执行栈,例如有如下的一个递归函数:
function factorial(num){return num > 1 ? num * factorial(--num) : 1
}
我们当然可以像下面这样写,从而将递归转为循环:
function factorial(num) {let result = 1;for (let i = num; i > 1; i--) {result *= i;}return result;
}
不过上面的这种方式并没有很好的实现“模拟函数执行栈”,我们可以改用下面这种写法。其中stack
数组就相当于是函数的执行栈,stack
中的数字就是需要执行的函数,只要执行栈stack
中还有元素,那while
循环就不会终止,只有当达到终止条件(n
小于等于1)时循环才会终止,这里的循环终止条件其实就相当于是递归的终止条件,循环体中的内容就相当于是递归函数中的内容。
function factorial(num) {const stack = [num];let result = 1;while (stack.length) {let n = stack.pop();result *= n;if (n > 1) stack.push(--n);}return result;
}
基于上面的思路我将我之前写的深拷贝方法进行了“递归转循环”:
function getType(value) {return Object.prototype.toString.call(value).match(/(?<=\[object )(\w+)(?=\])/)[0];
}// 准备容器
function initContainer(value, type) {type = type || getType(value);switch (type) {case "Object":return Object.create(Object.getPrototypeOf(value));case "Array":case "Arguments":return [];case "Set":return new Set();case "Map":return new Map();}
}// 根据类型克隆数据
function cloneByType(value, type) {type = type || getType(value);if (type === "Date") {return new Date(value.getTime());} else if (type === "RegExp") {return new RegExp(value.source, value.flags);} else if (type === "ArrayBuffer") {return value.slice();} else if (type === "DataView") {const buffer = value.buffer.slice();return new DataView(buffer, value.byteOffset, value.byteLength);} else if (["Int8Array","Uint8Array","Uint8ClampedArray","Int16Array","Uint16Array","Int32Array","Uint32Array","Float32Array","Float64Array",].includes(type)) {const buffer = value.buffer.slice();return new value.constructor(buffer, value.byteOffset, value.length);}return value;
}// 使用遍历方式进行深拷贝
function cloneDeep(value) {let result = initContainer(value);if (!result) return cloneByType(value);const cacheStack = new WeakMap(); //缓存栈const executionStack = [{ source: value, target: result }]; //执行栈// 用循环模拟递归的过程while (executionStack.length) {let { source, target } = executionStack.pop();// 缓存cacheStack.set(source, target);// 获取数据类型const type = getType(source);if (type === "Set") {source.forEach(item => {const subTarget = initContainer(item);if (subTarget) {if (cacheStack.has(item)) {target.add(cacheStack.get(item));} else {target.add(subTarget);executionStack.push({ source: item, target: subTarget });}} else {target.add(cloneByType(item));}});} else if (type === "Map") {source.forEach((item, key) => {const subTarget = initContainer(item);if (subTarget) {if (cacheStack.has(item)) {target.set(key, cacheStack.get(item));} else {target.set(key, subTarget);executionStack.push({ source: item, target: subTarget });}} else {target.set(key, cloneByType(item));}});} else if (type === "Object") {const keys = Reflect.ownKeys(source);keys.forEach(key => {const subTarget = initContainer(source[key]);if (subTarget) {if (cacheStack.has(source[key])) {target[key] = cacheStack.get(source[key]);} else {target[key] = subTarget;executionStack.push({ source: source[key], target: subTarget });}} else {target[key] = cloneByType(source[key]);}});} else if (type === "Array" || type === "Arguments") {for (let i = 0; i < source.length; i++) {const subTarget = initContainer(source[i]);if (subTarget) {if (cacheStack.has(source[i])) {target[i] = cacheStack.get(source[i]);} else {target[i] = subTarget;executionStack.push({ source: source[i], target: subTarget });}} else {target[i] = cloneByType(source[i]);}}}}return result;
}
参考资料
- https://github.com/lodash/lodash
- lodash.cloneDeep | Lodash中文文档 | Lodash中文网
- 一篇彻底搞定对象的深度克隆 | 包括function和symbol类型_克隆symbol-CSDN博客
- 如何写出一个惊艳面试官的深拷贝?
- ArrayBuffer.prototype.slice() - JavaScript | MDN
- 对象深拷贝—解决循环引用以及递归爆栈问题_递归遇到循环引用怎么办-CSDN博客