JavaScript 中的结构化克隆:为什么它比 JSON 序列化更强大

本文深入对比 JavaScript 结构化克隆与 JSON 序列化,分析它们在数据类型、循环引用、二进制转移上的差异,并结合 structuredClone、postMessage、IndexedDB 等场景给出实战选择建议。

从一个常见的深拷贝需求说起

在JavaScript里复制一个对象,很多人第一反应是JSON.parse(JSON.stringify(obj))。这个写法简单直接,但生产环境里会踩不少坑:Date对象被序列化成字符串,Map和Set变成空对象,RegExp变成普通对象,遇到循环引用直接抛错。我见过不少项目因为这些边界情况在线上出问题,最后不得不再写一个深拷贝工具函数。

AI technology illustration

后来浏览器和Node.js提供了structuredClone(),很多人以为深拷贝问题终于被官方解决了。但结构化克隆并不是一个普通的深拷贝方法,它背后是一套完整的算法,支持的类型比JSON多得多,也有自己的限制。这篇文章就聊聊结构化克隆为什么比JSON序列化更强大,以及它在什么情况下仍然不够用。

什么是结构化克隆

结构化克隆(Structured Clone)是一套用于在JavaScript运行时中复制复杂对象的算法,它由HTML规范定义,最早出现在Web Worker的postMessage()通信中。当你在两个线程之间传递数据时,消息不是直接共享引用,而是通过结构化克隆算法复制一份,再传给目标线程。

它的核心机制其实不难理解:算法维护一个记录表,保存每个已经访问过的对象和它的克隆副本。当遇到循环引用时,直接从记录表里取回副本,避免无限递归。这种基于引用图的遍历方式,让结构化克隆能够完整复制对象之间的复杂关系。

和JSON序列化完全不同,结构化克隆会递归遍历对象的所有属性,并且用一张记录表追踪已经访问过的对象。这样有两个直接的好处:第一,它能处理循环引用;第二,它能保留更多内置类型的内部结构。

结构化克隆和JSON序列化的能力对比

要理解结构化克隆为什么更强大,先看一张对比表。这张表列出了常见类型在两种方案下的表现。

数据类型 JSON.parse(JSON.stringify()) 结构化克隆
普通对象 / 数组 支持,属性会被复制 支持
Date 变成字符串 保留为Date对象
RegExp 变成空对象 保留为RegExp对象
Map / Set 变成空对象 保留为Map / Set
ArrayBuffer / TypedArray 变成普通对象 保留为二进制缓冲区,可转移
循环引用 抛错 支持,复制后保持引用关系
函数 被丢弃 不支持,直接抛错
Symbol 被忽略 不支持
DOM节点 被丢弃 不支持

从表格可以看出,结构化克隆在数据类型支持上几乎是全面领先的。尤其是循环引用二进制数据这两个场景,JSON完全无能为力,而结构化克隆是原生支持的。

循环引用:JSON最大的痛点

考虑一个常见的业务对象:一个链表节点,或者一个带父节点引用的树结构。用JSON深拷贝这种结构,会直接抛出Converting circular structure to JSON错误。而结构化克隆可以完整地复制整个引用图,复制后的对象仍然保持原来的循环关系。

const node = { name: 'root' };
node.self = node;

// JSON 方式:直接抛错
// JSON.parse(JSON.stringify(node));

// 结构化克隆:正常工作
const cloned = structuredClone(node);
console.log(cloned.self === cloned); // true

这个能力在业务代码里可能用得不多,但在处理复杂状态管理、图形结构或者缓存数据时,会省掉大量手工处理。

二进制数据:从“拷贝”到“转移”

结构化克隆还有一个JSON完全不具备的能力:转移(Transfer)。在postMessage()中,你可以把ArrayBuffer的所有权直接转移给另一个线程,而不是复制一份。这意味着大数据传输不再需要复制内存,性能提升非常明显。

const buffer = new ArrayBuffer(1024 * 1024);

worker.postMessage(buffer, [buffer]);
// buffer 的 byteLength 已经变成 0,内存所有权已转移

在Web Worker之间传递音频、图片或文件数据时,这个特性非常重要。JSON只能把二进制数据转成字符串或数组,然后再解析,效率低不说,还会破坏数据的原始结构。

结构化克隆的实际应用场景

结构化克隆并不是只有structuredClone()一个入口。很多浏览器API在底层都在用它,理解这一点有助于你做出更好的架构决策。

  • Web Worker 的 postMessage():主线程和Worker之间传递数据时,默认使用结构化克隆,除非你显式指定了转移列表。
  • History APIhistory.pushState()history.replaceState()的state对象会被结构化克隆。
  • IndexedDB:存入数据库的值会先经过结构化克隆,因此你可以直接存储Date、Map等类型。
  • Notification API:通知的data属性也是结构化克隆。
  • Node.js 的 v8.serialize():V8引擎暴露的序列化接口,本质上也是结构化克隆的实现。

这些场景有一个共同特点:数据要跨越某个边界。要么是线程边界,要么是存储边界。在这些地方,数据不能直接共享引用,必须复制一份。而复制的方式,就是结构化克隆。

结构化克隆的限制:它不是万能的

尽管结构化克隆比JSON强大很多,但它仍然有明确的能力边界。最常见的限制有三个。

不支持函数和Symbol

函数和Symbol是JavaScript中无法被结构化克隆的类型。如果你尝试克隆一个包含函数的对象,structuredClone()会直接抛出DataCloneError。这很容易理解:函数是执行上下文的一部分,无法被序列化后重建。

const obj = {
  fn: () => console.log('hello'),
  sym: Symbol('key')
};

// 抛错:DataCloneError
// structuredClone(obj);

不支持DOM节点和Error对象

DOM节点不能被结构化克隆,Error对象的stack信息也会丢失。对于需要保留错误上下文的数据结构,结构化克隆并不适用。

原型链不会被完整保留

结构化克隆会复制对象自身的可枚举属性,但对象的原型只会被重置为对应类型的标准原型。比如你自定义了一个类MyClass,它的实例克隆后原型是Object.prototype,而不是MyClass.prototype。换句话说,结构化克隆只保证数据类型,不保证类行为

这个限制对业务影响很大。如果你克隆的对象带有自定义方法,这些方法都会丢失,因为方法通常定义在原型上。即便你克隆的是普通对象,如果某个属性是类实例,克隆后也只会变成普通对象。

structuredClone() 在实际项目中的使用方式

现代浏览器和Node.js 17+都提供了全局的structuredClone()方法,使用起来非常简单。

const original = {
  id: 1,
  createdAt: new Date(),
  tags: new Set(['a', 'b']),
  meta: new Map([['key', 'value']])
};

const cloned = structuredClone(original);

console.log(cloned.createdAt instanceof Date); // true
console.log(cloned.tags instanceof Set);       // true
console.log(cloned.meta instanceof Map);       // true

如果你的运行环境不支持structuredClone(),有两个常见方案:一是用MessageChannel模拟,二是使用history.replaceState()的底层机制。但前者是异步的,后者在部分浏览器有长度限制。更稳妥的做法是使用一个基于结构化克隆算法的polyfill,或者干脆在构建阶段通过core-js引入。

和Lodash的deepClone相比,该选谁

在实际工程中,很多团队会用Lodash的cloneDeep来做深拷贝。Lodash的cloneDeep功能也很全面,支持Map、Set、Date、RegExp等类型,也支持循环引用。那么它和结构化克隆有什么区别?

最大的区别在于复制方式。Lodash是递归遍历对象属性,逐个复制,而结构化克隆是引擎级别的算法,底层直接操作对象内部结构。因此,在绝大多数情况下,structuredClone()的性能优于cloneDeep,尤其对于大型对象和二进制数据。

但Lodash也有自己的优势:它支持函数(虽然只是引用复制),支持自定义类实例,并且可以在所有JavaScript环境中稳定运行。而结构化克隆不支持函数,也不支持自定义原型,所以如果你的对象里有方法,cloneDeep反而更合适。

这里给出一个选择参考:

  • 需要跨线程、跨存储边界复制数据时,使用structuredClone()或直接依赖底层API的克隆机制。
  • 只是在同一个JavaScript执行上下文里做深拷贝,且对象包含函数或自定义类实例时,选择Lodash的cloneDeep
  • 对象比较简单,不包含Date、Map、Set等类型,用JSON.parse(JSON.stringify())也不是不可以,但要明确它的边界。

几个容易踩的坑

即使了解了结构化克隆,实际使用中还是会有一些意想不到的问题。

transferList 会清空源对象

postMessage()中传入transferList后,源ArrayBuffer会被detach,不能再使用。很多人第一次用这个功能时,会在主线程继续访问buffer,结果发现byteLength变成了0。

const buffer = new ArrayBuffer(1024);
worker.postMessage(buffer, [buffer]);
console.log(buffer.byteLength); // 0,已转移

Error对象和DOM对象会被静默处理

在某些旧版本浏览器中,结构化克隆遇到不支持的属性时可能会直接丢弃,而不是抛错。这会导致数据静默丢失,排查起来非常费劲。建议在关键路径上显式检查克隆后的数据完整性。

性能不是永远有优势

对于非常小的对象,structuredClone()的性能可能不如JSON.parse(JSON.stringify())。因为结构化克隆要维护引用记录表,处理类型检查,这些都需要开销。所以如果你的数据很简单,直接用JSON方式也许更快。

总结

结构化克隆是JavaScript运行时提供的底层复制算法,它比JSON序列化更强大,主要体现在对内置类型的完整支持、循环引用的处理以及二进制数据的转移能力。它也不是没有代价:函数、Symbol和自定义原型都不能被保留。

在工程中,理解结构化克隆的价值不在于记住API,而在于知道数据在跨边界时是如何被复制的。当你遇到postMessage()中Date变成字符串、IndexedDB中Map丢失等诡异问题时,首先要想到的可能是结构化克隆和JSON序列化的差异。

回到最初的问题:为什么结构化克隆比JSON序列化更强大?因为它不是把对象“拍扁”成文本,而是真正地复制内存中的数据结构。这个差异决定了它能在更多场景下保持数据原貌,也让JavaScript在处理复杂数据传递时更接近原生语言的体验。

原创文章,作者:fudengji,如若转载,请注明出处:https://fudengji.cn/article/455/

(0)
上一篇 1天前
下一篇 2小时前

相关推荐