你真的了解js中克隆对象吗?

243 阅读12分钟

相信身为前端的你,在面试时有时也会被问到一个问题

面试官:你能说说浅克隆和深克隆的区别?它们分别适用于什么场景,又分别有哪些实现方法,又有哪些区别?

** 本文中有一些方法的思路、图片和代码等来自loadsh源码或者网络上的网文,有兴趣的可以去看看,这里我使用的Node版本为19,如有涉及到侵权问题,可随时联系修正删除等 **

相信有一部分前端人员可能就只会提到浅克隆是复制对象中的第一层对象,而深克隆是需要进行循环里面嵌套的每一层引用对象

哈哈哈,以前,身为弱鸡前端人员的我,可能也是只会说出这个来,这个答案只能说明你对克隆有了解过,但并不深入,今天,咱就来就我的理解来说一说这个问题,如果你看到这篇问题,认为说的有问题或者不对的地方或者太片面,欢迎补充和指正。。。

要理清这个问题的本质,首先还得理解下js中的数据传递方式。

那么,相信大家都清楚,在js中,截止到目前,已经有8种数据类型了,不知道的,可自行查阅文档资料补充下基础,这里就不在赘述了。它们又可分别分为以下两大类:

  1. 基本数据类型
  2. 引用数据类型

. 在理清克隆的本质前,先来了解下它们对于理解克隆还是很有帮助的

一. 基本数据类型

基本类型存放在栈区,访问时按值访问,赋值是按照普通方式赋值

  1. 如果一个基本的数据类型绑定到某个变量,我们可以认为该变量包含这个基本数据类型的值。
let x = 10
let y = 'abc'
let z = null
  1. 当我们使用 = 对这些基本数据类型进行过赋值操作时,实际上是将对应的值拷贝了一份,然后赋值给新的变量。我们把它称作值传递。
let a = 11
let b = 'ab'

let aa = a
let bb = b

console.log(a, b, aa, bb) // 11, ab, 11, ab
  1. a 和 aa 都包含 11, 并且他们是相互独立的拷贝,互不干涉,如果我们将 a 的值改变,aa 不会受到影响。
a = 1111
console.log(a, aa) // 1111, 11

b = 'abcd'
console.log(b, bb) // abcd, ab

二、引用数据类型

引用类型指的是对象,此对象并非只局限于单纯的object类型的变量。它可以拥有属性和方法,并且咱可以修改其属性和方法。引用对象存放的方式是:在栈中存放对象变量标示名称和该对象在堆中的存放地址,在堆中存放数据。

对象使用的是引用赋值。当我们把一个对象赋值给一个新的变量时,赋的其实是该对象的在堆中的地址,而不是堆中的数据。也就是两个对象指向的是同一个存储空间,无论哪个对象发生改变,其实都是改变的存储空间的内容,因此,两个对象是联动的。

比如,咱一个变量绑定到一个非基本数据类型(Array, Function, Object),那么它就会为这个变量记录一个内存地址,该地址会讲具体的数据存入。注意之前提到指向基本数据类型的变量相当于包含了数据,而现在指向非基本数据类型的变量本身是不包含数据的。它一般有如下特性:

  1. 对象会在内存中被创建,比如,当我们声明一个数据arr=[]时,js会在内存中开辟一块空间,用来记录该变量对应内存中的引用地址
let arr = [1, 2, 3]

当执行完之后,内存中创建了一个空的数组对象,其内存地址为 #001 ,arr 指向该地址

变量引用地址对象
arr#001[1, 2, 3]
  1. 对象是通过引用传递,而不是值传递。也就是说,变量赋值只会将地址传递过去
let arr2 = arr
console.log(arr, arr2) // [1, 2, 3], [1, 2, 3]
变量内存地址对象
arr#001[1, 2, 3]
arr2#001(↑)
  1. arr 和 arr2 指向同一个数组。 如果我们更新 arr,arr2 也会受到影响
arr.push(4)
console.log(arr, arr2) // [1, 2, 3, 4], [1, 2, 3, 4]

变量内存地址对象
arr#001[1, 2, 3, 4]
arr2#001(↑)
  1. 引用重新赋值:如果我们将一个已经赋值的变量重新赋值,那么它将包含新的数据或则引用地址。如果原来的对象内容没有任何变量去引用,JS 就会释放掉原来的对象内存。
let obj = { a: 1 }
console.log(obj) // {a: 1}
变量地址对象
obj#0001{ a: 1 }
obj = { a: 1, b: 2 }
console.log(obj) // {a: 1, b: 2}
变量地址对象
(空)#0001{a: 1}
obj#0002{a: 1, b: 2}

好了,废话了一大篇,相信你也基本了解了js中值传递的方式以及为何需要克隆对象了,总结一下:

当两个对象指向同一个地址时,在修改其中一个对象中的属性值时,又不影响到原来的对象本身,此时就需要对对象进行克隆

好了,废话了半篇,终于要进入今天的主题了

三. 那么应该如何实现对象的克隆呢

众所周知,在js中,存在两种克隆对象的方式,一种是浅克隆,一种深度克隆,那么它们分别是怎么实现的,又有哪些实现方式了,接下来,我将一一介绍下我所知晓的N种方式,如果你有更好的方式,欢迎补充

1. 浅克隆

概念:浅拷贝创建一个新的对象,只拷贝一层,即拷贝对象里第一层基本数据类型的值和引用类型的地址

主要存在的问题:如果对象中又包含引用类型的属性值,则会导致在克隆后,克隆的过来的新对象和原本的对象之间依然会存在共用同一个引用类型的属性值。修改它们其中的一个,还是会相互影响,比如:

var objA = {
    a: 1,
    b: { c: 'c' }
};

var objB = {};

Object.assign(objB, objA);

console.log(' a -> b', objA, objB);

objB.a = 2;

console.log(' a -> b', objA, objB);

objB.b.c = 'b.c的值被修改了';

console.log(' a -> b', objA, objB);

运行结果如下:

看,我们在修改了objB中a属性值后,原本objA中a属性值并没有变,而当我们修改了objB中的引用属性b中的c的值,原本objA中对应位置的值也跟着变化了,它们里面的引用类型的属性还是能够相互影响

接下来,看下实现浅克隆的几种方式:

方案一. 利用es5中的Object.assign函数

demo可见上面的示例

关于Object.assign的具体玩法,可参考:developer.mozilla.org/zh-CN/docs/…

方案二. 利用es6提供的展开运算符Spread ...

var objA = {
    a: 1,
    b: { c: 'c' }
};

var objB = { ...objA };

console.log(' a -> b', objA, objB);

objB.a = 2;

console.log(' a -> b', objA, objB);

objB.b.c = 'b.c的值被修改了';

console.log(' a -> b', objA, objB);

运行结果如下:

关于 ...拓展符 具体玩法,可参考: 阮一峰对于es6的介绍文档

方案三. 利用第三方工具库,如:lodash、undescore等

这种可自行运行示例查看效果,就不多举例了

方案四. 自己实现shadowClone

/** 浅比较的简单实现 */
function shadowClone(obj) {
    // 当对象不存在或者为空对象时,直接返回
    if (!obj && !Object.keys(obj).length) return obj;
    const result = {};
    for (let k in obj) {
        const value = obj[k];
        result[k] = value;
    }
    return result;
}

对于纯数组也可以用以下几种方式实现

1. Array.prototype.concat()

语法:arr.concat(value0, /* … ,*/ valueN)

注:如果省略了所有 valueN 参数,则 concat 会返回调用此方法的现存数组的一个浅拷贝。

示例:

let arr = [1, 2, { name: 'Tom' }]
let arr_new = arr.concat()
console.log(arr === arr_new)  // false
console.log(arr[2] === arr_new[2])  // true

2. Array.prototype.slice

语法:arr.slice(begin, end)

注:如果省略了 beginend 参数,则 slice 会返回调用此方法的现存数组的一个浅拷贝。

示例:

let arr = [1, 2, { name: 'Tom' }]
let arr_new = arr.slice()
console.log(arr === arr_new)  // false
console.log(arr[2] === arr_new[2])  // true

这些就是目前我想到的一些实现浅克隆的方案,有不对的地方,欢迎大家指正,或你还有其它的一些方案,也随时欢迎进行补充,一起进步

2. 深克隆

概念:就是将整个对象克隆到另一个内存区块中去,让它们之间的各种操作互不影响

主要存在的问题

  1. 需要为新对象开辟一块新的内存区块, 内存占用变大
  2. 如果存在循环引用,就需要自行处理此类问题,一些第三方工具库基本已经帮忙处理过了,倒不必担心

这里咱以lodash中的deepclone来举个例子,如下:

let obj = {
  name: 'Tom',
  age: 15,
  hobby: ['eat', 'game'],
  favorite: {
      food: 'bread',
      drink: {
        dname: 'milk',
        color: 'white',
      },
  }
}
let obj_new = _.cloneDeep(obj)
console.log(obj)
console.log(obj_new)
console.log(obj.name === obj_new.name)
console.log(obj.favorite === obj_new.favorite)
console.log(obj.favorite.drink === obj_new.favorite.drink)

看下对应的值:

可以看见新旧对象所有属性及属性值完全相同

那再来细节对比一下,看看拷贝后对象的地址是否相同

最终运行的结果:

修改一下属性值,看看前后对比:

obj_new.name = 'Jerry'
obj_new.hobby[0] = 'sing'
obj_new.favorite.food = 'cheese'

看下修改后的值:

可以看到,在修改属性值之后,原对象和克隆猴的对象是没有相互影响

接下来,我们该看下改如何实现了。。。

一般,实现深克隆有如下几种方案,大家如果有更好的方案欢迎补充。。。

1. 利用第三方工具库,如:lodash、undescore等,这里以lodash为例,相信这个应该没有人没用过吧

let obj = {
    name: 'Tom',
    age: 15,
    hobby: ['eat', 'game'],
    favorite: {
        food: 'bread',
        drink: {
          dname: 'milk',
          color: 'white',
        },
    }
  }
  let obj_new = _.cloneDeep(obj)
  
  console.log(obj)
  console.log(obj_new)
  console.log(obj.name === obj_new.name)
  console.log(obj.favorite === obj_new.favorite)
  console.log(obj.favorite.drink === obj_new.favorite.drink)

最终运行结果:

2. JSON.parse(JSON.stringify(obj))

JSON.stringify() 将JSON格式的对象转为字符串

JSON.parse() 将JSON格式的字符串转为对象

这里有几点需要注意

  • 拷贝的对象的值中如果有函数,undefined,symbol则经过JSON.stringify()序列化后的JSON字符串中这个键值对会消失,这些值在如果是数组的值时,会被转换为null

  • 无法拷贝不可枚举的属性,无法拷贝对象的原型链

  • 拷贝Date引用类型会变成字符串

  • 拷贝RegExp引用类型会变成空对象

  • 对象中含有NaN、Infinity和-Infinity,则序列化的结果会变成null

  • 无法拷贝对象的循环应用(即obj[key] = obj)

3. 自己实现deepClone 方法

一. 普通递归实现,没有解决循环引用问题
/** 深克隆的简单实现 TODO: 其余分支待补充 */
function deepClone(obj) {
    // 当对象不存在或者为空对象时,直接返回
    if (!obj && !Object.keys(obj).length) return obj;
    const result = {};
    for (let k in obj) {
        const value = obj[k];
        const validation = validateType(value);
        if (validation.isDate) { // 日期
            result[k] = new value.constructor(+value);
        } else if (validation.isRegexp) { // 正则
            result[k] = cloneRegExp(value);
        } else if ([validation.isSet, validation.isMap].includes(true)) { // Set、Map
            result[k] = new value.constructor(value.valueOf());
        } else if (validation.isArray) { // 数组
            result[k] = initCloneArray(value).map(item => deepClone(item));
        } else if (validation.isSymbol) { // Symbol
            result[k] = cloneSymbol(value);
        } else if (validation.isObject) { // Object
            result[k] = deepClone(value);
        } else {
            result[k] = value;
        }
    }
    return result;
}

完整代码地址:深克隆

二. 递归解决循环引用问题
/** 深克隆的简单实现 TODO: 其余分支待补充 */
function deepClone(obj, map = new WeakMap()) {
    // 当对象不存在或者为空对象时,直接返回
    if (!obj && !Object.keys(obj).length) return obj;
    if (map.has(obj)) return map.get(obj);
    const result = {};
    map.set(obj, result);
    for (let k in obj) {
        const value = obj[k];
        const validation = validateType(value);
        if (validation.isDate) { // 日期
            result[k] = new value.constructor(+value);
        } else if (validation.isRegexp) { // 正则
            result[k] = cloneRegExp(value);
        } else if ([validation.isSet, validation.isMap].includes(true)) { // Set、Map
            result[k] = new value.constructor(value.valueOf());
        } else if (validation.isArray) { // 数组
            result[k] = initCloneArray(value).map(item => deepClone(item, map));
        } else if (validation.isSymbol) { // Symbol
            result[k] = cloneSymbol(value);
        } else if (validation.isObject) { // Object
            result[k] = deepClone(value, map);
        } else {
            result[k] = value;
        }
    }
    return result;
}

objA.obj = objA;

var objB = deepClone(objA);

console.log(' a -> b', objA, objB);

最终运行结果:

4. 通过MessageChannel实现

MessageChannel 介绍:MessageChannel创建了一个通信的管道,这个管道有两个端口,每个端口都可以通过postMessage方法发送数据,另一个端口只要调用onmessage方法,就可以接收从另一个端口传过来的数据。

主要存在的问题:对于Symbol、函数、Symbol、WeakMap、WeakSet类型的数据不能被克隆,有无其它问题,目前还不太清楚,也欢迎对此熟悉的补充

var s = 9;
var o = {};

var obj = {
    a: 1,
    arr: [3, 4],
    b: { c: 'c' },
    // c: function () {
    //     console.log('c');
    // },
    // d: () => {
    //     console.log('d');
    // },
    nan: NaN,
    e: new Date(),
    reg: new RegExp('\d', 'gi'),
    null: null,
    undef: undefined,
    // sym: Symbol(6666),
    map: new Map([[1, 2], [3, 4]]),
    set: new Set([{}, {}, 5, null, NaN, NaN, null, undefined, undefined, 4, 5, s, s, o, o]),
    // weakMap: new WeakMap([[{ a: 1 }, { a: 'a' }]]),
    // weakSet: new WeakSet([{ a: 1 }, { a: 'a' }]),
};

obj.myFn = obj;

// MessageChannel实现克隆,这个还能解决循环引用问题
function deepClone(obj) {
    return new Promise((resolve) => {
        const { port1, port2 } = new MessageChannel() // 实例化了一个 channel 对象
        port1.postMessage(obj)  // 通过postMessage方法把数据传递
        port2.onmessage = msg => { // 通过onmessage方法监听事件接收到信息
            resolve(msg.data)
        }
    })
}

deepClone(obj).then(res => {
    console.log(res);
})

最终运行结果:

这里也只是考虑到了某一些场景,真实情况肯定还会有好多情况需要考虑的,这里就不赘述了。。。

总算结束了,下面我们总结下吧

总结

  1. 自己封装deepClone方法虽然能实现对ES原生引用类型的拷贝,但是对于对象来说范围太广了,仍有很多无法准确拷贝的(比如DOM节点), 但是在日常开发中一般并不需要克隆很多特殊的引用类型,深克隆对象使用JSON.stringify依然是最方便的方法之一(在了解了JSON.stringify的优缺点后,就能更好的利用它并结合场景来实现深克隆)

  2. 实现一个完整的深克隆是非常复杂的, 需要考虑到很多边界情况, 这里我也只是对部分的原生的构造函数进行了深克隆, 对于特殊的引用类型有克隆需求的话, 非特殊业务场景,个人建议还是借助业内比较成熟的第三方工具库,毕竟它们很多人在用,也覆盖了咱们大部分的场景,能够大大的降低出现问题的可能

  3. 对于深入研究深拷贝的原理有助于理解JS引用类型的特点,以及遇到相关特殊的问题也能迎刃而解,对于提高JS的基础还是很有帮助的

参考资料

JavaScript高级程序设计