前言
你有没有遇到过这种灵异事件?
你明明只修改了变量 B,结果变量 A 竟然也跟着变了!这感觉就像你剪了个头发,结果你双胞胎兄弟的头发也自动变短了一样惊悚。
其实,这不是系统 BUG,而是你掉进了 JavaScript 内存管理与拷贝 的“坑”里。今天我们就结合实战代码,来一场内存世界的探险,顺便搞定面试官最爱问的“深浅拷贝”。
第一关:内存里的“户型图” —— 栈 vs 堆
要理解拷贝,先得懂内存。JS 的内存空间主要分两类:栈内存 (Stack) 和 堆内存 (Heap) 。
1. 栈内存:小而美的“经济适用房”
栈内存主要存放基本数据类型(Number, String, Boolean, undefined, null)。
-
特点:空间连续、大小固定、读写极快。
-
代码实战:
let a = 1 let b = 2 let d = a // 值拷贝当你执行 d = a 时,就好比你复印了一张写着“1”的纸条给 d。以后 d 想把纸条改成“100”,跟 a 手里那张纸条半毛钱关系都没有。简单、高效、互不干扰。
2. 堆内存:大而全的“豪华大别墅”
堆内存存放引用数据类型(Object, Array, Function)。
-
特点:空间弹性大、动态分配、数据量大。
-
代码实战:
const users = [ { id: 1, name: '陈俊璋', hometown: '南昌' }, { id: 2, name: '舒俊', hometown: '南昌' } ]像 users 这种复杂的数组,数据量大,栈内存塞不下,只能扔进堆内存。
关键点来了:变量 users 存在栈里,但它存的不是数据本身,而是一个内存地址(引用) 。
比喻:users 只是一个手里拿着“别墅钥匙”的人。别墅(数据)在堆内存里。
第二关:引用的陷阱 —— “借钥匙”的代价
当我们进行赋值操作时,危机降临了。
const data = users // 引用式拷贝
这一行代码并没有克隆出一栋新别墅,它只是配了一把新钥匙给 data。
此时,users 和 data 手里拿着同一栋别墅的钥匙。
data[0].hobbies = ['篮球', '看烟花']
console.log(users[0].hobbies) // 竟然也有 ['篮球', '看烟花'] !!!
面试考点:const data = users 是拷贝吗?
答:不是拷贝,这叫赋值(Assignment)。对于引用类型,赋值操作仅仅是复制了内存地址。
第三关:浅拷贝 —— “只抄了封面,没抄内容”
为了解决上面的问题,我们引入了“浅拷贝”。它会创建一个新对象,但对象内部的子对象依然是引用。
常见的浅拷贝手段
- 展开运算符 (...)
- Array.slice()
- Object.assign()
- Array.from()
const arr = [1, 2, 3]
const arr2 = [...arr] // 或者 arr.slice()
arr2.push(4)
// 此时 arr 不会变,因为 arr 里存的是基本类型数字
但是!浅拷贝有个致命弱点: 如果数组里存的是对象(多层嵌套),它就露馅了。
const shallowUsers = [...users]
// 这一步虽然换了个新数组壳子,但里面的 {id:1...} 还是原来的引用!
shallowUsers[0].name = '被修改了'
console.log(users[0].name) // '被修改了' -> 原数据还是废了
总结:浅拷贝只拷贝了第一层(壳子),里面的内容如果还是引用类型,依然是藕断丝连。
第四关:深拷贝 —— “灵魂克隆术”
如果我想彻底切断联系,创造一个完全独立的副本,就需要深拷贝。这意味着在堆内存里重新申请空间,把所有数据(无论嵌套多少层)统统复制一份。
方法一:即兴派 —— JSON 暴力转换
这是最经典、最简单的“偷懒”写法。
var data = JSON.parse(JSON.stringify(users))
data[0].hobbies = ['代码', '睡觉']
console.log(users[0].hobbies) // undefined -> 原数据安全了!
-
原理:先把对象变成字符串(基本类型),再把字符串变回对象(产生新内存)。
-
面试雷区(必考) :
虽然简单,但 JSON.stringify 有严重缺陷:- 丢失数据:undefined、Symbol、Function 会在转换中直接消失。
- 循环引用报错:如果对象自己引用自己,会直接报错。
- NaN 和 Infinity:会变成 null。
方法二:现代派 —— structuredClone
这是 2022 年后浏览器原生支持的完美方案。
const obj = { name: '柯基', age: 18 }
const obj2 = structuredClone(obj)
// 就算 obj2 怎么改,obj 都不受影响,而且支持循环引用!
- 优点:原生支持,性能比 JSON 甚至 Lodash 更好,支持 Date、RegExp 等特殊类型。
- 缺点:老旧浏览器不支持(但在现代前端开发中已不是大问题)。
方法三:学院派 —— Lodash 库
在 structuredClone 普及之前,业界标准是使用 Lodash 的 _.cloneDeep。
const _ = require('lodash')
const obj2 = _.cloneDeep(obj)
稳定、兼容性好,但需要引入额外的库文件。
面试官追问环节 🎙️
Q1: 手写一个简单的深拷贝?
(这考察你对递归的理解)
function deepClone(obj) {
if (obj === null || typeof obj !== 'object') return obj; // 递归出口
// 判断是数组还是对象
let newObj = Array.isArray(obj) ? [] : {};
for (let key in obj) {
if (obj.hasOwnProperty(key)) {
// 递归调用
newObj[key] = deepClone(obj[key]);
}
}
return newObj;
}
Q2: 为什么 const 声明的对象可以被修改?
答:const 锁定的只是栈内存中的地址不可变。它保证你不能把钥匙换成别的钥匙,但它管不了你拿着这把钥匙进屋去把家具(堆内存数据)砸了。
Q3: Array.from 是深拷贝还是浅拷贝?
答:浅拷贝。
总结
JavaScript 的内存世界里:
- 基本类型住栈里,赋值就是复印。
- 引用类型住堆里,赋值就是给钥匙。
- 浅拷贝是换个新房子,但家具还是旧的。
- 深拷贝是连房子带家具通通 3D 打印一份新的。
下次写代码,当你想复制一个复杂对象时,千万别随手一个 =,记得问问自己: “我要的是钥匙,还是房子?”