此前写过一篇文章: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 字符串后才能传输。
此前写过一篇文章,介绍了 JSON 和 JavaScript 的关系以及上述两个方法的一些细节。可看:详谈 JSON 与 JavaScript。
如果自己实现一个深拷贝的方法,其实是有很多边界问题要处理的,至于这些种种的边界 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)
}
六、参考
- GitHub/lodash
- 原生 JS 灵魂之问(中),检验自己是否真的熟悉 JavaScript?
The end.