structuredClone 与深拷贝
structuredClone 是浏览器与 Node 内置的深拷贝。它比 JSON 方案强很多,但不是万能替代品。
1. 它能拷贝哪些结构
structuredClone 的底层就是浏览器一直在用的结构化克隆算法——postMessage 传数据时用的同一套规范。因此它的能力边界和 postMessage 完全一致:支持循环引用、Map、Set、Date、RegExp、ArrayBuffer、TypedArray、Error,不用自己写递归,也不会像 JSON.parse(JSON.stringify()) 那样把 Date 变成字符串、把循环引用直接报错。
除了「克隆」,它还支持第二个参数 options.transfer 做零拷贝移交。普通克隆对大 ArrayBuffer 是整段内存复制,而 transfer 把底层资源的所有权直接移交给克隆体,原对象的 buffer 立即被「剥离」(detached),byteLength 变成 0——内存没有被复制,只是所有权挪了位置。对几十 MB 的二进制(图像、音频、几何体数据)这是量级级别的省内存手段。
// 50MB buffer:用 transfer 移交所有权,不复制内存
const largeBuffer = new ArrayBuffer(50 * 1024 * 1024)
const data = { buffer: largeBuffer, type: 'image' }
const clone = structuredClone(data, { transfer: [largeBuffer] })
console.log(largeBuffer.byteLength) // 0 —— 原 buffer 已被剥离
console.log(clone.buffer.byteLength) // 52428800 —— 克隆体持有内存
两点要记牢:只有 Transferable 类型(ArrayBuffer、MessagePort、OffscreenCanvas、ImageBitmap 等)能进 transfer 列表,普通对象进不去;transfer 之后原 buffer 不可再用,若还想保留原对象就别传它。这个能力和「深拷贝」是两回事——拷贝是 O(n) 制造新内存,移交是 O(1) 挪动所有权。
2. 哪些值它会直接抛错
三条硬限制要记住:函数与 DOM 节点会直接抛 DataCloneError;原型链不会保留,类实例会退化成普通对象;getter/访问器属性会被求值成普通属性。所以「有了 structuredClone 就不用手写深拷贝」是错的——需要保留原型、需要自定义拷贝策略(比如跳过某些字段、处理第三方类实例)时,仍然要自己写。
function deepClone(source, cache = new WeakMap()) {
if (source === null || typeof source !== 'object') return source
if (cache.has(source)) return cache.get(source) // 循环引用
if (source instanceof Date) return new Date(source)
if (source instanceof RegExp) return new RegExp(source.source, source.flags)
const target = new source.constructor() // 保留原型
cache.set(source, target)
for (const key of Reflect.ownKeys(source)) {
target[key] = deepClone(source[key], cache)
}
return target
}
手写深拷贝必须处理四件事:用 WeakMap 缓存已拷贝对象来对付循环引用、用 constructor 保留原型、对 Date/RegExp/Map/Set 做类型分派、用 Reflect.ownKeys 覆盖 Symbol 键。上面这段是最小可用版本,真实项目里还会加 ArrayBuffer 这类二进制的拷贝。
// 函数、DOM 节点无法克隆,直接抛 DataCloneError
const obj = { fn: () => 1, node: document.body }
structuredClone(obj) // 抛 DataCloneError
3. 比 JSON 方案强在哪
JSON 方案的问题远不止「丢类型」。它把 undefined 和 Symbol 键直接丢掉、NaN 变成 null、循环引用直接抛错、Date 被序列化成字符串。这些在「试算」「快照」这类场景里是隐蔽的 bug 源。
// JSON 方案的坑:undefined 被丢、Date 变字符串、循环引用报错
const data = { a: undefined, b: new Date('2026-01-01'), self: null }
data.self = data
JSON.parse(JSON.stringify(data)) // 抛错:循环引用
| 场景 | JSON 方案 | structuredClone |
|---|---|---|
| 循环引用 | 抛错 | 支持 |
| Date | 变字符串 | 保留 |
| undefined / Symbol 键 | 丢失 | 保留 |
| 函数 / DOM | 丢失 | 抛 DataCloneError |
所以「JSON 方案只是丢类型」是低估了它——它连 undefined 都保不住。
什么时候仍需手写
决策很直接:普通数据(纯对象、数组、Map/Set、Date 这类)直接用 structuredClone;需要保留原型、需要自定义拷贝策略、要处理第三方类实例时,才手写。先问自己「需不需要特殊处理」,再决定要不要造轮子。
还有一个常被忽略的场景是边界克隆(defensive copy):在系统边界(API 响应入口、Web Worker 消息、第三方库回调)收到外部数据时,先 structuredClone 一份再使用,避免外部引用在你不知情时被改掉。配合 transfer 还能在「移交所有权 + 防止原对象被误改」之间取平衡——比如异步校验 buffer 内容后再落库,校验期间把 buffer 转一份并 transfer,原 buffer 即使被外部修改也不影响你手上这份。
还有一类隐蔽差异:getter 会在克隆时被求值一次,变成一潭死水:
const obj = {
n: 1,
get double() { return this.n * 2 },
}
const copy = structuredClone(obj)
copy.n = 10
console.log(copy.double) // 20?不,是 2 —— 克隆时已经求值成固定值,getter 没了
拷贝代价有多大
深拷贝是 O(n) 操作,而且会瞬间制造大量短期对象、增加 GC 压力。在热路径(比如列表里每条记录的 onChange)上整体深拷贝,是性能劣化的常见来源。能用不可变更新(只改变的那一层)就别整体拷贝——能不拷贝就不拷贝。若数据含大二进制且只需移交、不需要保留原件,transfer 是 O(1) 的零拷贝,比整体深拷贝快得多;需要保留原件时再用普通克隆。
structuredClone 支持哪些类型、哪些会抛错,MDN:structuredClone 有明确清单,函数与 DOM 节点那两条尤其容易踩。