[译]现代化JavaScript深拷贝方式

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

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

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

// 😍
const copied = structuredClone(calendarEvent)

你是否注意到在上面的例子中,我们拷贝整个对象同时也拷贝了内嵌的数组甚至是 Date 对象。

并且所有的一切都符合我们的预期。

javascript 复制代码
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 中的例子很不错。)

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

javascript 复制代码
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)

为什么不展开对象呢?

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

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

又或者是下面两种方式

javascript 复制代码
const shallowCopy = Object.assign({}, simpleEvent)
const shallowCopy = Object.create(simpleEvent)

但只要有嵌套对象,我们就会遇到问题:

javascript 复制代码
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 可以解决这些缺点。

这里有一个例子:

javascript 复制代码
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 就能看到:

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

这显然不是我们想要的!date 应该是一个 Date 对象而非字符串。

会出现这样的情况是因为 JSON.stringify 只能处理基本的对象、数组和基本数据类型。而其他数据类型的处理方式则各不相同。比如日期类型会被转为字符串,但 Set 类型则转换为 {}

JSON.stringify 甚至还会忽略某些类型,比如 undefined 或者方法。

比如下面这个例子,我们用 JSON.stringify 来拷贝 kitchenSink 对象:

javascript 复制代码
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))

我们会得到这样的结果:

javascript 复制代码
{
  "set": {},
  "map": {},
  "regex": {},
  "deep": {
    "array": [
      {}
    ]
  },
  "error": {},
}

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

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

为什么不是 _.cloneDeep

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

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

javascript 复制代码
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 错误。

javascript 复制代码
// 🚩 Error!
structuredClone({ fn: () => { } })

DOM 节点

同样会抛出 DataCloneError 错误。

javascript 复制代码
// 🚩 Error!
structuredClone({ el: document.body })

属性表述、setter 和 getter

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

比如,当有 getter 时,会拷贝结果但不会拷贝函数本身(或者其他属性上的元数据):

javascript 复制代码
structuredClone({ get foo() { return 'bar' } })
// Becomes: { foo: 'bar' }

对象原型

原型链不会被遍历或者重复。因此,当你拷贝一个 MyClass 的实例,被拷贝的对象将不会被认为是该类的实例(但该类中所有合法的属性都会被拷贝)。

javascript 复制代码
class MyClass { 
  foo = 'bar' 
  myMethod() { /* ... */ }
}
const myClass = new MyClass()

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

cloned instanceof myClass // false

支持的类型列表

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

JS 内置函数

Array, ArrayBuffer, Boolean, DataView, Date, Error 类型(下面列出), Map , Object , 基本数据类型, 除了 symbol (比如 number, string, null, undefined, boolean, BigInt), RegExp, Set, TypedArray

错误类型

Error, EvalError, RangeError, ReferenceError , SyntaxError, TypeError, URIError

Web/API 类型

AudioData, Blob, CryptoKey, DOMException, DOMMatrix, DOMMatrixReadOnly, DOMPoint, DomQuad, DomRect, File, FileList, FileSystemDirectoryHandle, FileSystemFileHandle, FileSystemHandle, ImageBitmap, ImageData, RTCCertificate, VideoFrame

浏览器和运行时支持情况

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

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

来源: MDN

结论

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

相关推荐
二闹1 分钟前
Python文件读取三巨头你该选择哪一个?
后端·python
楠木6859 分钟前
RAG 资料库 Demo 完整开发流程
前端·ai编程
肠胃炎20 分钟前
挂载方式部署项目
服务器·前端·nginx
苏三说技术27 分钟前
推荐几个牛逼的AI Agent项目
后端
像我这样帅的人丶你还28 分钟前
使用 Next.js + Prisma + MySQL 开发全栈项目
前端
FPGA小迷弟28 分钟前
FPGA 时序约束基础:从时钟定义到输入输出延迟的完整设置
前端·学习·fpga开发·verilog·fpga
Kel28 分钟前
深入剖析 openai-node 源码:一个工业级 TypeScript SDK 的架构之美
javascript·人工智能·架构
武子康1 小时前
大数据-253 离线数仓 - Airflow 入门与任务调度实战:DAG、Operator、Executor 部署排错指南
大数据·后端·apache hive
毛骗导演1 小时前
@tencent-weixin/openclaw-weixin 插件深度解析(四):API 协议与数据流设计
前端·架构
毛骗导演1 小时前
@tencent-weixin/openclaw-weixin 插件深度解析(二):消息处理系统架构
前端·架构