JavaScript 中深拷贝对象,用现代浏览器

166 阅读5分钟

image.png

您知道吗?JavaScript 现在有一种原生方式可以对象深拷贝。
没错,就是 structuredClone 函数,它是内置在 JavaScript 运行时中的:

const calendarEvent = {
  title: "Builder.io Conf",
  date: new Date(123),
  attendees: ["Steve"]
}

// 😍
const copied = structuredClone(calendarEvent)

是否注意到,在上面的示例中我们不仅复制了对象,还复制了嵌套数组,甚至 Date 对象!
一切都按预期工作:

copied.attendees // ["Steve"]
copied.date // Date: Wed Dec 31 1969 16:00:00
cocalendarEvent.attendees === copied.attendees // false

没错, structuredClone 不仅可以做到以上几点,还可以:

  • 可复制无限嵌套的对象和数组
  • 可复制循环引用自身的
  • 可复制各种 JavaScript 类型,例如 Date 、 Set 、 Map 、 Error 、 RegExp 、 ArrayBuffer 、 Blob 、 File 、 ImageData 等等,更多
  • 转移任何 可转移的对象

例如,这种疯狂甚至会像预期的那样奏效:

const kitchenSink = {
  set: new Set([1, 3, 3]),
  map: new Map([[1, 2]]),
  regex: /foo/,
  deep: { array: [ new File(someBlobData, 'file.txt') ] },
  error: new Error('Hello!')
}
kitchenSink.circular = kitchenSink

// ✅ All good, fully and deeply copied!
const clonedSink = structuredClone(kitchenSink)

为什么不是对象扩展运算 { ...obj } 呢?

重要的是要注意我们正在谈论深拷贝。如果你只需要做一个浅拷贝,也就是不复制嵌套对象或数组的拷贝,那么我们可以只做一个对象扩展运算:

const simpleEvent = {
  title: "Builder.io Conf",
}
// ✅ no problem, there are no nested objects or arrays
const shallowCopy = {...calendarEvent}

也可使用下面方法之一

const shallowCopy = Object.assign({}, simpleEvent)
const shallowCopy = Object.create(simpleEvent)

但是一旦我们有了嵌套,我们就会遇到麻烦,如下:

const calendarEvent = {
  title: "Builder.io Conf",
  date: new Date(123),
  attendees: ["Steve"]
}

const shallowCopy = {...calendarEvent}

// 🚩 oops - we just added "Bob" to both the copy *and* the original event
shallowCopy.attendees.push("Bob")

// 🚩 oops - we just updated the date for the copy *and* original event
shallowCopy.date.setTime(456)

如上所见,我们没有得到该对象的完整副本。

嵌套的日期和数组仍然是两者之间的共享引用,如果我们想编辑那些认为我们只是更新复制的日历事件对象,这可能会给我们带来重大问题。

为什么不用 JSON.parse(JSON.stringify(x)) ?

是啊,这也一种方式。它实际上是一个很棒的,并且性能出奇地好,但也有一些缺点,这正是 structuredClone 解决的问题。如下例:

const calendarEvent = {
  title: "Builder.io Conf",
  date: new Date(123),
  attendees: ["Steve"]
}

// 🚩 JSON.stringify converted the `date` to a string
const problematicCopy = JSON.parse(JSON.stringify(calendarEvent))

如果我们打印 problematicCopy,会得到:

{
  title: "Builder.io Conf",
  date: "1970-01-01T00:00:00.123Z"
  attendees: ["Steve"]
}

这不是我们想要的! date 应该是 Date 对象,而不是字符串。发生这种情况是因为 JSON.stringify 只能处理基本对象、数组和基础数据类型。任何其他类型都可以以难以预测的方式处理。例如,日期被转换为字符串。但是 Set 只是简单地转换为 {} 。

JSON.stringify 甚至完全忽略了某些东西,比如 undefined 或函数。比如,如果我们使用此方法复制我们的 kitchenSink 例子:

const kitchenSink = {
  set: new Set([1, 3, 3]),
  map: new Map([[1, 2]]),
  regex: /foo/,
  deep: { array: [ new File(someBlobData, 'file.txt') ] },
  error: new Error('Hello!')
}
kitchenSink.circular = kitchenSink

const veryProblematicCopy = JSON.parse(JSON.stringify(kitchenSink))

我们会得到:

{
  "set": {},
  "map": {},
  "regex": {},
  "deep": {
    "array": [
      {}
    ]
  },
  "error": {},
}

是的,我们必须删除我们最初为它设置的循环引用,因为如果 JSON.stringify 遇到其中之一,它只会抛出错误。

因此,虽然如果我们的需求符合它所能做的,那么这个方法可能很好,但我们可以使用structuredClone(也就是我们在这里未能做到的所有事情)做很多事情,而这个方法无法做到。

为什么不用 _.cloneDeep?

目前为止,Lodash 的 cloneDeep 函数是解决此问题的一种非常普遍的方法。事实上,这确实如预期的那样:

import cloneDeep from 'lodash/cloneDeep'

const calendarEvent = {
  title: "Builder.io Conf",
  date: new Date(123),
  attendees: ["Steve"]
}

// ✅ All good!
const clonedEvent = structuredClone(calendarEvent)

但是,这里有个警告。根据 VSCode 的  Import Cost 扩展,它可以显示导入包 kb 大小,这个函数压缩后总共有 17.4kb(gzip 后为 5.3kb): Screenshot of the import cost of 'lodash/cloneDeep' at 5.3kb gzipped

并且如果只导入该功能。如果改用更常见的方式导入,没有意识到 tree shaking 并不总是按照你所想的方式工作,你可能会不小心导入高达 25kb 的数据,只是为了这个功能 😱 Screenshot of the import cost of 'lodash' at 25.2kb gzipped

虽然这对任何人来说都不是问题,但在我们的情况下,这根本不是必要的,因为浏览器已经内置了structuredClone

structureClone 不能 拷贝什么?

无法拷贝 Functions

拷贝将抛出一个 DataCloneError 异常:

// 🚩 Error!
structuredClone({ fn: () => { } })

无法拷贝 DOM 节点

会抛出 DataCloneError 异常:

// 🚩 Error!
structuredClone({ el: document.body })

无法拷贝对象属性的 descriptorssettergetter

以及类似元数据的特性不会被克隆。例如,对于 getter,结果值被克隆,但不是 getter 函数本身(或任何其他属性元数据):

structuredClone({ get foo() { return 'bar' } })
// Becomes: { foo: 'bar' }

无法拷贝对象原型链

原型链未被遍历或复制。因此,如果您克隆 MyClass 的实例,则克隆的对象将不再是此类的实例(但此类的所有有效属性都将被克隆)

class MyClass { 
  foo = 'bar' 
  myMethod() { /* ... */ }
}
const myClass = new MyClass()

const cloned = structuredClone(myClass)
// Becomes: { foo: 'bar' }

cloned instanceof myClass // false

structureClone 支持类型的完整列表

更简单地说,不在以下列表中的任何内容都不能被克隆:

JS Built-ins

Array 、 ArrayBuffer 、 Boolean 、 DataView 、 Date 、 Error 类型(下面具体列出的那些)、 Map 、 Object 但仅限普通对象(例如来自对象字面量)、原始类型 除了 symbol (如 number 、 string 、 null 、 undefined 、 boolean 、 BigInt )、 RegExp 、 Set 、 TypedArray

Error types

ErrorEvalErrorRangeErrorReferenceError , SyntaxErrorTypeErrorURIError

Web/API types

AudioDataBlobCryptoKeyDOMExceptionDOMMatrixDOMMatrixReadOnlyDOMPointDomQuadDomRectFileFileListFileSystemDirectoryHandleFileSystemFileHandleFileSystemHandleImageBitmapImageDataRTCCertificateVideoFrame

浏览器和运行时支持

这里是最好的部分——所有主要浏览器都支持“structuredClone”,甚至Node.js和Deno都支持。只需注意Web Workers支持更有限的警告:

Browser support table - screenshot from the link directly below this image 来自: MDN

结论

很长一段时间了,但我们终于有了structuredClone,可以轻松地在JavaScript中进行深度克隆对象。

参考资源: