使用结构化克隆在 JavaScript 中进行深层复制

该平台现在附带 structuredClone(),这是一个用于深层复制的内置函数。

在很长一段时间内,您都必须借助权宜解决方法和库来创建 JavaScript 值的深层副本。该平台现在附带 structuredClone(),这是一个用于深层复制的内置函数。

Browser Support

  • Chrome: 98.
  • Edge: 98.
  • Firefox: 94.
  • Safari: 15.4.

Source

浅拷贝

在 JavaScript 中,复制值几乎总是浅层,而不是深层。这意味着,对深层嵌套值的更改也会在副本和原始值中显示。

在 JavaScript 中,使用对象展开运算符 ... 创建浅拷贝的一种方法:

const myOriginal = {
  someProp: "with a string value",
  anotherProp: {
    withAnotherProp: 1,
    andAnotherProp: true
  }
};

const myShallowCopy = {...myOriginal};

直接在浅拷贝上添加或更改属性只会影响拷贝,而不会影响原始对象:

myShallowCopy.aNewProp = "a new value";
console.log(myOriginal.aNewProp)
// ^ logs `undefined`

不过,添加或更改深层嵌套属性会同时影响副本和原始对象:

myShallowCopy.anotherProp.aNewProp = "a new value";
console.log(myOriginal.anotherProp.aNewProp) 
// ^ logs `a new value`

表达式 {...myOriginal} 使用展开运算符遍历 myOriginal 的(可枚举)属性。它会使用属性名称和值,并将它们逐一分配给新创建的空对象。因此,生成的对象在形状上完全相同,但具有自己的属性和值列表副本。值也会被复制,但 JavaScript 值对所谓的基元值的处理方式与非基元值不同。引用 MDN 的内容:

在 JavaScript 中,基元(基元值、基元数据类型)是指不属于对象且没有方法的数据。有七种原始数据类型:字符串、数字、bigint、布尔值、未定义、符号和 null。

MDN - Primitive

非基元值会被视为引用,这意味着复制值的行为实际上只是复制对同一底层对象的引用,从而导致浅层复制行为。

深层复制

与浅层副本相反的是深层副本。深层复制算法也会逐个复制对象的属性,但在发现对另一个对象的引用时,会以递归方式调用自身,从而也创建该对象的副本。这对于确保两段代码不会意外共享对象并在不知不觉中操纵彼此的状态非常重要。

过去,在 JavaScript 中没有简单或合适的方法来创建值的深层副本。许多人依赖第三方库,例如 Lodash 的 cloneDeep() 函数。可以说,此问题最常见的解决方案是基于 JSON 的黑客攻击:

const myDeepCopy = JSON.parse(JSON.stringify(myOriginal));

事实上,这是一种非常流行的解决方法,因此 V8 积极优化了 JSON.parse(),特别是上述模式,以使其尽可能快。虽然这种方法速度很快,但也有一些缺点和陷阱:

  • 递归数据结构:如果您向 JSON.stringify() 提供递归数据结构,它会抛出异常。在处理链表或树时,这种情况很容易发生。
  • 内置类型:如果值包含其他 JS 内置类型(例如 Map、Set、Date、RegExp 或 ArrayBuffer),JSON.stringify() 将抛出异常。
  • 函数:JSON.stringify() 会静默舍弃函数。

结构化克隆

该平台已经需要在几个地方创建 JavaScript 值的深层副本:在 IndexedDB 中存储 JS 值需要某种形式的序列化,以便将其存储在磁盘上,并在稍后反序列化以恢复 JS 值。同样,通过 postMessage() 向 WebWorker 发送消息需要将 JS 值从一个 JS 领域传输到另一个 JS 领域。用于此操作的算法称为“结构化克隆”,直到最近,开发者才能够轻松使用该算法。

现在,情况已经发生了变化!HTML 规范经过了修订,公开了一个名为 structuredClone() 的函数,该函数会运行该算法,以便开发者轻松创建 JavaScript 值的深层副本。

const myDeepCopy = structuredClone(myOriginal);

大功告成!这就是整个 API。如果您想深入了解详情,请参阅 MDN 文章。

功能和限制

结构化克隆解决了 JSON.stringify() 技术的许多(但并非全部)缺点。结构化克隆可以处理循环数据结构,支持许多内置数据类型,通常更稳健,而且往往更快。

不过,它仍有一些限制,可能会让您措手不及:

  • 原型:如果您将 structuredClone() 与类实例搭配使用,则会获得一个普通对象作为返回值,因为结构化克隆会舍弃对象的原型链。
  • 函数:如果您的对象包含函数,structuredClone() 将抛出 DataCloneError 异常。
  • 不可克隆:某些值无法进行结构化克隆,最明显的是 Error 和 DOM 节点。这会导致 structuredClone() 抛出异常。

如果这些限制中的任何一个对您的用例来说是致命的,Lodash 等库仍会提供其他深层克隆算法的自定义实现,这些算法可能适合也可能不适合您的用例。

性能

虽然我没有进行新的微基准比较,但我在 2018 年初(structuredClone() 曝光之前)进行过比较。当时,JSON.parse() 是处理极小对象的最快选项。我预计这种情况不会改变。对于较大的对象,依赖于结构化克隆的技术(明显)更快。考虑到新的 structuredClone() 没有滥用其他 API 的开销,并且比 JSON.parse() 更强大,我建议您将其作为创建深层副本的默认方法。

总结

如果您需要在 JS 中创建值的深层副本(可能是因为您使用了不可变的数据结构,或者您想确保函数可以操作对象而不影响原始对象),则无需再使用权宜解决方法或库。JS 生态系统现在有 structuredClone()。万岁!