深拷贝这笔账:structuredClone 到底能拷什么,又偷偷丢下了什么

上周修一个 bug,现象是这样的:表单里的日期填了,提交之后后端说收到的不是日期。

我看了半天,最后发现是深拷贝那一行:

const draft = JSON.parse(JSON.stringify(formState))

formState.birthday 本来是一个 Date,走完这一趟变成了字符串。而同一个对象里的 formState.remark 是 undefined,走完这一趟直接消失了——不是变成 undefined,是键没了。

这两件事我其实都知道,但每次都靠线上炸了才想起来。于是这个周末我把 structuredClone 翻了一遍,想搞清楚一件事:如果换用它,我到底要重新记哪些规则?

一座玻璃雕塑正在被复制,左边是发光的原物,右边是半透明的复制品

下面所有数字都是我在 Node v24.16.0(Linux x86_64)上跑出来的,脚本不依赖任何网络,你可以照着复现。

# 一、它不是一个新算法,是一个被借用了十几年的老算法

structuredClone 是 2022 年前后才集体就位的浏览器 API(Chrome 98、Firefox 94、Safari 15.4,Node 从 17 起),但它底下的结构化克隆算法(Structured Clone Algorithm)一点都不新——postMessage、worker、IndexedDB 用了它十几年了。

以前你想用,得绕一圈:

// 老办法:借 postMessage 做深拷贝
function clone(obj) {
  return new Promise((resolve) => {
    const { port1, port2 } = new MessageChannel()
    port2.onmessage = (e) => resolve(e.data)
    port1.postMessage(obj)
  })
}

现在只需要 structuredClone(obj),而且是同步的。

我顺手验证了一下它们确实是同一套规则——起一个 worker,主线程发过去一份带 Map 的对象,worker 改完再发回来:

主线程发出:  { payload: { n: 1 }, m: Map(1) }
worker 回传: payload.n = 2        ← 主线程原对象 n 仍然是 1,说明是副本
             m instanceof Map = true   ← Map 没退化成普通对象

而给 postMessage 传一个带函数的对象,报的错和 structuredClone 一模一样:

DOMException: () => 1 could not be cloned.

结论:你在 postMessage / IndexedDB 里踩过的坑,structuredClone 一个都不少地继承了下来。 区别只是现在它是同步的、随取随用的。

等距视角的两个房间代表主线程与 worker,中间传送带运送着发光的数据箱

# 二、先划清界限:什么能过,什么过不去

我把能想到的类型挨个喂了一遍,结果分成两类。

能过的:

类型 克隆后还是它自己吗 备注
number / string / boolean 是 含 NaN、Infinity、-0
null / undefined 是
BigInt 是 JSON 路线在这里直接抛 TypeError
Date 是 包括 Invalid Date
RegExp 是 但 lastIndex 会归零,见第三节
Map / Set 是 这是 JSON 路线永远做不到的
ArrayBuffer / 各种 TypedArray 是 真·内存拷贝,第七节单独算
DataView 是
Error(含 cause) 是 但挂在 Error 上的自定义字段会丢
Blob / File 是
装箱的 Number/String/Boolean 是 还是装箱的,不会拆成原始值

过不去的(全部抛 DOMException):

值 报错
Symbol('s') DOMException: Symbol(s) could not be cloned.
函数(含箭头函数、对象里的函数字段) DOMException: () => 1 could not be cloned.
Promise.resolve() DOMException: #<Promise> could not be cloned.
WeakMap / WeakSet / WeakRef DOMException: #<WeakMap> could not be cloned.
FinalizationRegistry 同上
new URL('https://example.com/') DOMException: Cannot clone object of unsupported type.

最后一行是我这次最意外的发现。URL 不在可克隆清单里,而它偏偏是个很常见的东西。对照一下:JSON 路线反而"成功"了——因为 URL 有 toJSON(),会变成一个字符串。所以这两条路对 URL 的处理是反过来的:JSON 给你一个能用的字符串,structuredClone 直接抛异常。

顺带一个容易混的点:Symbol 作为 key 和作为 value 是两回事。

structuredClone({ [Symbol('k')]: 1, a: 2 }) // OK,但结果只有 { a: 2 },Symbol 键被静默丢弃
structuredClone({ a: Symbol('v') }) // DOMException: Symbol(v) could not be cloned.

前者静默丢数据,后者抛异常。静默的那个更危险。

安检口的扫描仪:绿色一侧放行数据箱,红色屏障拦下代表函数与 Symbol 的抽象形体

# 三、它拷的时候会偷偷改东西(这部分最容易踩)

这一节是我认为最值得记的。能通过克隆,不代表拷过来和原来一模一样——有七处是会被改掉的。

# 3.1 原型会被剥掉

class Point {
  constructor(x, y) {
    this.x = x
    this.y = y
  }
  len() {
    return this.x + this.y
  }
}
const c = structuredClone(new Point(1, 2))
c instanceof Point // false
Object.getPrototypeOf(c) === Object.prototype // true
c.len() // TypeError: c.len is not a function

数据(x、y)留下了,行为(len)没了。Object.create(null) 造出来的无原型对象,克隆之后也会变成普通的 Object.prototype。

# 3.2 getter 会被求值成普通值

const o = {}
Object.defineProperty(o, 'v', {
  get: () => 7,
  enumerable: true,
  configurable: true
})
const c = structuredClone(o)
Object.getOwnPropertyDescriptor(c, 'v')
// { value: 7, writable: true, enumerable: true, configurable: true }

getter 变成了一个写死的 7。如果你拷的是个带计算属性的响应式对象,这个属性就不再跟着源数据变了。

# 3.3 不可枚举属性直接消失

const o = { a: 1 }
Object.defineProperty(o, 'b', {
  value: 2,
  enumerable: false,
  writable: true,
  configurable: true
})
structuredClone(o) // { a: 1 }  —— b 没了

# 3.4 属性描述符全部重置

writable: false 拷完变成 writable: true;Object.freeze() 出来的对象拷完是可扩展的;Object.seal() 同理。冻结状态不会被克隆。

# 3.5 RegExp 的 lastIndex 归零

const r = /\d/g
r.lastIndex = 5
const rc = structuredClone(r)
rc.lastIndex // 0
rc.source // '\d'
rc.flags // 'g'

source 和 flags 都在,只有 lastIndex 被重置。如果你在用一个带 g 标志的正则做迭代,拷一份接着用,位置就丢了。

# 3.6 Error 的自定义字段会丢

const e = Object.assign(new Error('boom'), { code: 'E_TIMEOUT' })
const ec = structuredClone(e)
ec.message // 'boom'
ec.code // undefined  ← 没了

name、message、stack、cause 会保留,其余自有属性一律丢弃。顺带一提,克隆出来的 Error 的 stack 变成了一个 accessor(有 getter/setter),和原生的不太一样。

# 3.7 稀疏数组的洞是真的洞

const a = []
a[5] = 1
const c = structuredClone(a)
c.length // 6
Object.keys(c) // ['5']

洞被保留,不是被填成 undefined。数组上挂的自定义属性(a.extra = 'x')也会保留。

一个人形机器人走开,把半透明的外壳留在地上,象征失去的原型

# 四、引用语义:这一节是它比 JSON 强的地方

JSON.parse(JSON.stringify(o)) 遇到循环引用会直接炸:

const o = {}
o.self = o
JSON.parse(JSON.stringify(o)) // TypeError: Converting circular structure to JSON

structuredClone 不但不炸,而且把图的结构原样保留了下来:

obj.self:   clone.self === clone          → true
Map 自引用: clone.get('self') === clone   → true
数组自引用: clone[1] === clone            → true

更关键的是共享引用。同一个对象被两个字段引用,克隆之后仍然共享:

const shared = { n: 1 }
const c = structuredClone({ a: shared, b: shared })
c.a === c.b // true   ← 没有被复制成两份
c.a === shared // false  ← 也不是浅拷贝
c.a.n = 99
c.b.n // 99
shared.n // 1      ← 原对象没被污染

这一点很重要:它是有向图(graph)的克隆,不是树(tree)的克隆。 你不会把一份共享数据意外变成两份各自独立的副本——这正是手写的递归深拷贝最容易写错的地方(要么忘了做 visited map 而栈溢出,要么做了但把共享引用拆开了)。

# 五、那到底什么时候该用哪个

把两边的语义差异摆在一起看:

字段 structuredClone 之后 JSON 往返之后
new Date() Date string
new Map([['a',1]]) Map Object → {}
new Set([1,2]) Set Object → {}
new Uint8Array([1,2]) Uint8Array Object → {"0":1,...}
new Error('boom') Error Object → {}
new URL('https://a.b/') ✗ DOMException string(靠 toJSON)
undefined(对象值) undefined(键保留) 键被删除
undefined(数组元素) undefined null
NaN NaN null
Infinity Infinity null
-0 -0(Object.is 可验证) 0(负号丢失)
1n (BigInt) 1n ✗ TypeError
函数 ✗ DOMException 静默删除
Symbol ✗ DOMException 静默删除
循环引用 正常克隆 ✗ TypeError
原型上的 toJSON() 被忽略 被调用

两个观察:

  1. structuredClone 会抛异常,JSON 会静默丢数据。 从工程角度看,抛异常其实更好——问题在拷贝那一刻就暴露,而不是等到三四个函数调用之后发现字段是 undefined。
  2. toJSON 只对 JSON 路线有效。 结构化克隆不认它。

# 六、性能:不是所有场景都更快

这是我最想算清楚的一节。八种载荷,每种跑 20 次取中位数(跑前预热 3 次):

载荷 structuredClone JSON 全程 其中 stringify JSON / sc
10 万个数字的数组 1.30 ms 1.51 ms 0.84 ms 1.16x
10 万个短字符串 2.12 ms 4.61 ms 0.99 ms 2.17x
5 万个键的扁平对象 12.27 ms 10.21 ms 5.47 ms 0.83x
1 万个对象的数组(8 字段) 7.15 ms 5.93 ms 2.05 ms 0.83x
1 MB 单个长字符串 0.16 ms 0.85 ms 0.58 ms 5.39x
8 MB Uint8Array 0.76 ms 685.98 ms 512.22 ms 899x
深度 800 的链 0.16 ms 0.13 ms 0.10 ms 0.80x

有三档,值得分开说:

structuredClone 明显更快(2 倍以上)——大量字符串、单个大字符串、二进制数据。原因很直观:JSON 路线要把整个结构序列化成文本再解析回来,字符串要转义、数字要转字符再转回来。而 structuredClone 走的是内存里的拷贝路径。1 MB 纯字符串那一行最典型:0.16 ms 对 0.85 ms,快 5.39 倍。

JSON 更快(约 1.2 倍,也就是 0.83x)——键很多的对象/数组。这一档里 structuredClone 慢 20% 左右,因为 JSON 的序列化和解析是 V8 里高度优化的原生代码,而 structuredClone 要递归地为每个对象做类型判断和内存分配。5 万个键的扁平对象就是最坏情况:12.27 ms 对 10.21 ms。

不能比的一档——Map 有 10 万个键时,structuredClone 用了 10.57 ms,而 JSON.stringify 的结果是 "{}",耗时 0。这个 0 不是"快",是根本没有拷对。拿 JSON 拷 Map 是个 bug,不是优化。

两个几何runner在跑道上竞速,前方的人拖着长长的运动模糊残影,旁边有秒表

# 七、二进制数据:8 MB 拷 0.76 毫秒,JSON 拷 686 毫秒

上面那个 899 倍值得单独拆开看,因为它不只是慢,是体积先炸了。

JSON.stringify 一个 Uint8Array,会把它当成普通对象,逐下标展开成 {"0":0,"1":0,...}:

Uint8Array JSON 字符串体积 膨胀倍数 stringify 耗时
1 MB 10.9 MB 10.9x 53 ms
4 MB 46.9 MB 11.7x 289 ms
8 MB 94.9 MB 11.9x 591 ms
16 MB 197.4 MB 12.3x 1330 ms
32 MB 405.4 MB 12.7x 3155 ms
64 MB ✗ RangeError: Invalid string length — —

8 MB 的二进制数据在内存里走 JSON 一趟,中间要生成一个 95 MB 的字符串。 到 64 MB 就直接越界了——V8 的字符串最大长度是 536870888 字节(512.0 MB),64 MB 的 typed array 按同一比例展开后约 810 MB,超出上限。

而 structuredClone 拷同样的 8 MB,耗时 0.76 ms。因为它拷的是内存块,不是文本。

代价是内存真的翻倍,我用 process.memoryUsage().arrayBuffers 量了一下(这个计数器不受 GC 抖动影响):

分配一个 64 MB ArrayBuffer:   arrayBuffers +64.0 MB
再 structuredClone 一次:      arrayBuffers 再 +64.0 MB

克隆出来的是一块新的、独立的内存,不是引用——这点从"改了克隆体的第 0 个字节,原数组第 0 个字节不变"也能验证。

一个小立方体在左侧,右侧膨胀成一个巨大的橙色气球形状,箭头指示膨胀方向

# transfer:可以只搬不拷

如果你拷完就不打算再用原对象了,可以让它移交而不是复制:

const buf = new Uint8Array([1, 2, 3, 4]).buffer
const moved = structuredClone(buf, { transfer: [buf] })

buf.byteLength // 0
buf.detached // true   ← 原来的那块内存被"搬走"了
moved.byteLength // 4

这在处理大文件、音频缓冲、OffscreenCanvas 这类场景里是省掉一整次拷贝的办法。代价是原对象立刻失效,再访问就报错。

# SharedArrayBuffer:拷了对象,没拷内存

这是个反直觉的行为:

const sab = new SharedArrayBuffer(8)
const copy = structuredClone(sab)
copy === sab // false  ← 是新的对象
new Uint8Array(copy)[0] = 7
new Uint8Array(sab)[0] // 7      ← 但底下是同一块内存

对象外壳是新的,内存是共享的。这符合"共享内存"的本意,但如果你以为拿到了独立副本,就会踩到并发写入。

# 八、一个反直觉的结论:深嵌套时它比 JSON 更脆

我一直以为 structuredClone 是"更现代、更健壮"的那个,直到测了深度。

构造一条 o.next.next.next... 的链,二分找出能撑住的最大深度:

structuredClone:                 1562 层
JSON.stringify:                  3125 层
JSON.parse(JSON.stringify()):    3125 层

structuredClone 的最大深度只有 JSON 的一半,超过就 RangeError: Maximum call stack size exceeded。

原因是它是递归实现的,而且每层递归消耗的栈帧比 JSON.stringify 多。我验证了一下这确实纯粹是栈的问题——把栈调大 4 倍:

node --stack-size=4000
  structuredClone:  6250 层  (1562 × 4)
  JSON.stringify:  12500 层  (3125 × 4)

严格线性,确认是栈限制而不是算法的硬上限。

日常业务数据几乎不可能到 1500 层,所以这条多半碰不到。但如果你在处理深度嵌套的 AST、深递归的 JSON schema 或者被恶意构造的深层 payload,这是个真实的边界:structuredClone 会先炸,而且是从外面看不出原因的 RangeError。

# 九、所以我的结论

默认用 structuredClone。 理由不是它更快(第六节里它在两档上反而慢 20%),而是它不会静默丢数据:Date 不会变成字符串,Map/Set 不会变成 {},-0 不会变成 0,undefined 不会被删键,循环引用不会崩。它的失败方式是抛异常,而不是给你一份看起来正常、实际上少了三个字段的数据。

但别忘了它的七条暗规则:原型会剥掉、getter 会求值、不可枚举属性会丢、描述符会重置、RegExp 的 lastIndex 归零、Error 的自定义字段丢失、Symbol 键静默丢弃。拷 class 实例、拷带计算属性的响应式对象(比如 Vue 的 reactive、MobX 的 observable)时,这几条会咬人。

拷二进制一定要用它。 8 MB 数据:0.76 ms 对 686 ms,中间还不用生成 95 MB 的临时字符串。

JSON 路线还剩一个用处:你需要一个能直接 JSON.stringify 发出去、或者要写进 localStorage、或者要跨语言边界传输的纯数据快照时。这时候"Date 变成字符串"不是 bug,正是你要的。

最后给一个我自己现在在用的判断顺序:

// 1. 就是想拷一份数据,且不需要保留原型/方法
const next = structuredClone(state)

// 2. 要发给后端 / 存 localStorage / 要一个纯 JSON 快照
const payload = JSON.parse(JSON.stringify(state))

// 3. 要保留原型和方法 → 两个都不行,老老实实写一个拷贝构造函数

十年了,JSON.parse(JSON.stringify(x)) 一直是我们手边最近的那个工具。但它从来就不是为深拷贝设计的,它是为序列化设计的。structuredClone 才是那个真正叫"深拷贝"的东西——只是它也有自己的脾气,值得花一个下午摸清楚。