前言
配图源自 Feepik
此前写过一篇文章:JavaScript深浅拷贝,其实没那么难!,但里面的拷贝处理显然不够理想。
今天再来详细的讲讲...
正文
一、JSON.stringify() 的缺陷
利用 JavaScript 内置的 JSON 处理函数,可以实现简易的深拷贝:
const obj = { // ... } JSON.parse(JSON.stringify(obj)) // 序列化与反序列化
这个方法,其实能适用于 90% 以上的应用场景。毕竟多数项目下,很少会去拷贝一个函数什么的。
但不得不说,这里面有“坑”,这些“坑”是 JSON.stringify()
方法本身实现逻辑产生的:
JSON.stringify(value[, replacer[, space]])
该方法有以下特点:
- 布尔值、数值、字符串对应的包装对象,在序列化过程会自动转换成其原始值。
undefined
、任意函数
、Symbol 值
,在序列化过程有两种不同的情况。若出现在非数组对象的属性值中,会被忽略;若出现在数组中,会转换成null
。任意函数
、undefined
被单独转换时,会返回undefined
。- 所有
以 Symbol 为属性键的属性
都会被完全忽略,即便在该方法第二个参数replacer
中指定了该属性。 Date 日期
调用了其内置的toJSON()
方法转换成字符串,因此会被当初字符串处理。NaN
和Infinity
的数值及null
都会当做null
。- 这些对象
Map
、Set
、WeakMap
、WeakSet
仅会序列化可枚举的属性。 - 被转换值如果含有
toJSON()
方法,该方法定义什么值将被序列化。 - 对包含
循环引用
的对象进行序列化,会抛出错误。
二、深拷贝的边界
其实,针对以上两个内置的全局方法,还有这么多情况不能处理,是不是很气人。其实不然,我猜测 JSON.parse()
和 JSON.stringify()
只是让我们更方便地操作符合 JSON 格式的 JavaScript 对象或符合 JSON 格式的字符串。
至于上面提到的“坑”,很明显是不符合作为跨平台数据交换的格式要求的。在 JSON 中,它有 null
,是没有 undefined
、Symbol
类型、函数等。
JSON 是一种数据格式,也可以说是一种规范。JSON 是用于跨平台数据交流的,独立于语言和平台。而 JavaScript 对象是一个实例,存在于内存中。JavaScript 对象是没办法传输的,只有在被序列化为 JSON 字符串后才能传输。
如果自己实现一个深拷贝的方法,其实是有很多边界问题要处理的,至于这些种种的边界 Case,要不要处理最好从实际情况出发。
常见的边界 Case 有什么呢?
主要有循环引用、包装对象、函数、原型链、不可枚举属性、Map/WeakMap、Set/WeakSet、RegExp、Symbol、Date、ArrayBuffer、原生 DOM/BOM 对象等。
就目前而言,第三方最完善的深拷贝方法是 Lodash 库的 _.cloneDeep()
方法了。在实际项目中,如需处理 JSON.stringify()
无法解决的 Case,我会推荐使用它。否则请使用内置 JSON 方法即可,没必要复杂化。
但如果为了学习深拷贝,那应该要每种情况都要去尝试实现一下,我想这也是你在看这篇文章的原意。这样,无论是实现特殊要求的深拷贝,还是面试,都可以从容应对。
下面一起来学习吧,如有不足,欢迎指出 ~
三、自实现深拷贝方法
主要运用到递归的思路去实现一个深拷贝方法。
PS:完整的深拷贝方法会在文章最后放出。
先写一个简易版本:
const deepCopy = source => { // 判断是否为数组 const isArray = arr => Object.prototype.toString.call(arr) === '[object Array]' // 判断是否为引用类型 const isObject = obj => obj !== null && (typeof obj === 'object' || typeof obj === 'function') // 拷贝(递归思路) const copy = input => { if (typeof input === 'function' || !isObject(input)) return input const output = isArray(input) ? [] : {} for (let key in input) { if (input.hasOwnProperty(key)) { const value = input[key] output[key] = copy(value) } } return output } return copy(source) }
以上简易版本还存在很多情况要特殊处理,接下来针对 JSON.stringify()
的缺陷,一点一点去完善它。
3.1 针对布尔值、数值、字符串的包装对象的处理
需要注意的是,从 ES6 开始围绕原始数据类型创建一个显式包装器对象不再被支持。但由于遗留原因,现有的原始包装器对象(如
new Boolean
、new Number
、new String
)仍可使用。这也是 ES6+ 新增的Symbol
、BigInt
数据类型无法通过new
关键字创建实例对象的原因。
由于 for...in 无法遍历不可枚举的属性。例如,包装对象的 [[PrimitiveValue]]
内部属性,因此需要我们特殊处理一下。
以上结果,显然不是预期结果。包装对象的 [[PrimitiveValue]]
属性可通过 valueOf()
方法获取。
const deepCopy = source => { // 获取数据类型(本次新增) const getClass = x => Object.prototype.toString.call(x) // 判断是否为数组 const isArray = arr => getClass(arr) === '[object Array]' // 判断是否为引用类型 const isObject = obj => obj !== null && (typeof obj === 'object' || typeof obj === 'function') // 判断是否为包装对象(本次新增) const isWrapperObject = obj => { const theClass = getClass(obj) const type = /^\[object (.*)\]$/.exec(theClass)[1] return ['Boolean', 'Number', 'String', 'Symbol', 'BigInt'].includes(type) } // 处理包装对象(本次新增) const handleWrapperObject = obj => { const type = getClass(obj) switch (type) { case '[object Boolean]': return Object(Boolean.prototype.valueOf.call(obj)) case '[object Number]': return Object(Number.prototype.valueOf.call(obj)) case '[object String]': return Object(String.prototype.valueOf.call(obj)) case '[object Symbol]': return Object(Symbol.prototype.valueOf.call(obj)) case '[object BigInt]': return Object(BigInt.prototype.valueOf.call(obj)) default: return undefined } } // 拷贝(递归思路) const copy = input => { if (typeof input === 'function' || !isObject(input)) return input // 处理包装对象(本次新增) if (isWrapperObject(input)) { return handleWrapperObject(input) } // 其余部分没变,为了减少篇幅,省略一万字... } return copy(source) }
我们在控制台打印一下结果,可以看到是符合预期结果的。
3.2 针对函数的处理
直接返回就好了,一般不用处理。在实际应用场景需要拷贝函数太少了...
const copy = input => { if (typeof input === 'function' || !isObject(input)) return input }
3.3 针对以 Symbol 值作为属性键的处理
由于以上 for...in
方法无法遍历 Symbol
的属性键,因此:
const sym = Symbol('desc') const obj = { [sym]: 'This is symbol value' } console.log(deepCopy(obj)) // {},拷贝结果没有 [sym] 属性
这里,我们需要用到两个方法:
- Object.getOwnPropertySymbols()
它返回一个对象自身的所有 Symbol 属性的数组,包括不可枚举的属性。 - Object.prototype.propertyIsEnumerable()
它返回一个布尔值,表示指定的属性是否可枚举。
const copy = input => { // 其它不变 for (let key in input) { // ... } // 处理以 Symbol 值作为属性键的属性(本次新增) const symbolArr = Object.getOwnPropertySymbols(input) if (symbolArr.length) { for (let i = 0, len = symbolArr.length; i < len; i++) { if (input.propertyIsEnumerable(symbolArr[i])) { const value = input[symbolArr[i]] output[symbolArr[i]] = copy(value) } } } // ... }
下面我们对 source
对象做拷贝操作:
const source = {} const sym1 = Symbol('1') const sym2 = Symbol('2') Object.defineProperties(source, { [sym1]: { value: 'This is symbol value.', enumerable: true }, [sym2]: { value: 'This is a non-enumerable property.', enumerable: false } } )
打印结果,也符合预期结果:
3.4 针对 Date 对象的处理
其实,处理 Date
对象,跟上面提到的包装对象的处理是差不多的。暂时先放到 isWrapperObject()
和 handleWrapperObject()
中处理。
const deepCopy = source => { // 其他不变... // 判断是否为包装对象(本次更新) const isWrapperObject = obj => { const theClass = getClass(obj) const type = /^\[object (.*)\]$/.exec(theClass)[1] return ['Boolean', 'Number', 'String', 'Symbol', 'BigInt', 'Date'].includes(type) } // 处理包装对象 const handleWrapperObject = obj => { const type = getClass(obj) switch (type) { // 其他 case 不变 // ... case '[object Date]': return new Date(obj.valueOf()) // new Date(+obj) default: return undefined } } // 其他不变... }
3.5 针对 Map、Set 对象的处理
同样的,暂时先放到 isWrapperObject()
和 handleWrapperObject()
中处理。
利用 Map、Set 对象的 Iterator 特性和自身的方法,可以快速解决。
const deepCopy = source => { // 其他不变... // 判断是否为包装对象(本次更新) const isWrapperObject = obj => { const theClass = getClass(obj) const type = /^\[object (.*)\]$/.exec(theClass)[1] return ['Boolean', 'Number', 'String', 'Symbol', 'BigInt', 'Date', 'Map', 'Set'].includes(type) } // 处理包装对象 const handleWrapperObject = obj => { const type = getClass(obj) switch (type) { // 其他 case 不变 // ... case '[object Map]': { const map = new Map() obj.forEach((item, key) => { // 需要注意的是,这里的 key 不能深拷贝,否则就会失去引用了 // 具体原因可以思考一下,不难。想不明白再评论区吧 map.set(key, copy(item)) }) return map } case '[object Set]': { const set = new Set() obj.forEach(item => { set.add(copy(item)) }) return set } default: return undefined } } // 其他不变... }
打印下结果:
3.6 针对循环引用的问题
以下是一个循环引用(circular reference)的对象:
const foo = { name: 'Frankie' } foo.bar = foo
上面提到 JSON.stringify()
无法处理循环引用的问题,我们在控制台打印一下:
从结果可以看到,当对循环引用的对象进行序列化处理时,会抛出类型错误:Uncaught TypeError: Converting circular structure to JSON
。
接着,使用自行实现的 deepCopy()
方法,看下结果是什么:
我们看到,在拷贝循环引用的 foo
对象时,发生栈溢出了。
在另一篇文章,我提到过使用 JSON-js 可以处理循环引用的问题,具体用法是,先引入其中的
cycle.js
脚本,然后JSON.stringify(JSON.decycle(foo))
就 OK 了。但究其根本,它使用了 WeakMap 去处理。
那我们去实现一下:
const deepCopy = source => { // 创建一个 WeakMap 对象,记录已拷贝过的对象(本次新增) const weakmap = new WeakMap() // 中间这块不变,省略一万字... // 拷贝(递归思路) const copy = input => { if (typeof input === 'function' || !isObject(input)) return input // 针对已拷贝过的对象,直接返回(本次新增,以解决循环引用的问题) if (weakmap.has(input)) { return weakmap.get(input) } // 处理包装对象 if (isWrapperObject(input)) { return handleWrapperObject(input) } const output = isArray(input) ? [] : {} // 记录每次拷贝的对象 weakmap.set(input, output) for (let key in input) { if (input.hasOwnProperty(key)) { const value = input[key] output[key] = copy(value) } } // 处理以 Symbol 值作为属性键的属性 const symbolArr = Object.getOwnPropertySymbols(input) if (symbolArr.length) { for (let i = 0, len = symbolArr.length; i < len; i++) { if (input.propertyIsEnumerable(symbolArr[i])) { output[symbolArr[i]] = input[symbolArr[i]] } } } return output } return copy(source) }
先看看打印结果,不会像之前一样溢出了。
需要注意的是,这里不使用 Map 而是 WeakMap 的原因:
首先,Map 的键属于强引用,而 WeakMap 的键则属于弱引用。且 WeakMap 的键必须是对象,WeakMap 的值则是任意的。
由于它们的键与值的引用关系,决定了 Map 不能确保其引用的对象不会被垃圾回收器回收的引用。假设我们使用的 Map,那么图中的 foo
对象和我们深拷贝内部的 const map = new Map()
创建的 map
对象一直都是强引用关系,那么在程序结束之前,foo
不会被回收,其占用的内存空间一直不会被释放。
相比之下,原生的 WeakMap 持有的是每个键对象的“弱引用”,这意味着在没有其他引用存在时垃圾回收能正确进行。原生 WeakMap 的结构是特殊且有效的,其用于映射的 key 只有在其没有被回收时才是有效的。
基本上,如果你要往对象上添加数据,又不想干扰垃圾回收机制,就可以使用 WeakMap。
可看 Why WeakMap?
我们熟知的 Lodash 库的深拷贝方法,自实现了一个类似 WeakMap 特性的构造函数去处理循环引用的。(详看)
这里提供另一个思路,也是可以的。
const deepCopy = source => { // 其他一样,省略一万字... // 创建一个数组,将每次拷贝的对象放进去 const copiedArr = [] // 拷贝(递归思路) const copy = input => { if (typeof input === 'function' || !isObject(input)) return input // 循环遍历,若有已拷贝过的对象,则直接放回,以解决循环引用的问题 for (let i = 0, len = copiedArr.length; i < len; i++) { if (input === copiedArr[i].key) return copiedArr[i].value } // 处理包装对象 if (isWrapperObject(input)) { return handleWrapperObject(input) } const output = isArray(input) ? [] : {} // 记录每一次的对象 copiedArr.push({ key: input, value: output }) // 后面的流程不变... } return copy(source) }
此前实现有个 bug,感谢虾虾米指出,现已更正。
请在实现深拷贝之后测试以下示例:
const foo = { name: 'Frankie' } foo.bar = foo const cloneObj = deepCopy(foo) // 自实现深拷贝 const lodashObj = _.cloneDeep(foo) // Lodash 深拷贝 // 打印结果如下,说明是正确的 console.log(lodashObj.bar === lodashObj) // true console.log(lodashObj.bar === foo) // false console.log(cloneObj.bar === cloneObj) // true console.log(cloneObj.bar === foo) // false
3.7 针对正则表达式的处理
正则表达式里面,有两个非常重要的属性:
- RegExp.prototype.source
返回当前正则表达式对象的模式文本的字符串。注意,这是 ES6 新增的属性。 - RegExp.prototype.flags
返回当前正则表达式对象标志。
const { source, flags } = /\d/g console.log(source) // "\\d" console.log(flags) // "g"
有了以上两个属性,我们就可以使用 new RegExp(pattern, flags)
构造函数去创建一个正则表达式了。
const { source, flags } = /\d/g const newRegex = new RegExp(source, flags) // /\d/g
但需要注意的是,正则表达式有一个 lastIndex
属性,该属性可读可写,其值为整型,用来指定下一次匹配的起始索引。在设置了 global
或 sticky
标志位的情况下(如 /foo/g
、/foo/y
),JavaScript RegExp
对象是有状态的。他们会将上次成功匹配后的位置记录在 lastIndex
属性中。
因此,上述拷贝正则表达式的方式是有缺陷的。看示例:
const re1 = /foo*/g const str = 'table football, foosball' let arr while ((arr = re1.exec(str)) !== null) { console.log(`Found ${arr[0]}. Next starts at ${re1.lastIndex}.`) } // 以上语句会输出,以下结果: // "Found foo. Next starts at 9." // "Found foo. Next starts at 19." // 当我们修改 re1 的 lastIndex 属性时,输出以下结果: re1.lastIndex = 9 while ((arr = re1.exec(str)) !== null) { console.log(`Found ${arr[0]}. Next starts at ${re1.lastIndex}.`) } // "Found foo. Next starts at 19." // 以上这些相信你们都都懂。
所以,你可以发现以下示例,打印结果是不一致的,原因就是使用 RegExp
构造函数去创建一个正则表达式时,lastIndex
会默认设为 0
。
const re1 = /foo*/g const str = 'table football, foosball' let arr // 修改 lastIndex 属性 re1.lastIndex = 9 // 基于 re1 拷贝一个正则表达式 const re2 = new RegExp(re1.source, re1.flags) console.log('re1:') while ((arr = re1.exec(str)) !== null) { console.log(`Found ${arr[0]}. Next starts at ${re1.lastIndex}.`) } console.log('re2:') while ((arr = re2.exec(str)) !== null) { console.log(`Found ${arr[0]}. Next starts at ${re2.lastIndex}.`) } // re1: // expected output: "Found foo. Next starts at 19." // re2: // expected output: "Found foo. Next starts at 9." // expected output: "Found foo. Next starts at 19."
因此:
const deepCopy = source => { // 其他不变,省略... // 处理正则表达式 const handleRegExp = regex => { const { source, flags, lastIndex } = regex const re = new RegExp(source, flags) re.lastIndex = lastIndex return re } // 拷贝(递归思路) const copy = input => { if (typeof input === 'function' || !isObject(input)) return input // 正则表达式 if (getClass(input) === '[object RegExp]') { return handleRegExp(input) } // 后面不变,省略... } return copy(source) }
打印结果也是符合预期的:
由于 RegExp.prototype.flags
是 ES6 新增属性,我们可以看下 ES5 是如何实现的(源自 Lodash):
/** Used to match `RegExp` flags from their coerced string values. */ var reFlags = /\w*$/; /** * Creates a clone of `regexp`. * * @private * @param {Object} regexp The regexp to clone. * @returns {Object} Returns the cloned regexp. */ function cloneRegExp(regexp) { var result = new regexp.constructor(regexp.source, reFlags.exec(regexp)); result.lastIndex = regexp.lastIndex; return result; }
但还是那句话,都 2021 年了,兼容 ES5 的问题就放心交给 Babel 吧。
3.8 处理原型
注意,这里只实现类型为
"[object Object]"
的对象的原型拷贝。例如数组等不处理,因为这些情况实际场景太少了。
主要是修改以下这一步骤:
const output = isArray(input) ? [] : {}
主要利用 Object.create()
来创建 output
对象,改成这样:
const initCloneObject = obj => { // 处理基于 Object.create(null) 或 Object.create(Object.prototype.__proto__) 的实例对象 // 其中 Object.prototype.__proto__ 就是站在原型顶端的男人 // 但我留意到 Lodash 库的 clone 方法对以上两种情况是不处理的 if (obj.constructor === undefined) { return Object.create(null) } // 处理自定义构造函数的实例对象 if (typeof obj.constructor === 'function' && (obj !== obj.constructor || obj !== Object.prototype)) { const proto = Object.getPrototypeOf(obj) return Object.create(proto) } return {} } const output = isArray(input) ? [] : initCloneObject(input)
来看下打印结果,可以看到 source
的原型对象已经拷贝过来了:
再来看下 Object.create(null)
的情况,也是预期结果。
我们可以看到 Lodash 的 _.cloneDeep(Object.create(null))
深拷贝方法并没有处理这种情况。当然了,要拷贝这种数据结构在实际应用场景,真的少之又少...
关于 Lodash 拷贝方法为什么不实现这种情况,我找到了一个相关的 Issue #588:
A shallow clone won't do that as it's just
_.assign({}, object)
and a deep clone is loosely based on the structured cloning algorithm and doesn't attempt to clone inheritance or lack thereof.
四、优化
综上所述,完整但未优化的深拷贝方法如下:
const deepCopy = source => { // 创建一个 WeakMap 对象,记录已拷贝过的对象 const weakmap = new WeakMap() // 获取数据类型 const getClass = x => Object.prototype.toString.call(x) // 判断是否为数组 const isArray = arr => getClass(arr) === '[object Array]' // 判断是否为引用类型 const isObject = obj => obj !== null && (typeof obj === 'object' || typeof obj === 'function') // 判断是否为包装对象 const isWrapperObject = obj => { const theClass = getClass(obj) const type = /^\[object (.*)\]$/.exec(theClass)[1] return ['Boolean', 'Number', 'String', 'Symbol', 'BigInt', 'Date', 'Map', 'Set'].includes(type) } // 处理包装对象 const handleWrapperObject = obj => { const type = getClass(obj) switch (type) { case '[object Boolean]': return Object(Boolean.prototype.valueOf.call(obj)) case '[object Number]': return Object(Number.prototype.valueOf.call(obj)) case '[object String]': return Object(String.prototype.valueOf.call(obj)) case '[object Symbol]': return Object(Symbol.prototype.valueOf.call(obj)) case '[object BigInt]': return Object(BigInt.prototype.valueOf.call(obj)) case '[object Date]': return new Date(obj.valueOf()) // new Date(+obj) case '[object Map]': { const map = new Map() obj.forEach((item, key) => { map.set(key, copy(item)) }) return map } case '[object Set]': { const set = new Set() obj.forEach(item => { set.add(copy(item)) }) return set } default: return undefined } } // 处理正则表达式 const handleRegExp = regex => { const { source, flags, lastIndex } = regex const re = new RegExp(source, flags) re.lastIndex = lastIndex return re } const initCloneObject = obj => { if (obj.constructor === undefined) { return Object.create(null) } if (typeof obj.constructor === 'function' && (obj !== obj.constructor || obj !== Object.prototype)) { const proto = Object.getPrototypeOf(obj) return Object.create(proto) } return {} } // 拷贝(递归思路) const copy = input => { if (typeof input === 'function' || !isObject(input)) return input // 正则表达式 if (getClass(input) === '[object RegExp]') { return handleRegExp(input) } // 针对已拷贝过的对象,直接返回(解决循环引用的问题) if (weakmap.has(input)) { return weakmap.get(input) } // 处理包装对象 if (isWrapperObject(input)) { return handleWrapperObject(input) } const output = isArray(input) ? [] : initCloneObject(input) // 记录每次拷贝的对象 weakmap.set(input, output) for (let key in input) { if (input.hasOwnProperty(key)) { const value = input[key] output[key] = copy(value) } } // 处理以 Symbol 值作为属性键的属性 const symbolArr = Object.getOwnPropertySymbols(input) if (symbolArr.length) { for (let i = 0, len = symbolArr.length; i < len; i++) { if (input.propertyIsEnumerable(symbolArr[i])) { const value = input[symbolArr[i]] output[symbolArr[i]] = copy(value) } } } return output } return copy(source) }
接下来就是优化工作了...
4.1 优化一
我们上面使用到了 for...in
和 Object.getOwnPropertySymbols()
方法去遍历对象的属性(包括字符串属性和 Symbol 属性),还涉及了可枚举属性和不可枚举属性。
- for...in:遍历自身和继承过来的可枚举属性(不包括 Symbol 属性)。
- Object.keys:返回一个数组,包含对象自身所有可枚举属性(不包括不可枚举属性和 Symbol 属性)
- Object.getOwnPropertyNames:返回一个数组,包含对象自身的属性(包括不可枚举属性,但不包括 Symbol 属性)
- Object.getOwnPropertySymbols:返回一个数组,包含对象自身的所有 Symbol 属性(包括可枚举和不可枚举属性)
- Reflect.ownKeys:返回一个数组,包含自身所有的属性(包括 Symbol 属性,不可枚举属性以及可枚举属性)
由于我们仅拷贝可枚举的字符串属性和可枚举的 Symbol 属性,因此我们将
Reflect.ownKeys()
和Object.prototype.propertyIsEnumerable()
结合使用即可。
所以,我们将以下这部分:
for (let key in input) { if (input.hasOwnProperty(key)) { const value = input[key] output[key] = copy(value) } } // 处理以 Symbol 值作为属性键的属性 const symbolArr = Object.getOwnPropertySymbols(input) if (symbolArr.length) { for (let i = 0, len = symbolArr.length; i < len; i++) { if (input.propertyIsEnumerable(symbolArr[i])) { const value = input[symbolArr[i]] output[symbolArr[i]] = copy(value) } } }
优化成:
// 仅遍历对象自身可枚举的属性(包括字符串属性和 Symbol 属性) Reflect.ownKeys(input).forEach(key => { if (input.propertyIsEnumerable(key)) { output[key] = copy(input[key]) } })
4.2 优化二
优化 getClass()
、isWrapperObject()
、handleWrapperObject()
、handleRegExp()
及其相关的类型判断方法。
由于 handleWrapperObject()
原意是处理包装对象,但是随着后面要处理的特殊对象越来越多,为了减少文章篇幅,暂时都写在里面了,稍微有点乱。
因此下面我们来整合一下,部分处理函数可能会修改函数名。
五、最终
其实,上面提到的一些边界 Case、或者其他一些特殊对象(如 ArrayBuffer
等),这里并没有处理,但我认为该完结了,因为这些在实际应用场景真的太少了。
代码已丢到 GitHub toFrankie/Some-JavaScript-File。
还是那句话:
如果生产环境使用
JSON.stringify()
无法解决你的需求,请使用 Lodash 库的_.cloneDeep()
方法,那个才叫面面俱到。千万别用我这方法,切记!
这篇文章主要面向学习、面试(手动狗头),或许也可以帮助你熟悉一些对象的特性。如有不足,欢迎指出,万分感谢
终于终于终于......要写完了,吐了三斤血...
最终版本如下:
const deepCopy = source => { // 创建一个 WeakMap 对象,记录已拷贝过的对象 const weakmap = new WeakMap() // 获取数据类型,返回值如:"Object"、"Array"、"Symbol" 等 const getClass = x => { const type = Object.prototype.toString.call(x) return /^\[object (.*)\]$/.exec(type)[1] } // 判断是否为数组 const isArray = arr => getClass(arr) === 'Array' // 判断是否为引用类型 const isObject = obj => obj !== null && (typeof obj === 'object' || typeof obj === 'function') // 判断是否为“特殊”对象(需要特殊处理) const isSepcialObject = obj => { const type = getClass(obj) return ['Boolean', 'Number', 'String', 'Symbol', 'BigInt', 'Date', 'Map', 'Set', 'RegExp'].includes(type) } // 处理特殊对象 const handleSepcialObject = obj => { const type = getClass(obj) const Ctor = obj.constructor // 对象的构造函数 const primitiveValue = obj.valueOf() // 获取对象的原始值 switch (type) { case 'Boolean': case 'Number': case 'String': case 'Symbol': case 'BigInt': // 处理包装对象 Wrapper Object return Object(primitiveValue) case 'Date': return new Ctor(primitiveValue) // new Date(+obj) case 'RegExp': { const { source, flags, lastIndex } = obj const re = new RegExp(source, flags) re.lastIndex = lastIndex return re } case 'Map': { const map = new Ctor() obj.forEach((item, key) => { // 注意,即使 Map 对象的 key 为引用类型,这里也不能 copy(key),否则会失去引用,导致该属性无法访问得到。 map.set(key, copy(item)) }) return map } case 'Set': { const set = new Ctor() obj.forEach(item => { set.add(copy(item)) }) return set } default: return undefined } } // 创建输出对象(原型拷贝关键就在这一步) const initCloneObject = obj => { if (obj.constructor === undefined) { return Object.create(null) } if (typeof obj.constructor === 'function' && (obj !== obj.constructor || obj !== Object.prototype)) { const proto = Object.getPrototypeOf(obj) return Object.create(proto) } return {} } // 拷贝方法(递归思路) const copy = input => { if (typeof input === 'function' || !isObject(input)) return input // 针对已拷贝过的对象,直接返回(解决循环引用的问题) if (weakmap.has(input)) { return weakmap.get(input) } // 处理包装对象 if (isSepcialObject(input)) { return handleSepcialObject(input) } // 创建输出对象 const output = isArray(input) ? [] : initCloneObject(input) // 记录每次拷贝的对象 weakmap.set(input, output) // 仅遍历对象自身可枚举的属性(包括字符串属性和 Symbol 属性) Reflect.ownKeys(input).forEach(key => { if (input.propertyIsEnumerable(key)) { output[key] = copy(input[key]) } }) return output } return copy(source) }
六、参考
The end.