译:现代化JavaScript深拷贝方式

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

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

1
2
3
4
5
6
7
8
const calendarEvent = {
title: "Builder.io Conf",
date: new Date(123),
attendees: ["Steve"],
};

// 😍
const copied = structuredClone(calendarEvent);

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

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

1
2
3
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 中的例子很不错。)

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

1
2
3
4
5
6
7
8
9
10
11
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);

为什么不展开对象呢?

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

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

又或者是下面两种方式

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
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 可以解决这些缺点。

这里有一个例子:

1
2
3
4
5
6
7
8
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 就能看到:

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

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

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

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

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

1
2
3
4
5
6
7
8
9
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));

我们会得到这样的结果:

1
2
3
4
5
6
7
8
9
10
11
{
"set": {},
"map": {},
"regex": {},
"deep": {
"array": [
{}
]
},
"error": {},
}

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

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

为什么不是 _.cloneDeep

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

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

1
2
3
4
5
6
7
8
9
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 错误。

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

DOM 节点

同样会抛出 DataCloneError 错误。

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

属性表述、setter 和 getter

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

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

1
2
3
4
5
6
structuredClone({
get foo() {
return "bar";
},
});
// Becomes: { foo: 'bar' }

对象原型

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

1
2
3
4
5
6
7
8
9
10
11
12
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.


译:现代化JavaScript深拷贝方式
https://konta9.github.io/2024/04/15/2024/translate-deep-clone-object-in-morden-js/
作者
Konata
发布于
2024年4月15日
许可协议