JavaScript深度复制之使用浏览器API实现详解

如何实现深度拷贝,前面的文章:javascript中的深拷贝和浅拷贝区分以及实现 给出了几种,但都是很复杂的,这篇文章通过另辟蹊径的用浏览器自身的API来实现深度拷贝,有MessageChannel、history api 、Notification api等。 如何在JavaScript中复制对象? 这是一个简单的问题,没有一个简单的答案。

通过参考调用

JavaScript通过引用传递所有内容。 如果你不知道这意味着什么,这里有一个例子:

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

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

函数mutate改变它作为参数传递的对象。 在“按值调用”的环境中,函数会传递该函数可以使用的值 – 所以是副本。 该函数对该对象所做的任何更改都不会在该函数外部可见。但是在像JavaScript这样的“引用调用”环境中,函数会得到 – 你猜对它 – 引用 ,并且会改变实际的对象本身。 因此,最后的console.log将显示为true 。 然而,有时候,您可能希望保留原始对象并为其他函数创建副本以便使用。

浅拷贝:Object.assign()

复制对象的一种方法是使用Object.assign(target, sources…) 。 它需要任意数量的源对象,枚举它们自己的所有属性并将它们分配给target 。 如果我们使用一个新鲜的空物体作为target ,我们基本上就是复制。

const obj = /* ... */;
const copy = Object.assign({}, obj);

但是,这仅仅是一个浅拷贝。 如果我们的对象包含对象,它们将保持共享引用,这不是我们想要的:

function mutateDeepObject(obj) {
  obj.a.thing = true;
}

const obj = {a: {thing: false}};
const copy = Object.assign({}, obj);
mutateDeepObject(copy)
console.log(obj.a.thing); // prints true

另一件可能会跳过的事是Object.assign()将getter变成简单的属性。 所以现在怎么办? 原来,有几种方法可以创建对象的深层副本。

注意:有些人询问了对象扩散算子。 对象传播也会创建一个浅拷贝。

JSON.parse

创建对象副本的最古老方法之一是将对象转换为其JSON字符串表示形式,然后将其解析回对象。 这感觉有点霸道,但它确实有效:

const obj = /* ... */;
const copy = JSON.parse(JSON.stringify(obj));

这里的缺点是您创建了一个临时的,可能很大的字符串,以便将其返回到解析器。 另一个缺点是这种方法无法处理循环对象。 尽管你可能会想,但这些可以很容易地发生。例如,当您构建树状数据结构时,节点引用其父项,并且父项又引用其自己的子项。

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!

此外,诸如地图,集合,RegExps,日期,ArrayBuffers和其他内置类型的东西在序列化时会丢失。

结构化克隆

结构化克隆是一种现有的算法,用于将值从一个领域转移到另一个领域。 例如,只要调用postMessage将消息发送到其他窗口或WebWorker,就会使用它 。 关于结构化克隆的好处在于它处理循环对象并支持大量的内置类型 。 问题在于,在编写本文时算法不会直接暴露,只能作为其他API的一部分。 我想我们必须看看那些,我们不会…

MessageChannel

正如我所说的,无论何时调用postMessage ,都会使用结构化克隆算法。 我们可以创建一个MessageChannel并发送消息。 在接收端,消息包含我们原始数据对象的结构化克隆。

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);

这种方法的缺点是它是异步的。 这并不是什么大问题,但有时您需要一种同步方式来深度复制对象。

历史API

如果您曾经使用history.pushState()构建SPA,那么您知道可以提供一个状态对象来保存URL。 事实证明,这个状态对象在结构上被克隆 – 同步。 我们必须小心,不要混淆可能使用状态对象的任何程序逻辑,所以我们需要在完成克隆后恢复原始状态。 为了防止发生任何事件,请使用history.replaceState()而不是history.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秒内将调用replaceState限制为100次。

通知API

在 Twitter 上对整个旅程进行了推特风暴之后, Jeremy Banks向我展示了第三种方法可以利用结构化克隆:Notification API。 通知有一个与它们相关的数据对象被克隆。

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

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

简洁,简洁。 我喜欢它! 但是,它基本上是在浏览器中的权限机制的踢,所以我怀疑它很慢。 出于某种原因,Safari总是返回undefined的数据对象。

大量演示

我想测量哪些方法是最高性能的。 在我的第一次(天真的)尝试中,我拿了一个小JSON对象并通过这些不同的克隆对象一千次的方法来传送它。 幸运的是, Mathias Bynens告诉我,当你添加属性到一个对象时, V8具有高速缓存 。 我比其他任何东西都更像基准测试缓存。 为了确保我永远不会碰到缓存,我写了一个函数,使用随机密钥名称生成给定深度和宽度的对象,并重新运行测试 。

图表!

以下是不同技术在Chrome,Firefox和Edge中的执行情况。 越低越好。


结论

那么我们从这里拿走了什么?

  • 如果您不希望循环对象并且不需要保留内置类型,那么通过使用JSON.parse(JSON.stringify())在所有浏览器中获得最快的克隆,这让我感到非常意外。
  • 如果你想要一个适当的结构化克隆, MessageChannel是你唯一可靠的跨浏览器选择。

如果我们只是把structuredClone()作为平台的一个功能,会不会更好? 我当然这样认为,并重新对HTML规范的旧问题重新考虑这种方法。 原文参考:https://dassur.ma/things/deep-copy/