JavaScript 里的“影分身术”:从深浅拷贝看透内存玄机

55 阅读5分钟

前言

你有没有遇到过这种灵异事件?
你明明只修改了变量 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)。对于引用类型,赋值操作仅仅是复制了内存地址。

第三关:浅拷贝 —— “只抄了封面,没抄内容”

为了解决上面的问题,我们引入了“浅拷贝”。它会创建一个新对象,但对象内部的子对象依然是引用。

常见的浅拷贝手段

  1. 展开运算符 (...)
  2. Array.slice()
  3. Object.assign()
  4. 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 有严重缺陷:

    1. 丢失数据:undefined、Symbol、Function 会在转换中直接消失。
    2. 循环引用报错:如果对象自己引用自己,会直接报错。
    3. 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 的内存世界里:

  1. 基本类型住栈里,赋值就是复印
  2. 引用类型住堆里,赋值就是给钥匙
  3. 浅拷贝是换个新房子,但家具还是旧的。
  4. 深拷贝是连房子带家具通通 3D 打印一份新的。

下次写代码,当你想复制一个复杂对象时,千万别随手一个 =,记得问问自己: “我要的是钥匙,还是房子?”