你不知道的javascript小知识之:深入理解 JavaScript 中简单类型与复杂类型的内存复制机制

58 阅读5分钟

前言:为什么“复制”行为如此重要?

在日常 JavaScript 开发中,我们经常需要将一个变量的值赋给另一个变量。看似简单的 let a = b; 背后,却隐藏着两种截然不同的内存操作逻辑——值复制(Value Copy)引用复制(Reference Copy)

许多开发者曾遇到这样的困惑:

  • 修改 obj2 的属性,为什么 obj1 也跟着变了?
  • 为什么字符串、数字修改后互不影响,而对象却“牵一发而动全身”?

这些问题的根源,在于 JavaScript 对 简单数据类型(Primitive Types)复杂数据类型(Reference Types / Objects) 采用了完全不同的内存分配与复制策略。

本文将系统讲解:

  • 简单类型与复杂类型在内存中的存储方式
  • 为什么复制行为存在本质差异
  • 栈内存与堆内存的角色分工
  • 如何正确实现对象的深拷贝
  • 结合大厂面试题,巩固核心概念

掌握这些知识,不仅能避免常见的数据污染 bug,还能写出更高效、更安全的代码。


一、JavaScript 数据类型的分类

JavaScript 的数据类型分为两大类:

✅ 1. 简单数据类型(Primitive Types)

共 7 种:

  • string
  • number
  • boolean
  • null
  • undefined
  • symbol(ES6)
  • bigint(ES2020)

✅ 2. 复杂数据类型(Reference Types)

  • object(包括普通对象、数组、函数、日期、正则等)

💡 关键区别
简单类型是 不可变的值(immutable) ,复杂类型是 可变的对象(mutable)


二、内存模型:栈 vs 堆

🔹 栈内存(Stack)

  • 特点:空间小、访问快、自动管理

  • 存储内容

    • 简单类型的值(如 "hello"42true
    • 复杂类型的 引用地址(指针)

🔹 堆内存(Heap)

  • 特点:空间大、访问稍慢、需垃圾回收(GC)

  • 存储内容

    • 复杂类型的 实际对象数据(如 { name: "男人", age: 18 }

📌 设计原因
简单类型体积小,直接存栈中效率高;
复杂类型可能很大(如大型数组),若全放栈中会迅速耗尽栈空间(通常仅几 MB)。


三、复制行为的本质差异

🟢 场景 1:简单类型的复制(值复制)

js
编辑
let str = "hello";      // 栈:存储 "hello"
let str2 = str;         // 栈:复制 "hello" → 新的独立副本
str2 = "你好";          // 修改 str2,不影响 str
console.log(str, str2); // "hello" "你好"

内存图解:

text
编辑
栈内存:
┌─────────────┐
│ str: "hello" │ ← 独立存储
├─────────────┤
│ str2: "你好" │ ← 独立存储(原为 "hello",后被覆盖)
└─────────────┘

结论

  • 复制的是 值本身
  • 两个变量 完全独立
  • 修改一个,不影响另一个

🔴 场景 2:复杂类型的复制(引用复制)

js
编辑
let obj = {             // 堆:存储 { name: "男人", age: 18 }
    name: "男人",       // 栈:obj 存储指向堆的地址(如 0x1234)
    age: 18,
};
let obj2 = obj;         // 栈:obj2 复制地址 0x1234(不是对象本身!)
obj2.age++;             // 通过地址 0x1234 修改堆中的对象
console.log(obj2, obj); // { name: "男人", age: 19 } ×2

内存图解:

text
编辑
栈内存:
┌──────────────┐
 obj: 0x1234    ─┐
├──────────────┤  
 obj2: 0x1234   ─┼──→ 共同指向同一块堆内存
└──────────────┘  
                  
堆内存:
┌───────────────────────────┐
 地址 0x1234:              
 { name: "男人", age: 19 }    obj  obj2 共享
└───────────────────────────┘

结论

  • 复制的是 引用地址(指针)
  • 两个变量 共享同一个对象
  • 修改一个,另一个 同步变化

四、为什么这样设计?性能与安全的权衡

设计考量简单类型复杂类型
内存效率值小,直接复制开销低对象可能很大,复制整个对象成本高
访问速度栈访问极快通过指针间接访问,稍慢但可接受
语义合理性字符串/数字天然不可变对象天然可变,共享引用符合直觉

💡 举例
如果每次 let arr2 = arr1 都深拷贝整个数组,
那么处理百万级数据时,性能将急剧下降。


五、如何正确复制复杂类型?

1. 浅拷贝(Shallow Copy)

仅复制第一层属性,嵌套对象仍共享引用。

js
编辑
// 方法1:展开运算符
let obj2 = { ...obj };

// 方法2:Object.assign
let obj2 = Object.assign({}, obj);

✅ 适用场景:对象无嵌套结构

2. 深拷贝(Deep Copy)

递归复制所有层级,完全独立。

js
编辑
// 方法1:JSON 序列化(有局限)
let obj2 = JSON.parse(JSON.stringify(obj));
// ❌ 缺点:丢失函数、undefined、Symbol、Date 等

// 方法2:递归实现(推荐)
function deepClone(obj) {
  if (obj === null || typeof obj !== 'object') return obj;
  if (obj instanceof Date) return new Date(obj);
  if (obj instanceof Array) return obj.map(item => deepClone(item));
  if (typeof obj === 'object') {
    let clonedObj = {};
    for (let key in obj) {
      if (obj.hasOwnProperty(key)) {
        clonedObj[key] = deepClone(obj[key]);
      }
    }
    return clonedObj;
  }
}

✅ 适用场景:需要完全独立的对象副本


六、大厂高频面试题

❓ 1. 以下代码输出什么?为什么?

js
编辑
let a = { n: 1 };
let b = a;
a.x = a = { n: 2 };
console.log(a.x);
console.log(b.x);

答案

text
编辑
undefined
{ n: 2 }

解析

  • a.x = a = { n: 2 } 从右向左执行
  • 先 a = { n: 2 }(a 指向新对象)
  • 再 a.x = ... 实际是 原对象(b 指向的)的 x 属性被赋值为 { n: 2 }

❓ 2. 如何判断两个对象是否相等?

参考答案

  • === 比较的是 引用地址,不是内容
  • 要比较内容,需手动遍历或使用工具库(如 Lodash 的 _.isEqual
  • 简单对象可用 JSON.stringify(obj1) === JSON.stringify(obj2)(有局限)

❓ 3. 为什么字符串是简单类型,但可以调用 .length

参考答案

  • JS 在访问简单类型的属性时,会临时包装为对象(如 new String("hello")
  • 操作完成后立即销毁包装对象
  • 这是 装箱(Boxing)  机制,不影响其作为简单类型的本质

七、总结:核心原则牢记于心

特性简单类型复杂类型
存储位置栈内存堆内存(值) + 栈(引用)
复制方式值复制(独立副本)引用复制(共享对象)
修改影响互不影响同步变化
典型代表stringnumberbooleanobjectarrayfunction

一句话口诀
“简单类型存值,复杂类型存址;复制简单得副本,复制复杂得共享。”


结语

理解 JavaScript 的内存复制机制,是写出健壮代码的基础。当你下次再看到 let obj2 = obj 时,请记住:你拿到的不是一个新对象,而是一把通往同一座房子的钥匙。

在实际开发中:

  • 对简单类型,放心复制
  • 对复杂类型,明确需求:要浅拷贝还是深拷贝?
  • 遇到数据意外变更,优先检查是否因引用共享导致

掌握这些底层原理,你不仅能避开陷阱,更能设计出更高效、更清晰的数据流架构。