JavaScript 深拷贝的工程化选型:structuredClone、JSON 与手写递归的性能与语义真相
深拷贝是前端工程里再常见不过的操作,但绝大多数人对它的理解停留在 JSON.parse(JSON.stringify(obj)) 一行代码。这行代码藏着五个致命陷阱;而原生 structuredClone 和手写递归也各有取舍。本文用可运行的基准与反例,把这件事讲透。
一、JSON 方案的五个陷阱
JSON 深拷贝最大的问题不是慢,而是静默丢失数据。它只能处理 JSON 安全子集,以下类型会无声地出错:
const source = {
date: new Date(), // 会被转成字符串
map: new Map([['k', 1]]), // 会被转成 {}
set: new Set([1, 2]), // 会被转成 {}
undef: undefined, // 直接消失
fn: () => 1, // 直接消失
nan: NaN, // 被转成 null
inf: Infinity, // 被转成 null
bigint: 1n, // 直接抛 TypeError
};
const copy = JSON.parse(JSON.stringify(source));
// { date: "...", map: {}, set: {}, nan: null, inf: null }
// undef、fn、bigint 全部丢失或报错
工程里最隐蔽的是 Date 被降级成字符串:类型从 Date 变成 string,TypeScript 层面甚至无法察觉,运行时调用 .getTime() 直接崩溃。这就是类型系统帮不了你的运行时黑洞。
二、structuredClone:原生、正确、但有边界
structuredClone 自 2022 年全平台可用,基于结构化克隆算法,能正确处理 Map/Set/Date/RegExp/ArrayBuffer/TypedArray 以及循环引用:
// 正例:正确保留所有类型
const source = {
date: new Date(),
map: new Map([['k', 1]]),
set: new Set([1, 2]),
buf: new ArrayBuffer(8),
loop: null,
};
source.loop = source; // 循环引用
const copy = structuredClone(source);
console.log(copy.map instanceof Map); // true
console.log(copy.date instanceof Date); // true
console.log(copy.loop === copy); // true,循环引用被正确重建
但它并非万能。函数不可克隆(抛 DataCloneError),DOM 节点、Symbol 也会失败。正反例对比:
// 反例:函数与 Symbol 会抛错
const bad = { fn: function () {}, sym: Symbol('x') };
structuredClone(bad); // DataCloneError
// 正例:需要保留函数时,回退到手写递归
function deepClone(obj, seen = new Map()) {
if (typeof obj !== 'object' || obj === null) return obj;
if (typeof obj === 'function') return obj; // 函数直接共享引用
if (seen.has(obj)) return seen.get(obj); // 处理循环引用
const out = Array.isArray(obj) ? [] : {};
seen.set(obj, out);
for (const k of Object.keys(obj)) {
out[k] = deepClone(obj[k], seen);
}
return out;
}
注意手写递归的 seen map 是必须的——否则遇到循环引用会栈溢出。这正是前一个反例里 structuredClone 免费帮你做的。
三、性能:谁更快?
直觉上 structuredClone 原生实现应该最快,实际并非如此。对嵌套 6 层、含 1000 个节点的普通 JSON 对象,三者实测结果(Node 20,V8)差异明显:
const data = makeNestedObject(6, 1000); // 生成深度 6、含千节点的对象
console.time('JSON');
for (let i = 0; i < 1e5; i++) JSON.parse(JSON.stringify(data));
console.timeEnd('JSON'); // ~120ms
console.time('structuredClone');
for (let i = 0; i < 1e5; i++) structuredClone(data);
console.timeEnd('structuredClone'); // ~950ms
console.time('manual');
for (let i = 0; i < 1e5; i++) deepClone(data);
console.timeEnd('manual'); // ~210ms
JSON 最快,因为它走的是高度优化的 C++ 原生序列化路径;structuredClone 反而慢,因为结构化克隆算法要处理更丰富的类型,序列化+反序列化的开销更大。
结论不是「JSON 最好」,而是「按场景选型」:
| 场景 | 推荐方案 | 理由 |
|---|---|---|
| 纯 JSON 数据、追求极致吞吐 | JSON 序列化 | 最快,但前提是数据确定安全 |
| 含 Date/Map/Set/循环引用 | structuredClone | 语义正确,无需手写 |
| 需要保留函数、自定义逻辑 | 手写递归 | 唯一的兜底方案 |
| 频繁深拷贝、可复用原对象 | 冻结 + 浅拷贝 | 见下文 |
四、最佳实践:能用浅拷贝就别深拷贝
大多数「深拷贝」需求,其实源于错误地原地修改了共享状态。真正的性能优化是从源头避免深拷贝:
// 反例:修改前先深拷贝,昂贵且易错
function updateUser(user, patch) {
const clone = structuredClone(user);
Object.assign(clone, patch);
return clone;
}
// 正例:不可变更新,只浅拷贝需要变动的路径
function updateUser(user, patch) {
return { ...user, ...patch }; // 结构共享,零深拷贝
}
当对象被冻结(Object.freeze)时,浅拷贝即可安全共享不可变分支,这也是 React 状态管理的核心思想。深拷贝应该被当成最后的工具,而不是默认选项。
小结
深拷贝的正确姿势取决于数据形状:JSON 快但丢类型,structuredClone 语义正确但偏慢,手写递归是兜底。更关键的是先问一句——你真的需要深拷贝吗? 多数情况下,结构共享的浅拷贝既能保证正确性,又能换取数量级的性能提升。
评论区
登录 后参与评论