实现 `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 拿全键。

相关推荐
伍哥的传说3 小时前
Radash.js 现代化JavaScript实用工具库详解 – 轻量级Lodash替代方案
开发语言·javascript·ecmascript·tree-shaking·radash.js·debounce·throttle
程序视点3 小时前
IObit Uninstaller Pro专业卸载,免激活版本,卸载清理注册表,彻底告别软件残留
前端·windows·后端
前端程序媛-Tian3 小时前
【dropdown组件填坑指南】—怎么实现下拉框的位置计算
前端·javascript·vue
iamlujingtao4 小时前
js多边形算法:获取多边形中心点,且必定在多边形内部
javascript·算法
嘉琪0014 小时前
实现视频实时马赛克
linux·前端·javascript
烛阴4 小时前
Smoothstep
前端·webgl
若梦plus4 小时前
Eslint中微内核&插件化思想的应用
前端·eslint
爱分享的程序员4 小时前
前端面试专栏-前沿技术:30.跨端开发技术(React Native、Flutter)
前端·javascript·面试
超级土豆粉4 小时前
Taro 位置相关 API 介绍
前端·javascript·react.js·taro
若梦plus4 小时前
Webpack中微内核&插件化思想的应用
前端·webpack