一、变量里装的到底是什么?
当你写 let a = { x: 1 } 时,a 里装的不是那个对象,而是一张纸条——纸条上写着地址。对象本身躺在内存里,纸条告诉你"去这个地址找它"。
基本类型(number、string、boolean)不一样,值直接写在纸条上。但对象、数组、函数,纸条上永远只是个地址。
搞清楚这一点,剩下的全是推论。
二、赋值不是拷贝
let a = { x: 1 };
let b = a; // 抄了纸条,没抄房子
b.x = 999;
console.log(a.x); // 999 —— 同一栋房子
a 和 b 是两张纸条,写着同一个门牌号。改 b 就是改 a。这不叫拷贝。
三、浅拷贝:抄了第一层,嵌套的还是共享
let a = { x: 1, nested: { y: 2 } };
let b = { ...a };
b.nested.y = 999;
console.log(a.nested.y); // 999 —— 嵌套层还是同一栋
展开运算符 ...a 建了一栋新房子,但 nested 这个抽屉里放的还是原来那张地址条。浅拷贝只抄了一层深,再往下的引用还是共享的。
四、深拷贝:一路抄到底
遇到引用就顺着走过去,把那栋房子也抄一份,把新地址条放进新房子。一路递归到底,直到所有东西都是新建的,两份完全独立。
听起来简单,做起来全是坑。自己造一个就知道了:
function deepClone(source, cache = new WeakMap()) {
if (source === null || typeof source !== 'object') return source;
if (typeof source === 'function') return source;
if (cache.has(source)) return cache.get(source); // 防循环引用
if (source instanceof Date) return new Date(source.getTime());
if (source instanceof RegExp) return new RegExp(source.source, source.flags);
const target = Array.isArray(source) ? [] : {};
cache.set(source, target); // 先记录,再递归
Reflect.ownKeys(source).forEach(key => {
target[key] = deepClone(source[key], cache);
});
return target;
}
这还没完——Map/Set 要特殊处理,getter/setter 会变成普通属性,原型链用 {} 会丢失。深拷贝没有"完美"实现,每补一个坑就发现下一个。
五、现成方案对比
| 赋值 | 浅拷贝 | JSON方案 | structuredClone | _.cloneDeep | |
|---|---|---|---|---|---|
| 第一层独立 | ✗ | ✓ | ✓ | ✓ | ✓ |
| 嵌套层独立 | ✗ | ✗ | ✓ | ✓ | ✓ |
| 循环引用 | ✗ | ✗ | ✗(报错) | ✓ | ✓ |
| 函数 | 共享 | 共享 | 丢失 | ✗(报错) | 共享 |
| Date/RegExp | 共享 | 共享 | ✗(变字符串) | ✓ | ✓ |
| Map/Set | 共享 | 共享 | ✗(丢失) | ✓ | ✓ |
| 原型链 | 保留 | 保留 | ✗(丢失) | ✗(丢失) | 保留 |
-
JSON.parse(JSON.stringify()):只适合纯 JSON 数据,循环引用直接崩。 -
structuredClone():日常首选,支持循环引用和大部分内置类型,但函数会报错、原型链不保留。 -
_.cloneDeep():最全面,函数不报错直接共享,保留原型链。
读这张表的方法:先问"我需要什么",再选方案。 而不是选一个方案然后祈祷它覆盖所有情况。
六、收尾
深拷贝之所以存在,是因为 JS 对象是引用类型——变量里装的是纸条,不是房子。一旦你真正理解了这一点,浅拷贝和深拷贝的行为就是推论,不是需要背的知识点。
知道 structuredClone 这个名字,不代表你理解它为什么拒绝拷贝函数。真正理解的人,能从纸条和房子的关系开始,把整个逻辑链重新推导出来。