JavaScript:深拷贝的实现 ✂️
赋值与浅拷贝
在开发中,我们有时会遇到需要拷贝数据的情况。对于基本数据类型,如 number, string, undefined, null, boolean, bigint等,我们可以直接赋值。而对于 object 类对象,如果依然采用赋值的方式进行拷贝,则会遇到问题。
在 JavaScript 中,对象的浅拷贝与其源对象的属性共享相同引用。一言以蔽之,如果属性是基本类型,拷贝的就是基本类型的值,如果属性是引用类型,拷贝的就是内存地址。
下面就是一个浅拷贝的例子:
1 | let v1 = {x: 1, y: 1}; |
用赋值的方式进行拷贝时,实际上拷贝的是对象的指针,而指针所指的对象是被共用的内存。这就是浅拷贝。
深拷贝可序列化的对象:JSON 法
要深拷贝可序列化的对象,一种简单的方法是使用 JSON.stringify() 加 JSON.parse() 进行序列化和反序列化,从而获得一个经过深拷贝的对象。
1 | const o1 = {pos: {x: 1, y: 1, z: 1}, vel: {x: 0, y: -1, z: 0}}; |
缺点
无法处理循环引用的情况。
不支持 JSON 序列化的属性会丢失,如 Function, Map, Set, RegEx 等。
Date 对象会变成字符串类型。
举个例子:
1 | function fun () {console.log('hi');} |
可以看到,a.fun 因为是函数类型,所以在没报错的情况下被直接丢掉(大坑)。
对象 a 的 date 属性被转换成了 string 类型。这是由于 date 对象在序列化的过程中调用的是 Date.prototype.toJSON 方法。
而对于循环引用的情况,JSON.stringify() 会直接报错。
深拷贝可序列化的对象:结构化克隆
structuredClone 方法存在于全局作用域,直接调用即可。它使用了 结构化克隆算法 将一个可序列化的对象进行深拷贝。对于不可序列化的对象,将会抛出 DOMException 异常。
相较于 JSON 法,structuredClone 方法解决了循环引用的问题。对含有循环引用的对象使用 structuredClone 方法运行结果如下:
1 | let a = { x: 20, date: new Date()}; |
改变 a.self 并不会影响 b 或者 b.self 属性,因此实现了循环引用的深拷贝。
支持的类型:
- Array
- ArrayBuffer
- Boolean
- DataView
- Date
- Error(仅限部分)
- Map
- Object 对象:仅限简单对象(如使用对象字面量创建的)
- 除 symbol 以外的基本类型
- RegExp:lastIndex 字段不会被保留。
- Set
- String
- TypedArray
缺点
structuredClone 法作为 JSON 的上位替代,同样只支持可序列化的对象。如果对象中有
function类型的属性,会报错:1
2
3
4function fun () {console.log('hi');}
const a = { x: 20, date: new Date(), fun };
const b = structuredClone(a);
// ERROR: Uncaught DOMException: Failed to execute 'structuredClone' on 'Window': function fun () {console.log('hi');} could not be cloned.克隆 DOM 节点(Node)也会抛出异常。
一些特定对象的属性不会被保留:
- RegExp 对象的
lastIndex属性。 - 属性描述符,setters 以及 getters(以及其他类似元数据的功能)同样不会被复制。例如,如果一个对象用属性描述符标记为 read-only,它将会被复制为 read-write,因为这是默认的情况下。
- 原形链上的属性也不会被追踪以及复制。
- RegExp 对象的
手写深拷贝:散装版
先来个热身,用递归法手写一个简单的深拷贝。在每次递归时,简单地判断一下是否为 object 类型,然后判断是否为数组。之后使用 for ... in 对 对象/数组 进行遍历,挨个拷贝。
1 | function clone(src) { |
手写深拷贝:青春版
散装版代码有个明显的问题,那就是没有考虑到循环引用。对于有循环引用的对象,浏览器会报错:
Uncaught RangeError: Maximum call stack size exceeded
解决方法就是使用一个 map 来存储已经拷贝过的对象指针。对于已经拷贝过的,我们不再递归而是直接 return 掉;对于没有拷贝过的,我们再递归拷贝。这样就不会陷入回调地狱。
1 | function clone(src, map = new Map()) { |
手写深拷贝:精装版
青春版的代码解决了循环引用的问题。但是并没有提供不可序列化的解决方案。
我们可以在初始化的时候,调用源对象的构造函数:new src.constructor(src)。这样就支持了 Date, Map, Set 等无法用 JSON 序列化的复杂对象。
另外,我们也可以扣一扣细节,做一些性能优化。
使用
for ... in循环并不是最优的选择。可以使用Object.keys().forEach代替。开始对 object 的检查没有考虑到 null,因为
typeof null也是'object'。使用
map.get()检查 value 是否存在并不严谨,因为 value 本身有可能是 false 或者 null 值。我们可以用map.has()代替。
1 | function clone(src, map = new Map()) { |
碎碎念
网上看到一些文章,建议使用弱引用的 WeakMap 来替代强引用的 Map,以最大限度地发挥垃圾回收机制,提高性能。个人认为没有必要。因为在这个案例中,无论是 WeakMap 还是 Map,都是存在于函数的块级作用域内的,因此它们的垃圾回收时机是一致的,并不会带来性能上的差别。
for ... in为什么比Object.keys().forEach()慢呢?这是因为for ... in会枚举原型链上的属性,也就是说会把继承的属性也枚举出来。有些代码会修改原型链的信息,比如我自己有时会用的NodeList.prototype.forEach = Array.prototype.forEach,那么使用for ... in循环会把这些额外内容也显示出来:
1 | NodeList.prototype.forEach = Array.prototype.forEach; |
- 除了上面的
Object.keys().forEach以外,我们还可以使用 ES6 的for ... of来枚举Object.entries(),性能是一样的:
1 | for(const [k,v] of Object.entries(src)) { |
- 一句话总结一下:日常使用的话无脑
structuredClone,如果structuredClone解决不了,那就在类里自己实现拷贝函数,具体案例具体分析。
参考
青春版深拷贝来源:如何写出一个惊艳面试官的深拷贝? - 掘金