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

相关推荐
明月_清风7 分钟前
模仿 create-vite / create-vue 风格写一个现代脚手架
前端·后端
aou8 分钟前
让表格式录入像 Excel 一样顺滑
前端·ai编程
前端付豪8 分钟前
必知 Express和 MVC
前端·node.js·全栈
重铸码农荣光9 分钟前
CSS 也能“私有化”?揭秘模块化 CSS 的防坑指南(附 Vue & React 实战)
前端·css·vue.js
南囝coding9 分钟前
CSS终于能做瀑布流了!三行代码搞定,告别JavaScript布局
前端·后端·面试
ccnocare9 分钟前
git 创建远程分支
前端
全栈王校长10 分钟前
Vue.js 3 项目构建神器:Webpack 全攻略
前端
1024小神10 分钟前
cloudflare+hono使用worker实现api接口和r2文件存储和下载
前端
Anita_Sun11 分钟前
Lodash 源码解读与原理分析 - Lodash 对象创建的完整流程
前端
米诺zuo11 分钟前
TypeScript 知识总结
前端