导读:本期聚焦于小宵创作的《在JavaScript中实现深拷贝有哪些需要警惕的陷阱?》,敬请观看详情。深拷贝不像表面那样简单。把对象交给JSON.parse(JSON.stringify(obj))虽然省事,但遇到Date会变成字符串,遇到RegExp会变成空对象,遇到Map和Set会直接丢掉内容;如果对象中存在循环引用,整个序列化过程还会直接抛错。自己写递归拷贝同样不轻松,需要处理原型链、Symbol键、不可枚举属性以及属性描述符,否则拷贝结果会丢失行为或元数据。本文不重复基础概念,直接聚焦那些容易被忽略的边界:为什么浅层副本会互相污染,循环引用如何检测,特殊内置对象怎样正确克隆,以及如何兼顾性能与正确性。读完你会明白,生产环境中的深拷贝往往需要针对具体数据结构做定制,而不是套用一个万能函数。

深拷贝的目标是复制一个对象的值,而不是复制引用。但JavaScript里对象的成员类型非常复杂,一旦出现Date、RegExp或Map,很多看似可用的方案都会悄悄改变语义。更隐蔽的是,属性描述符、Symbol键和原型链在常规拷贝中经常被忽略,最终得到的对象虽然能用,行为却和原对象不一致。

在JavaScript中实现深拷贝有哪些需要警惕的陷阱?

一、JSON序列化方案会静默丢掉哪些数据

JSON.parse(JSON.stringify(obj))是流传最广的深拷贝写法,但它的限制远不止不能处理函数。JSON.stringify会调用对象上的toJSON方法,如果某个对象自己定义了toJSON,序列化结果可能与原值完全不同。例如Date实例会被转成ISO字符串,RegExp实例会变成空对象,Map和Set实例会变成没有任何键值对的普通对象。这些变化在复制后不会报警,而是静默发生,排查起来非常困难。

另一个容易被忽略的问题是undefined和Symbol。如果对象属性值为undefined,JSON.stringify会直接忽略该属性;如果属性键是Symbol,也会被跳过。数组中的undefined会被转成null,而不是保持undefined。下面这段代码展示了几个典型差异。

const source = {
  date: new Date(),
  pattern: /ab/g,
  map: new Map([['name', 'demo']]),
  missing: undefined,
  [Symbol('id')]: 1
};

const copy = JSON.parse(JSON.stringify(source));

console.log(copy.date);    // 字符串,不再是Date实例
console.log(copy.pattern); // {}
console.log(copy.map);     // {}
console.log('missing' in copy); // false
console.log(copy[Symbol('id')]); // undefined

可以看到,JSON方案只适合处理纯JSON数据,也就是由普通对象、数组、字符串、数字、布尔值和null组成的结构。只要数据中混入了一点非JSON类型,就应该放弃这个方案。否则表面上看拷贝成功了,实际已经产生了数据偏差,后续逻辑可能因此出现难以复现的问题。

二、循环引用会让递归拷贝直接崩溃

自己写递归深拷贝时,最经典的错误就是没有处理循环引用。当一个对象通过自身属性直接或间接指向自己时,递归会无限进行下去,最终导致调用栈溢出。即使对象树不是完全循环,只是存在共享引用,也可能让拷贝结果变得不符合预期:原本指向同一个子对象的两个属性,在拷贝后变成了两个独立对象。

解决循环引用的标准做法是引入一个缓存结构,例如WeakMap。每次进入深拷贝函数时,先检查当前对象是否已经复制过,如果复制过就直接返回缓存中的副本。这样不仅能中断循环,还能保持共享引用关系。下面的例子展示了使用WeakMap处理循环引用的基本写法。

function deepClone(value, seen = new WeakMap()) {
  if (typeof value !== 'object' || value === null) {
    return value;
  }
  if (seen.has(value)) {
    return seen.get(value);
  }
  const target = Array.isArray(value) ? [] : {};
  seen.set(value, target);
  for (const key of Reflect.ownKeys(value)) {
    target[key] = deepClone(value[key], seen);
  }
  return target;
}

const a = { name: 'a' };
a.self = a;
const cloneA = deepClone(a);
console.log(cloneA.self === cloneA); // true

WeakMap比Map更适合这个场景,因为它的键是弱引用,不会阻止原对象被垃圾回收。如果使用普通Map,缓存会一直持有原对象和副本的引用,可能导致内存泄漏。实现深拷贝时还要注意数组也是对象,需要先判断Array.isArray,否则数组会被复制成普通对象,丢失length和其他数组行为。

三、特殊内置对象和原型链的克隆难题

深拷贝不止要考虑普通对象和数组。Date、RegExp、Map、Set、ArrayBuffer、Blob、File等内置类型都有自己的内部槽和原型方法,用普通对象字面量复制会丢失这些行为。比如直接复制Date得到的结果只是一个空对象,调用getTime会直接报错;复制RegExp得到空对象,test方法也不存在。正确处理方式是根据原对象的构造器创建同类型实例,再把内部数据填进去。

原型链同样不能忽略。如果原对象是通过class或构造函数创建的,拷贝出来的普通对象会失去原型方法。例如一个Person实例经过简单递归复制后,instanceof Person会返回false,调用原型上的方法也会失败。因此稳健的深拷贝需要保留原型链,可以通过Object.getPrototypeOf获取原型,再用Object.create或构造器创建目标对象。下面的代码展示了一个支持Date、Map、Set和原型链的简化版本。

function cloneSpecial(value, seen = new WeakMap()) {
  if (typeof value !== 'object' || value === null) return value;
  if (seen.has(value)) return seen.get(value);

  let target;
  if (value instanceof Date) {
    target = new Date(value.getTime());
  } else if (value instanceof RegExp) {
    target = new RegExp(value.source, value.flags);
  } else if (value instanceof Map) {
    target = new Map();
    seen.set(value, target);
    for (const [k, v] of value) {
      target.set(cloneSpecial(k, seen), cloneSpecial(v, seen));
    }
    return target;
  } else if (value instanceof Set) {
    target = new Set();
    seen.set(value, target);
    for (const item of value) {
      target.add(cloneSpecial(item, seen));
    }
    return target;
  } else {
    const proto = Object.getPrototypeOf(value);
    target = Object.create(proto);
  }

  seen.set(value, target);
  for (const key of Reflect.ownKeys(value)) {
    const descriptor = Object.getOwnPropertyDescriptor(value, key);
    if (descriptor.value !== undefined || 'value' in descriptor) {
      descriptor.value = cloneSpecial(descriptor.value, seen);
    }
    Object.defineProperty(target, key, descriptor);
  }
  return target;
}

const source = {
  date: new Date(),
  map: new Map([['key', { count: 1 }]]),
  pattern: /ab/gi
};
const copy = cloneSpecial(source);
console.log(copy.date instanceof Date); // true
console.log(copy.map.get('key') !== source.map.get('key')); // true

上面这段代码还存在一些限制,例如没有处理ArrayBuffer、DataView、Error等类型,也没有覆盖所有内置对象。实际项目中更推荐使用成熟库,或者在明确数据结构范围的前提下写针对性克隆函数。万能深拷贝往往代码量巨大,而且任何新出现的类型都可能成为新的漏洞。

四、属性描述符、Symbol键与不可枚举属性

很多深拷贝实现只使用for...in或者Object.keys来遍历属性,这会把不可枚举属性、Symbol键和访问器属性全部漏掉。Object.keys只返回自身可枚举的字符串键,for...in还会遍历原型链上的可枚举属性,两者都无法覆盖完整信息。更准确的做法是使用Reflect.ownKeys,它一次性返回所有字符串键和Symbol键,无论是否可枚举。

拿到键之后,还不能简单地用赋值方式复制属性值。如果原属性是一个访问器属性,赋值会把getter的结果写死成数据属性,丢失原有的访问行为。正确做法是读取属性描述符,通过Object.getOwnPropertyDescriptor获取每个键的配置,再用Object.defineProperty写回目标对象。这样可写性、可枚举性、可配置性以及getter和setter都能完整保留。下面的代码演示了如何复制访问器属性。

const source = {};
Object.defineProperty(source, 'fullName', {
  get() {
    return this.first + ' ' + this.last;
  },
  set(value) {
    [this.first, this.last] = value.split(' ');
  },
  enumerable: true,
  configurable: true
});
source.first = 'Ada';
source.last = 'Lovelace';

const target = {};
const descriptor = Object.getOwnPropertyDescriptor(source, 'fullName');
Object.defineProperty(target, 'fullName', descriptor);
target.first = 'Grace';
target.last = 'Hopper';

console.log(target.fullName); // "Grace Hopper"

这个例子没有递归深拷贝,但它说明了属性描述符的重要性。如果把访问器属性当作普通数据属性复制,目标对象上的fullName就会变成一个固定字符串,后续修改first或last也不会再触发计算。对于Symbol键,许多序列化和遍历方式会直接忽略它们,但Reflect.ownKeys能正确返回,因此深拷贝应当优先使用这个API。

五、实现深拷贝时的几个工程建议

生产环境中的深拷贝不是单一函数就能完美覆盖所有场景的。首先需要明确数据边界:如果你的数据只包含纯JSON结构,JSON.parse(JSON.stringify(obj))确实是最简单可靠的选择,但前提是数据一定不会出现Date、Map、Set、RegExp、undefined、Symbol和循环引用。如果数据来自表单、接口响应或状态管理,通常纯JSON方案就够了。

如果数据中会混入特殊类型,建议使用经过大量项目验证的库,例如lodash的cloneDeep、structuredClone,或者immer。浏览器原生提供的structuredClone已经能处理循环引用、Date、Map、Set、ArrayBuffer等,但不能复制函数和Symbol,而且某些旧版本浏览器需要polyfill。无论选择哪种工具,都要先写测试用例覆盖你关心的边界,而不是默认某个函数一定能正确处理所有数据。

最后,团队内部最好约定深拷贝的使用位置和次数。频繁对大型对象做深拷贝会带来明显的内存和CPU开销,必要时可以配合不可变数据、结构共享或只读访问来减少复制。理解深拷贝的陷阱,不只是为了写出正确的代码,也是在提醒我们:复杂的对象图往往意味着复杂的所有权关系,复制之前先想清楚是否真的需要完整克隆。

JavaScript深拷贝循环引用特殊对象修改时间:2026-10-05 08:17:49

免责声明:已尽一切努力确保本网站所含信息的准确性。网站作品多为原创整理与精心创作,观点力求客观中立。本站旨在免费分享,内容仅供个人学习、研究或参考使用。若引用了第三方作品,版权归原作者所有。如内容涉及您的权益,请联系我们进行处理Email:chomcom@qq.com。
引用或转载本作品时,请注明当前出处:https://www.ipipp.com/html/1005/65916.html,基于非商业用途的前提下,欢迎转载或二创本作品。
内容垂直聚焦
专注技术核心技术栏目,确保每篇文章深度聚焦于实用技能。从代码技巧到架构设计,为用户提供无干扰的纯技术知识沉淀,精准满足专业提升需求。
知识结构清晰
覆盖从开发到部署的全链路。AI、前端、编程、数据库、服务器、建站、系统层层递进,构建清晰学习路径,帮助用户系统化掌握开发与运维所需的核心技术。
深度技术解析
拒绝泛泛而谈,深入技术细节与实践难点。无论是数据库优化还是服务器配置,均结合真实场景与代码示例进行剖析,致力于提供可直接应用于工作的解决方案。
专业领域覆盖
精准对应开发生命周期。从前端界面到后端编程,从数据库操作到服务器运维,形成完整闭环,一站式满足全栈工程师和运维人员的技术需求。
即学即用高效
内容强调实操性,步骤清晰、代码完整。用户可根据教程直接复现和应用于自身项目,显著缩短从学习到实践的距离,快速解决开发中的具体问题。
持续更新保障
专注既定技术方向进行长期、稳定的内容输出。确保各栏目技术文章持续更新迭代,紧跟主流技术发展趋势,为用户提供经久不衰的学习价值。