在封装一个图表组件时,遇到个棘手问题:
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 封装组件,要求:
- 初始化时保存一份原始配置
originalConfig
- 用户修改配置后,能一键"恢复默认"
- 配置项可能包含函数(如
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 |
六、举一反三:三个变体场景实现思路
- 需要"选择性深拷贝"
传入ignoreKeys: ['fn', 'dom']
或customHandlers
处理特定类型。
js
function deepClone(obj, { ignoreKeys = [], customHandlers = {}, hash = new WeakMap() } = {}) {
// 在遍历时跳过 ignoreKeys
}
-
实现"深冻结"
递归
Object.freeze
,防止配置被意外修改。 -
结合 Proxy 实现"响应式备份"
监听配置变化,自动触发 UI 更新,同时保留可恢复的原始值。
小结
基础类型直接返,
日期正则特殊判,
函数引用原样传,
WeakMap 解循环,
constructor 保原型,
getOwnProperty 拿全键。