前言
前两天刚好去面试,刚好碰到面试官问到 如何实现深度克隆?,现在总结下思路。
我们数据是存储在内存中,学过后端到同学应该清楚,存储有两种类型
- 值类型
- 引用类型
值类型 在内存中储存的是值本身,存储在栈里面,引用类型 是数据的引用,它分为两块存储:引用也就是内存地址,存储在栈里面,实际数据存储在堆里面。
克隆分为 浅克隆和 深克隆,浅克隆 实际是 “第一层拷贝”,深克隆 是递归多层拷贝。
以常规对象为例:
const obj = {
name: 'cola',
info: {
age: 1
}
}
“第一层拷贝” 只是第一层数据值的拷贝,比如:[name, info],因为 info 是 引用类型,所以对它进行拷贝实际是引用地址的拷贝,当我们修改 obj.info.age 时,会发现原有数据也会发生变化,比如:
const obj = {
name: 'xiaoming',
info: {
age: 1
}
}
const newObj = Object.assign({}, obj);
newObj.name = 'xiaohong'
newObj.info.age = 2
console.log(obj) // {name: 'xiaoming', info: { age: 2 }}
接下来我们来看看 JavaScript 中,浅克隆 和 深克隆 的实现方式吧
浅克隆
在 JavaScript 中,我们常用对象有两种:普通对象和数组,我们分为看下它们的 浅克隆(值类型不需要讨论,直接赋值即可)。
针对普通对象,一般使用
Object.assignObject.create扩展运算符
针对数组,一般使用
Array.prototype.slice扩展运算符Array.fromArray.prototype.concatObject.createObject.assign
深克隆
提到深拷贝,你会想到几种方式呢?
本文将介绍六种实现方式
- JSON.parse 和 JSON.stringify
- MessageChannel
- Notification API
- History API
- structuredClone
- 自己实现或三方库
JSON.parse
JSON.parse 和 JSON.stringify 想必是经常使用的方式了,实现深克隆非常简单:
const obj = {}
const newObj = JSON.parse(JSON.stringify(obj))
console.log(newObj)
但是这种方式存在以下缺陷:
- 忽略
undefined、symbol、function,如果存在数组中,则转换为null - 不能处理循环引用,如果存在会报错
- 所有以
symbol为键的会忽略,即使在replacer强制指定 - NaN 和 Infinity 格式的数值及 null 都会被当做 null。
const obj = {
a: {
b: {
c: 1,
d: void 0,
e: Symbol.for('symbol'),
f: function(){},
g: NaN
}
}
}
obj.a.b.h = obj
const newObj = JSON.parse(JSON.stringify(obj, (k, v) => {
if(typeof v === 'undefined') return ''
if(typeof v === 'function') return v.toString()
if(typeof k === 'symbol') return v
return v
}))
console.log(newObj)
我们可以看到,对于一些场景,我们可以使用 replacer 进行操作,但是有些场景还是无法覆盖。
MessageChannel
function deepCopy(obj) {
return new Promise((resolve, reject) => {
const channel = new MessageChannel();
channel.port1.onmessage = (event) => {
resolve(event.data);
};
channel.port2.postMessage(obj);
});
}
const obj = {
a: {
b: {
c: 1,
d: void 0,
e: Symbol.for('symbol'),
f: function(){},
g: NaN
}
}
}
obj.a.b.h = obj
// throw error
const newObj = await deepCopy(obj)
console.log(newObj)
MessageChannel 解决了 JSON.parse 部分问题,但是依然一些问题:
- 不能拷贝函数,会报错
- 不能拷贝
Symbol,会报错 - 解决循环引用和
undefined问题 - Symbol为键还是会忽略
Notification API
Notification 也可以用来深度拷贝吗?当然可以
const obj = {
a: {
b: {
c: 1,
d: void 0,
e: Symbol.for('symbol'),
f: function(){},
g: NaN
}
}
}
obj.a.b.h = obj
const newObj = new Notification('', {data: obj, silent: true}).data;
console.log(newObj)
它的作用和 MessageChannel 一模一样
History API
用过 vue 的朋友都知道 vue-router,它的底层使用 history api 进行路由的导航,但是你有没有想过,它会和 深克隆 扯上关系
const obj = {
a: {
b: {
c: 1,
d: void 0,
e: Symbol.for('symbol'),
f: function(){},
g: NaN,
[Symbol.for('symbol')]: 'symbol'
}
}
}
obj.a.b.h = obj
function deepCopy(obj){
const old = history.state
history.replaceState(obj, document.title)
const value = history.state
history.replaceState(old, document.title)
return value
}
const newObj = deepCopy(obj)
console.log(newObj)
它的作用也和 MessageChannel 一模一样
structuredClone
浏览器新增 structuredClone API,它的兼容性如下:
const obj = {
a: {
b: {
c: 1,
d: void 0,
e: Symbol.for('symbol'),
f: function(){},
g: NaN,
[Symbol.for('symbol')]: 'symbol'
}
}
}
obj.a.b.h = obj
const newObj = structuredClone(obj)
console.log(newObj)
它的作用也和 MessageChannel 一模一样
自定义实现
根据以上表现,自行封装 deepCopy
基础实现
function deepCopy(obj) {
const res = Object.create(null)
for (const [key, value] of Object.entries(obj)) {
if (value && typeof value === 'object') {
res[key] = deepCopy(value)
} else {
res[key] = value
}
}
return res
}
考虑特殊类型
JavaScript 中有一些内置的对象,比如:Function、RegExp、Date
另外 ES6 新增的对象也需要考虑,比如:Map、Set、Symbol
function init(obj) {
if (obj instanceof Map) {
return new obj.constructor()
}
if (obj instanceof Set) {
return new obj.constructor()
}
const properties = Object.getOwnPropertyDescriptors(obj)
return Object.create(Object.getPrototypeOf(obj), properties)
}
function deepCopy(obj) {
if (obj && typeof obj === 'object') {
if (obj instanceof Date) return new Date(obj)
if (obj instanceof RegExp) return new RegExp(obj)
if (typeof obj === 'symbol') {
return Symbol(obj.description)
}
let res = init(obj)
if (obj instanceof Map) {
obj.forEach((value, key) => {
res.set(key, deepCopy(value))
})
return res
}
if (obj instanceof Set) {
obj.forEach((value) => {
res.add(deepCopy(value))
})
return res
}
for (const [key, value] of Object.entries(obj)) {
if (value && typeof value === 'object') {
res[key] = deepCopy(value)
} else {
res[key] = value
}
}
return res
}
return obj
}
考虑边界场景
考虑循环引用和函数
function cloneFunc(func) {
const paramsReg = /(?<=\().+(?=\)[\s+]?\{)/m
const bodyReg = /(?<={)(.|[\n|\n\r]?)+(?=})/m
const func_string = func.toString()
if (func.prototype) {
const body = bodyReg.exec(func_string)
const params = paramsReg.exec(func_string)
if (body) {
if (params) {
params = params[0].split(',')
return new Function(...params, body[0])
} else {
return Function(body[0])
}
} else {
return null
}
} else {
return eval(func_string)
}
}
function init(obj) {
if (obj instanceof Map) {
return new obj.constructor()
}
if (obj instanceof Set) {
return new obj.constructor()
}
const properties = Object.getOwnPropertyDescriptors(obj)
return Object.create(Object.getPrototypeOf(obj), properties)
}
function deepCopy(obj, map = new WeakMap()) {
if (obj && typeof obj === 'object') {
if (obj instanceof Date) return new Date(obj)
if (obj instanceof RegExp) return new RegExp(obj)
if (typeof obj === 'symbol') {
return Symbol(obj.description)
}
if (typeof obj === 'function') return cloneFunc(obj)
let res = init(obj)
if (obj instanceof Map) {
obj.forEach((value, key) => {
res.set(key, deepCopy(value, map))
})
return res
}
if (obj instanceof Set) {
obj.forEach((value) => {
res.add(deepCopy(value, map))
})
return res
}
if (map.get(obj)) return map.get(obj)
map.set(obj, res)
for (const [key, value] of Object.entries(obj)) {
if (value && typeof value === 'object') {
res[key] = deepCopy(value, map)
} else {
res[key] = value
}
}
return res
}
return obj
}
const obj = {
a: {
b: {
c: 1,
d: void 0,
e: Symbol.for('symbol'),
f: function(){},
g: NaN,
[Symbol.for('symbol')]: 'symbol'
}
}
}
obj.a.b.h = obj
const newObj = deepCopy(obj)
console.log(newObj)
好了,现在克隆全部完结。
测试
如何进行测试呢?
首先需要 mock 数据,你可以简单的生成 1000 条数据,然后调用不同克隆方法执行它。但是 V8 有一个机制:当你添加属性到一个对象时,V8有一个缓存,所以其实是在给缓存做基准测试。为了确保数据永远不会碰到缓存,需要编写一个随机函数,每次生成不同的对象,然后在运行测试。以下是运行的结果:
这是我扩展测试的 代码
数值越小,效果越好!