1
头图

你知道现在 JavaScript 有一种原生的深拷贝方法吗?

没错,就是 structuredClone 方法。该方法已内置于 JavaScript 运行时中。(译者注:Nodejs > 17)

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 等。
  • 转移任何可转移对象(译者注:有点像 Rust 中所有权的转移。MDN 中的例子很不错。)

比如,像下面这种抽风的示例也会符合预期:(译者注:可以去控制台试试,真的无限嵌套了)

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)

为什么不展开对象呢?

需要注意的是我们正在讨论深拷贝。如果你仅仅只需要做浅拷贝,即不用拷贝那些嵌套的对象或者数组,那自然是可以使用对象展开的。

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 或者方法。

比如下面这个例子,我们用 JSON.stringify 来拷贝 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!')
}

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

我们会得到这样的结果:

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

是的,我们还必须删除循环引用,因为当 JSON.stringify 遇到后就会抛出错误。

当我们的需求满足时,尽管这个方式很棒,但 structuredClone 不仅也能做到并且还能做的更多。

为什么不是 _.cloneDeep

至今为止,LodashcloneDeep 方法已经是解决这个问题的通用方式。

并且实际也符合我们的需求:

import cloneDeep from 'lodash/cloneDeep'

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

const clonedEvent = cloneDeep(calendarEvent)

但有一点需要注意。根据我的 IDE 中 Import Cost插件显示,引用这一个方法就需要 17.4kb(压缩后为 5.3kb)。

这仅是引用这一个方法。如果你用更普通的方式引入,并没有意识到 Tree Shaking 不会总按照预期执行。那你将会因为这个方法额外引入 25kb。

当然这对任何人来说都不会是灭顶之灾。在我们的示例中没有必要,更不用说在已经内置了 structuredClone 的浏览器内。

什么 structuredClone 不能拷贝的

函数/方法不能被拷贝

会抛出 DataCloneError 错误。

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

DOM 节点

同样会抛出 DataCloneError 错误。

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

属性表述、setter 和 getter

类似的元数据(meta-data)不会被拷贝。

比如,当有 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

支持的类型列表

简单地说,不在下面列表中的内容无法被拷贝。

JS 内置函数

ArrayArrayBufferBooleanDataViewDateError 类型(下面列出), Map , Object基本数据类型, 除了 symbol (比如 numberstringnullundefinedbooleanBigInt), RegExpSetTypedArray

错误类型

ErrorEvalErrorRangeErrorReferenceError , SyntaxErrorTypeErrorURIError

Web/API 类型

AudioDataBlobCryptoKeyDOMExceptionDOMMatrixDOMMatrixReadOnlyDOMPointDomQuadDomRectFileFileListFileSystemDirectoryHandleFileSystemFileHandleFileSystemHandleImageBitmapImageDataRTCCertificateVideoFrame

浏览器和运行时支持情况

所有主流浏览器都支持 structuredClone ,还包括了 Node.js 和 Deno。

需要注意的是 Web Worker 有更多的限制。

来源: MDN

结论

虽然经历了长时间的等待,我们终于可以通过 structuredClone 让深拷贝在 JavaScript 中像呼吸那样简单。谢谢你,Surma.


无责任此方_修行中
19 声望1 粉丝

从前端转到后端的全干工程师。专注于 JavaScript。