前言:为什么“复制”行为如此重要?
在日常 JavaScript 开发中,我们经常需要将一个变量的值赋给另一个变量。看似简单的 let a = b; 背后,却隐藏着两种截然不同的内存操作逻辑——值复制(Value Copy) 与 引用复制(Reference Copy) 。
许多开发者曾遇到这样的困惑:
- 修改
obj2的属性,为什么obj1也跟着变了? - 为什么字符串、数字修改后互不影响,而对象却“牵一发而动全身”?
这些问题的根源,在于 JavaScript 对 简单数据类型(Primitive Types) 和 复杂数据类型(Reference Types / Objects) 采用了完全不同的内存分配与复制策略。
本文将系统讲解:
- 简单类型与复杂类型在内存中的存储方式
- 为什么复制行为存在本质差异
- 栈内存与堆内存的角色分工
- 如何正确实现对象的深拷贝
- 结合大厂面试题,巩固核心概念
掌握这些知识,不仅能避免常见的数据污染 bug,还能写出更高效、更安全的代码。
一、JavaScript 数据类型的分类
JavaScript 的数据类型分为两大类:
✅ 1. 简单数据类型(Primitive Types)
共 7 种:
stringnumberbooleannullundefinedsymbol(ES6)bigint(ES2020)
✅ 2. 复杂数据类型(Reference Types)
object(包括普通对象、数组、函数、日期、正则等)
💡 关键区别:
简单类型是 不可变的值(immutable) ,复杂类型是 可变的对象(mutable) 。
二、内存模型:栈 vs 堆
🔹 栈内存(Stack)
-
特点:空间小、访问快、自动管理
-
存储内容:
- 简单类型的值(如
"hello",42,true) - 复杂类型的 引用地址(指针)
- 简单类型的值(如
🔹 堆内存(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) 机制,不影响其作为简单类型的本质
七、总结:核心原则牢记于心
| 特性 | 简单类型 | 复杂类型 |
|---|---|---|
| 存储位置 | 栈内存 | 堆内存(值) + 栈(引用) |
| 复制方式 | 值复制(独立副本) | 引用复制(共享对象) |
| 修改影响 | 互不影响 | 同步变化 |
| 典型代表 | string, number, boolean | object, array, function |
✨ 一句话口诀:
“简单类型存值,复杂类型存址;复制简单得副本,复制复杂得共享。”
结语
理解 JavaScript 的内存复制机制,是写出健壮代码的基础。当你下次再看到 let obj2 = obj 时,请记住:你拿到的不是一个新对象,而是一把通往同一座房子的钥匙。
在实际开发中:
- 对简单类型,放心复制
- 对复杂类型,明确需求:要浅拷贝还是深拷贝?
- 遇到数据意外变更,优先检查是否因引用共享导致
掌握这些底层原理,你不仅能避开陷阱,更能设计出更高效、更清晰的数据流架构。