(译)最全的javaScript中对象深度拷贝指南

1,195 阅读5分钟

原文地址

我在JavaScript中如何拷贝一个对象?这是一个简单的问题,但是答案确不是很简单。

Did you ever wanted to create a deep copy of an object in JavaScript? There is a way, but you are not gonna like it... I feel like we need something better 🤔 pic.twitter.com/IDazhB8BKJ — Surma (@dassurma) 2018年1月22日

引用调用

JavaScript通过引用来传递所有的值。如果你不知道这是什么意思,下面有个例子👇:

function mutate(obj) {
  obj.a = true;
}

const obj = {a: false};
mutate(obj)
console.log(obj.a); // prints true

mutate方法改变了作为参数传递进来的这个对象。在值调用环境中,这个函数式传递的这个值,所以相关于这个函数是执行了一个拷贝。这个函数使这个对象对外是不可见的。但是在像js的这种引用调用的环境,将会得到这个真实的对象。所以最后控制台输出的为true

不过,你想要保持你的原始的对象,其他函数只是创建了这个对象的拷贝。

在下面就介绍几种深度拷贝的方式

JSON.parse

第一种最古老的方式就是通过将对象转换为JSON字符串格式,然后将其转换为对象。

let obj = { name : "huyue" };
let copy = JSON.parse(JSON.stringify(obj));
obj.name = 'hy';
console.log(copy);//'huyue'

但是这种方式有些问题

问题一:当对象中出现循环引用的时候会报错。尽管你可能认为你不会如此使用,但是那些还是会很容易发生。比如当你构建了树状类型的数据机构的时候,其中一个节点引用了父级的某个节点,这样就出现了这种场景。

const x = {};
const y = {x};
x.y = y; // Cycle: x.y.x.y.x.y.x.y.x...
const copy = JSON.parse(JSON.stringify(x)); // throws!

问题二:这种方式只支持基础类型,像MapSetRegExpDateArrayBuffer,函数对象等都会在序列化的时候弄丢

var source = { name:function(){console.log(1);}, child:{ name:"child" } } 
var target = JSON.parse(JSON.stringify(source));
console.log(target.name); //undefined

注:JSON对象是ES5中引入的新的类型(支持的浏览器为IE8+),浏览器支持情况

结构化克隆

结构化克隆是一个现有算法,它是被用来把一个领域的值传递到另一个。比如,你调用postMessage去发送一个消息给另一个窗口或WebWorker。结构化很好的地方就是他能处理循环对象,并且支持多种内置类型

MessageChannel

我们通过MessageChannel创建一个新的消息通道,并通过它的两个MessagePort属性来发送数据和获取数据。我们接受到的这条信息就是会包含原始数据的结构化克隆对象。但是这种方式是异步情况,所以下面例子使用的async awit实现了的,也可参见在线地址

function structuralClone(obj) {
  return new Promise(resolve => {
    const {port1, port2} = new MessageChannel();
    port2.onmessage = ev => resolve(ev.data);
    port1.postMessage(obj);
  });
}

const obj = /* ... */;
const clone = await structuralClone(obj);

注:浏览器支持IE10+,浏览器支持力度情况

History API

如果你曾经使用过history.pushState()去构建一个SPA(单页应用),你应该会知道能提供一个状态对象去保存这URL。这个状态对象就是结构化克隆,并且还是同步的。我们一定要小心,避免在使用这个状态对象的时候去混淆任何程序逻辑,所以我们需要在我们克隆了之后去恢复这个原始的状态对象。为了防止发生任何事件,请使用history.replaceState()而不是history.pushState()。replaceState和pushState区别详情

function structuralClone(obj) {
  const oldState = history.state;
  history.replaceState(obj, document.title);
  const copy = history.state;
  history.replaceState(oldState, document.title);//就是为了恢复原始状态对象,避免干扰
  return copy;
}

const obj = /* ... */;
const clone = structuralClone(obj);

为了复制一个对象,使用浏览器的引擎感觉有些笨拙。不过你还是可以这么做,有些事情还是得注意,因为Safari浏览器会限制30秒内调用relaceState的次数上限为100次

注:浏览器支持IE10+,浏览器支持力度情况

Notification API

这种方式由Jeremy Banks建议,通知接口用于向用户配置和显示桌面通知,这个消息通知的api有一个与它们相关的数据对象被克隆。看到这,可能有的人表示有点不是很明白,那么可以点击在线示例

function structuralClone(obj) {
  return new Notification('', {data: obj, silent: true}).data;
}

const obj = /* ... */;
const clone = structuralClone(obj);

它基本触犯了浏览器内的权限机制,所以怀疑这个可能会非常慢。出于某种原因,Safari浏览器总是返回undefined。可以使用在线示例

注:浏览器不支持IE,浏览器支持力度情况

性能测试

对上面几种方式进行性能测试看哪种方式性能最高。刚开始尝试时,我拿一个小JSON对象,并通过这些克隆对象一千次的不同方式来进行测试。幸运的是, Mathias Bynens告诉我在给一个对象增加属性的时候V8是有缓存。为了确保不走缓存,所以我写了一个[函数](a function that generates objects of given depth and width using random key names),使用随机键名称生成给定深度和宽度的对象,并重新运行测试示例

图表统计

image
image
image

总结

  • 如果你不会使用循环对象并且不会使用内置类型,那么还是推荐使用JSON.parse。并且浏览器兼容性还更好(ie8+)
  • 如果在考虑性能和浏览器兼容,MessageChannel是最好的选择。(ie10+)