前端开发··2 阅读·预计 9 分钟

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 语义正确但偏慢,手写递归是兜底。更关键的是先问一句——你真的需要深拷贝吗? 多数情况下,结构共享的浅拷贝既能保证正确性,又能换取数量级的性能提升。

0 评论

评论区

登录 后参与评论