likes
comments
collection
share

JS 现代化的深克隆

作者站长头像
站长
· 阅读数 4

大家好,这里是大家的林语冰。

前端手写深克隆/深拷贝是一道回头率超高的笔试题,虽然但是:

  • 笔试版深克隆一般不适用于生产环境(因为大部分前端八股文的网红应试都没考虑 Corner Cases)
  • JSON 的奇技淫巧并不完美
  • Lodash 的工具函数体积臃肿

所以,本期《前端翻译计划》给大家推荐的是一种运行时原生支持的深克隆方法 —— structuredClone(结构化克隆算法)。

JS 现代化的深克隆

免责声明

本文属于是语冰的直男翻译了属于是,略有删改,仅供粉丝参考,英文原味版请传送 Deep Cloning Objects in JavaScript, the Modern Way

您知道吗,JS 现在有一种原生方法可以深层复制对象?

是的没错,该 structuredClone 函数内置在 JS 运行时中:

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 不仅可以如上操作,还可以:

  • 克隆无限嵌套的对象和数组
  • 克隆循环引用
  • 克隆各种 JS 类型,比如 DateSetMapErrorRegExpArrayBufferBlobFileImageData 等等
  • 传送任何可转移对象(transferable objects)

举个栗子,下述奇葩操作甚至也会如期工作:

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 clonedSink = structuredClone(kitchenSink)

为什么不选择展开对象克隆呢?

注意,我们正在谈论的是深拷贝。如果您只需浅拷贝,即不复制嵌套对象或数组的副本,那么我们可以直接展开对象克隆:

const simpleEvent = {
  title: 'Builder.io Conf'
}
// ✅ 问题不大,此处没有嵌套对象/数组
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 }

// 🚩 夭寿啦:我们同时在 calendarEvent 及其副本中添加了 Bob
shallowCopy.attendees.push('Bob')

// 🚩 天呢噜:我们同时为 calendarEvent 及其副本更新了 date
shallowCopy.date.setTime(456)

如你所见,我们没有完整拷贝该对象。

嵌套日期和数组仍然是两者之间的共享引用,如果我们想编辑那些被认为只会更新 calendarEvent 对象副本的内容,这可能会给我们带来无妄之灾。

为什么不选择 JSON.parse(JSON.stringify(x)) 呢?

啊对对对,这确实是奇技淫巧。它实际上是一个很棒的点子,且具有惊人的性能,但存在若干 structuredClone 解决了的短板。

如下所示:

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

// 🚩 JSON.stringify 会把 date 转换为字符串
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 只能处理基本对象、数组和原始值。处理任何其他类型都十分佛系。举个栗子,Date 被转换为字符串。但 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!')
}

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']
}

// ✅ 一切顺利!
const clonedEvent = cloneDeep(calendarEvent)

虽然但是,此处有且仅有一个警告。根据本人 IDE 中的导入成本(import cost)扩展插件,它会打印我导入的任何内容的 kb 成本,该函数压缩后总共有 17.4kb(gzip 压缩后为 5.3kb):

JS 现代化的深克隆

而这是假设您只导入了该函数的情况。如果您以更常见的方式导入,却没有意识到 Tree Shaking 优化并不总是如期奏效,您可能会一不小心仅针对这一函数导入多达 25kb 的数据 😱

JS 现代化的深克隆

虽然这对任何人而言都不会是世界末日,但在我们的例子中根本没有必要,尤其是浏览器已经内置了 structuredClone

structuredClone 的短板

无法克隆函数

这会报错 —— DataCloneError 异常:

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

DOM 节点

梅开二度 —— DataCloneError 异常:

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

属性描述符,setters 和 getters

类似的类元数据(metadata-like)的功能也无法被克隆。

举个栗子,使用 getter 时,会克隆结果值,但不会克隆 getter 函数本身(或任何其他属性元数据):

structuredClone({
  get foo() {
    return 'bar'
  }
})
// 结果变成: { foo: 'bar' }

对象原型

原型链不会被遍历或重复。因此,如果您克隆 MyClass 的实例,那么克隆对象将不再被视为此类的实例(但此类的所有有效属性都将被克隆)

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

const cloned = structuredClone(myClass)
// 结果变成: { foo: 'bar' }

cloned instanceof myClass // false

支持的类型的完整列表

简而言之,下述列表中未列出的任何内容都无法克隆:

JS 内置函数

ArrayArrayBufferBooleanDataViewDateError 类型(那些下面具体列出),Map,仅限于普通对象的 Object(比如来自对象字面量),除了 symbol 的原始类型(又名 numberstringnullundefinedbooleanBigInt)、RegExpSetTypedArray

Error 类型

  • 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 Workers 的支持更有限的警告:

JS 现代化的深克隆

您现在收看的是《前端翻译计划》,学废了的小伙伴可以订阅此专栏合集,我们每天佛系投稿,欢迎持续关注前端生态。谢谢大家的点赞,掰掰~

JS 现代化的深克隆