[ 原生 JS 造轮子 ] - 四:深浅克隆

302 阅读6分钟

系列文章

说在前面的

关于深克隆,在前端已经是一个老生常谈的问题了,事实上应该大部分同学对此都有一定的了解,也有基本的实现思路了。然而深克隆真的这么简单吗?里面有没有一些鲜为人知的坑?如果有,是什么,怎么解决?本文今天将带着大家复习一下深浅克隆的实现原理,由浅入深的将其中的问题一一剖析。相信读完本文,大家都能自己实现一个严谨的深克隆。

什么是深浅克隆

大家都知道 js 中的变量分两种类型

  • 基本类型
  • 引用类型

这里不死板的将各个变量分别列在两种类型中,太难背了,只要记住,日常开发中:Object 和 Array 都是引用类型(事实上,从 js 的角度出发,Array 也是一种 Object),而数字和字符串都是基本类型即可。

通常我们将变量赋值给一个基本类型,就是在内存中为这个变量开辟了一块对应大小的空间,将值存在里面,如图所示:

img-01

如果我们将 a 赋值给另一个变量 b,则会同样为 b 开辟一块同样的内存,用于存放这个值,如图所示:

img-02

这个时候,如果我们修改 b 的值,并不会影响到 a。

而引用类型与基本类型不同的是,当我们将 objA 赋值给 objB 的时候,内存并不会新开辟一块空间用于存储 objB,而是会让 objB 指向 objA 所在的内存,如图所示:

img-03

显然,这个时候,如果我们修改 objB 的值,就会修改到对应内存上的值,就会影响到 objA。由此,就引出了我们的浅克隆,至于为什么是浅克隆,看到后面就明白了。

浅克隆的实现思路很简单:既然我直接把 objA 给 objB 给的是指针,会导致自己被影响,那就新创建一个 obj,把 objA 的属性分别复制过去不就行了吗?

function shallowClone(obj) {
if (typeof obj !== 'object') return obj
const res = {}
Object.keys(obj).forEach(name => {
res[name] = obj[name]
})
return res
}

那么什么是深克隆呢?

显然,如果我们被克隆的对象包含对象元素,我们仅仅对表层元素的遍历和复制,新生成的 objB 中的对象元素和原来的 objA 中的对象元素的指针,依然是指向同一片内存的。所以深克隆的核心就出来了:递归

遍历原对象的每一个元素,直到元素中不存在对象为止,将所有的基本元素依次复制到新对象中。这就是深克隆的核心功能了。

实现一个深克隆

知道了核心思路,手写一个深克隆就很简单了。

function deepClone(obj) {
if (typeof obj !== 'object') return obj
const res = {}
Object.keys(obj).forEach(name => {
res[name] = typeof obj[name] === 'object' ? deepClone(obj[name]) : obj[name]
})
return res
}

很简单,不到 10 行代码就搞定了深克隆,我们来测试看看结果:

const kun = {
name: 'Kun',
age: 18,
skill: {
sing: 'good',
jump: 'good',
rap: 'good'
}
}
const kunkun = deepClone(kun)

kunkun.skill.basketball = 'outstanding'
console.log(kun)
console.log(kunkun)

结果如下:

img-04

很好,已经实现了深克隆了。但是,真的这么简单吗?

让我们扩大测试范围来看看:

const kun = {
name: 'Kun',
age: 18,
skill: {
sing: 'good',
jump: 'good',
rap: 'good'
},
hobby: ['sing', 'jump', 'rap', 'basketball'], // 转 obj
key: new RegExp('rap', 'g'), // 转空 obj
sing: function() {
return 'sing'
}, // 丢失 constructor 指针
birthday: new Date() // 丢失
}
const kunkun = deepClone(kun)

kunkun.skill.basketball = 'outstanding'
console.log(kun)
console.log(kunkun)

通过上面的测试,我们发现,目前实现的深克隆,还是非常不完善的,其具体体现在 JS 对于不同类型的数据应该有不同的复制方法。那么我们先来实现一个方法用于判断对象的类型:

const getType = obj => {
const typeStr = Object.prototype.toString.call(obj)
switch (typeStr) {
case '[object Array]':
return 'Array'
case '[object Date]':
return 'Date'
case '[object RegExp]':
return 'RegExp'
case '[object Function]':
return 'Function'
case '[object Object]':
return 'Object'
default:
return
}
}

在我们改进代码之前,需要补充一点关于正则的知识点:完全克隆一个正则对象,我们需要通过它的扩展来获取其 flag 属性

const getRegExp = re => {
let flags = ''
if (re.global) flags += 'g'
if (re.ignoreCase) flags += 'i'
if (re.multiline) flags += 'm'
return flags
}

现在可以着手优化我们的深克隆了:

const deepClone = obj => {
if (obj === null) return null
if (typeof obj !== 'object') return obj

let res, proto, copy
let type = getType(obj)
switch (type) {
case 'Array':
res = []
break
case 'Date':
res = new Date(obj.getTime())
break
case 'RegExp':
res = new RegExp(obj.source, getRegExp(obj))
if (obj.lastIndex) res.lastIndex = obj.lastIndex
break
default:
proto = Object.getPrototypeOf(obj)
res = Object.create(proto)
break
}

Object.keys(obj).forEach(name => {
copy = obj[name]
res[name] = deepClone(copy)
})

return res
}

来测试一下:

img-05

可以看到,结果已经比较令人满意了。那么深克隆这里,除了类型以外,还有别的坑吗?

让我们再做个测试看看:

const kun = {
name: 'Kun',
age: 18,
skill: {
sing: 'good',
jump: 'good',
rap: 'good'
},
hobby: ['sing', 'jump', 'rap', 'basketball'],
key: new RegExp('rap', 'g'),
sing: function() {
return 'sing'
},
birthday: new Date()
}
kun.me = kun
const kunkun = deepClone(kun)

img-05

当出现循环引用的时候,直接就报错了。显然,这并不是我们想看到的结果。

为了解决这个问题,我们需要维护两个循环引用的数组,并且这两个数组需要在递归的外部存在,所以需要在封装一个内部的递归函数,最终解决方案如下:

const deepClone = parent => {
const parentList = []
const childList = []

const _deepClone = parent => {
let type, child, proto, index, copy

if (parent === null) return null
if (typeof parent !== 'object') return parent

type = getType(parent)
switch (type) {
case 'Array':
child = []
break
case 'Date':
child = new Date(parent.getTime())
break
case 'RegExp':
child = new RegExp(parent.source, getRegExp(parent))
if (parent.lastIndex) child.lastIndex = parent.lastIndex
break
default:
proto = Object.getPrototypeOf(parent)
child = Object.create(proto)
break
}

index = parentList.indexOf(parent)
// 父数组存在本对象,说明被引用过,直接返回该对象
if (index !== -1) return childList[index]
parentList.push(parent)
childList.push(child)

Object.keys(parent).forEach(name => {
copy = parent[name]
child[name] = _deepClone(copy)
})

return child
}

return _deepClone(parent)
}

结束语

本文从浅克隆到深克隆,再到类型处理和循环引用,逐渐深入实现了一个深克隆方法,而事实上,距离一个非常完善的深克隆,还有不远的距离,如 BufferPromisesetmap 等等可能都需要我们做特殊处理,当然,本质上于前面的类型处理差异不大,所以这里不再深入探究,有兴趣的小伙伴可以自己尝试完善一下。

另外,目前在生产环境中最好用的是 lodash 的深克隆实现,当然,除了使用,还是要知道原理,这样,面试官问起的时候,也能说得头头是道。加入只知道使用,或者只知道 序列化/反序列化 这样取巧的技巧,可能还会给别人留下只知道皮毛的印象。