实现 `this` 对象的深拷贝:从“循环引用崩溃”到生产级解决方案

在封装一个图表组件时,遇到个棘手问题:

js 复制代码
class Chart {
  constructor(options) {
    this.config = options
    this.init()
  }
  
  init() {
    // 需要备份原始配置,用于重置
    this.originalConfig = this.deepClone(this.config) // ❌ 这里出问题了
  }
  
  reset() {
    this.config = this.deepClone(this.originalConfig) // 恢复默认
  }
}

结果一运行就报错:Converting circular structure to JSON

问题出在 this 对象或其属性中存在循环引用 ------比如 config.parent = config,或者包含了 DOM 元素、函数等无法序列化的值。

一、问题场景:组件状态备份与重置

我们有个可配置的 ECharts 封装组件,要求:

  1. 初始化时保存一份原始配置 originalConfig
  2. 用户修改配置后,能一键"恢复默认"
  3. 配置项可能包含函数(如 formatter)、DOM 节点、甚至循环引用

如果直接 this.originalConfig = this.config,那就是浅拷贝,修改 config 会污染原始值。


二、解决方案:手写一个支持 this 上属性的深拷贝

js 复制代码
// deepClone.js
function deepClone(obj, hash = new WeakMap()) {
  // 🔍 1. 基础类型或 null/undefined
  if (obj == null || typeof obj !== 'object') return obj
  
  // 🔍 2. 处理日期
  if (obj instanceof Date) return new Date(obj)
  
  // 🔍 3. 处理正则
  if (obj instanceof RegExp) return new RegExp(obj)
  
  // 🔍 4. 处理函数(直接返回,不拷贝)
  if (typeof obj === 'function') return obj
  
  // 🔍 5. 解决循环引用
  if (hash.has(obj)) return hash.get(obj)
  
  // 🔍 6. 获取对象构造器(保留原型链)
  const result = new obj.constructor()
  
  // 🔍 7. 先存入 hash,防止递归爆栈
  hash.set(obj, result)
  
  // 🔍 8. 遍历所有可枚举和不可枚举属性(包括 Symbol)
  const keys = [...Object.getOwnPropertyNames(obj), ...Object.getOwnPropertySymbols(obj)]
  for (const key of keys) {
    const descriptor = Object.getOwnPropertyDescriptor(obj, key)
    if (descriptor.value !== undefined) {
      result[key] = deepClone(descriptor.value, hash)
    } else {
      // 处理 getter/setter
      Object.defineProperty(result, key, {
        ...descriptor,
        value: descriptor.value ? deepClone(descriptor.value, hash) : undefined
      })
    }
  }
  
  return result
}

// 在类中使用
class Chart {
  constructor(options) {
    this.config = options
    this.init()
  }
  
  init() {
    // ✅ 安全地备份 this 上的配置
    this.originalConfig = deepClone(this.config)
  }
  
  reset() {
    this.config = deepClone(this.originalConfig)
  }
}

三、原理剖析:从表面到 V8 引擎的三层机制

1. 表面用法:深拷贝的常见误区

方法 是否支持 问题
JSON.parse(JSON.stringify(obj)) 丢失函数、undefined、Symbol、循环引用报错
Object.assign() 浅拷贝
structuredClone() (新 API) 不支持函数、DOM 元素
js 复制代码
// ❌ JSON 方法的坑
const obj = { fn: () => {}, date: new Date(), self: null }
obj.self = obj // 循环引用
JSON.parse(JSON.stringify(obj)) // 报错!

2. 底层机制:V8 引擎如何处理对象引用

我们来画一张 对象引用与内存模型图

%% 内存结构示意图:栈、堆与 WeakMap 关系 graph LR subgraph 栈 Stack A["this.config"] B["this.circular"] end subgraph 堆 Heap C["{ a: 1, b: {...}, fn: → [Function] }"] D["WeakMap(hash)"] end %% 栈 → 堆 的引用 A -.->|指向| C B -.->|循环引用| C %% WeakMap 的键值对 D -.->|键: ref1| E["result1"] D -.->|键: ref2| F["result2"] %% 样式 classDef stack fill:#cce5ff,stroke:#333 classDef heap fill:#d4edda,stroke:#333 class A,B stack class C,D,E,F heap

关键点:

  • WeakMap 用于存储"原对象 → 新对象"的映射,且不会阻止垃圾回收
  • new obj.constructor() 保留了原型链,比 {} 更准确
  • Object.getOwnPropertyNames/Symbols 获取所有键,包括不可枚举的
  • Object.getOwnPropertyDescriptor 处理 getter/setter

3. 设计哲学:为什么深拷贝这么难?

JavaScript 的对象系统是动态、灵活但复杂的:

特性 深拷贝挑战
原型链 需要 new constructor 保留继承关系
循环引用 需要 WeakMap 缓存防止无限递归
特殊对象 Date/RegExp 需要特殊处理
函数 无法"拷贝"执行逻辑,只能引用
DOM 节点 属于宿主环境,不能拷贝

💡 类比:

深拷贝就像"克隆一只猫"------

  • 不能只复制外表(浅拷贝)
  • 要复制基因(构造器)
  • 还要处理"这只猫看着镜子里的自己"这种循环引用
  • 但"喵喵叫"(函数)是行为,不是数据,无法复制

四、生产级优化:支持更多类型

js 复制代码
function deepClone(obj, hash = new WeakMap()) {
  if (obj == null || typeof obj !== 'object') return obj
  
  // 支持更多内置对象
  if (obj instanceof Date) return new Date(obj)
  if (obj instanceof RegExp) return new RegExp(obj)
  if (obj instanceof Set) return new Set([...obj].map(v => deepClone(v, hash)))
  if (obj instanceof Map) return new Map([...obj].map(([k, v]) => [deepClone(k, hash), deepClone(v, hash)]))
  if (obj instanceof ArrayBuffer) return obj.slice(0)
  if (ArrayBuffer.isView(obj)) return new obj.constructor(obj.buffer.slice(0))
  
  if (typeof obj === 'function') return obj
  
  if (hash.has(obj)) return hash.get(obj)
  
  const result = new obj.constructor()
  hash.set(obj, result)
  
  const keys = [...Object.getOwnPropertyNames(obj), ...Object.getOwnPropertySymbols(obj)]
  for (const key of keys) {
    const descriptor = Object.getOwnPropertyDescriptor(obj, key)
    if (descriptor.value !== undefined) {
      result[key] = deepClone(descriptor.value, hash)
    } else {
      Object.defineProperty(result, key, {
        ...descriptor,
        value: descriptor.value ? deepClone(descriptor.value, hash) : undefined
      })
    }
  }
  
  return result
}

五、对比主流方案

方案 优点 缺点 适用场景
JSON.parse/stringify 简单 不支持函数、循环引用 纯数据对象
structuredClone 浏览器原生,支持 transfer 不支持函数、老浏览器 消息传递、Worker
手写递归 + WeakMap 完全可控,支持复杂类型 代码复杂 生产环境深拷贝
Lodash cloneDeep 成熟稳定 包体积大 项目已用 Lodash

六、举一反三:三个变体场景实现思路

  1. 需要"选择性深拷贝"
    传入 ignoreKeys: ['fn', 'dom']customHandlers 处理特定类型。
js 复制代码
function deepClone(obj, { ignoreKeys = [], customHandlers = {}, hash = new WeakMap() } = {}) {
  // 在遍历时跳过 ignoreKeys
}
  1. 实现"深冻结"

    递归 Object.freeze,防止配置被意外修改。

  2. 结合 Proxy 实现"响应式备份"

    监听配置变化,自动触发 UI 更新,同时保留可恢复的原始值。


小结

基础类型直接返,
日期正则特殊判,
函数引用原样传,
WeakMap 解循环,
constructor 保原型,
getOwnProperty 拿全键。

相关推荐
ObjectX前端实验室34 分钟前
【react18原理探究实践】scheduler原理之Task 完整生命周期解析
前端·react.js
ObjectX前端实验室43 分钟前
【react18原理探究实践】调度器(Scheduler)原理深度解析
前端·react.js
路漫漫心远1 小时前
音视频学习笔记十八——图像处理之OpenCV检测
前端
摸着石头过河的石头1 小时前
从零开始玩转前端:一站式掌握Web开发基础知识
前端·javascript
sniper_fandc1 小时前
关于Mybatis-Plus的insertOrUpdate()方法使用时的问题与解决—数值精度转化问题
java·前端·数据库·mybatisplus·主键id
10岁的博客2 小时前
技术博客SEO优化全攻略
前端
南屿im2 小时前
别再被引用坑了!JavaScript 深浅拷贝全攻略
前端·javascript
想要一辆洒水车3 小时前
vuex4源码分析学习
前端
sophie旭3 小时前
一道面试题,开始性能优化之旅(6)-- 异步任务和性能
前端·javascript·性能优化
年少不知有林皇错把梅罗当球王3 小时前
vue2、vue3中使用pb(Base64编码)
前端