有时候对 JavaScript 的代码很是疑惑,我写过这样一段代码:
javascript
let user1 = { name: '小明' };
let user2 = user1;
user2.name = '小红';
console.log(user1.name); // 结果居然是 "小红"?
我当时愣住了:我只是改了 user2,为什么 user1 也跟着变了?
而换成数字时,却完全不是这样:
javascript
let a = 10;
let b = a;
b = 20;
console.log(a); // 还是 10,没变
明明都是赋值,行为怎么差这么多?
后来我才明白,问题的关键并不是代码的写法问题,而是在 JavaScript 的数据类型和它们在内存中的存储方式。
搞懂这一点,很多看似奇怪的行为就都顺理成章了。
一、JavaScript 有几种数据类型?
根据 ECMAScript 标准,JavaScript 的数据类型分为两大类:
1. 原始类型------共 7 种
这些类型的特点是:值不可变、直接表示数据本身。
| 类型 | 示例 | 说明 |
|---|---|---|
number |
42, 3.14, NaN |
所有数字,包括整数、浮点数、特殊值 |
string |
'hello', "JS" |
字符串,不可变 |
boolean |
true, false |
布尔值 |
undefined |
let a; console.log(a); |
变量已声明但未赋值 |
null |
let b = null; |
表示空或无值(注意:它是原始类型!) |
symbol |
Symbol('id') |
ES6 引入,唯一且不可变的标识符 |
bigint |
123n, 9007199254740991n |
ES2020 引入,用于表示任意大的整数 |
typeof null 返回 "object" 是 JavaScript 的一个历史性 bug,至今未修复。但从语言设计上,null 属于原始类型。
2. 引用类型------统称对象
除了上述 7 种,其他所有值都是对象,属于引用类型,包括:
- 普通对象:
{ name: 'Tom' } - 数组:
[1, 2, 3] - 函数:
function() {} - 日期:
new Date() - 正则:
/abc/ - 甚至
Map、Set、Promise等
引用类型的核心特点是:可变、通过引用来访问实际数据。
二、它们在内存中怎么存储?
这是理解一切行为差异的根源!
计算机内存大致分为两个区域:
- 栈(Stack):速度快,空间小,用于存储简单、固定大小的数据。
- 堆(Heap):空间大,速度稍慢,用于存储复杂、动态大小的数据。
原始类型:直接存在栈里
javascript
let age = 25;
let isStudent = true;
这两个变量就像两个小抽屉,值本身直接放在栈中:
yaml
栈(Stack)
┌─────────────────┐
│ age: 25 │
│ isStudent: true │
└─────────────────┘
当你复制一个原始值:
javascript
let myAge = age; // 把 25 复制一份
myAge = 30;
console.log(age); // 仍然是 25
因为 myAge 拿到的是 全新的副本 ,和 age 完全无关。
所以原始类型是按值进行传递(Pass by Value)。
引用类型:栈存地址,堆存真实对象
javascript
let user = { name: 'Alice', age: 25 };
这时发生了两件事:
- 对象
{ name: 'Alice', age: 25 }被创建,存放在堆中。 - 变量
user并不直接包含这个对象,而是保存一个指向堆中对象的引用地址,这个地址放在栈里。
图示如下:
css
栈(Stack) 堆(Heap)
┌──────────────┐ ┌──────────────────────────┐
│ user: [地址A] ├─────→│ { name: 'Alice', age: 25 } │
└──────────────┘ └──────────────────────────┘
当你赋值给另一个变量:
javascript
let admin = user; // 复制的是地址,不是对象!
admin.name = 'Bob';
console.log(user.name); // 输出 "Bob"!
因为 admin 和 user 指向同一个堆中的对象:
yaml
栈(Stack) 堆(Heap)
┌──────────────┐
│ user: [地址A] ├──┐
├──────────────┤ │ ┌──────────────────────────┐
│ admin: [地址A] ├──┼──→│ { name: 'Bob', age: 25 } │
└──────────────┘ │ └──────────────────────────┘
│
└───┘
所以引用类型是按共享的方式进行传递(Pass by Sharing)
注意:不是按引用传递,JS 中没有真正的按引用传递
三、深入对比
| 特性 | 原始类型 | 引用类型(对象) |
|---|---|---|
| 存储位置 | 栈(Stack) | 栈(存引用地址) + 堆(存实际对象) |
| 赋值行为 | 复制值本身 | 复制引用地址(多个变量共享同一对象) |
| 是否可变 | 不可变(操作生成新值) | 可变(可直接修改属性) |
| 比较方式 | 比较值是否相等 | 比较引用地址是否相同 |
| 内存占用 | 固定、较小 | 动态、可能很大 |
| 典型场景 | 数字、字符串、布尔值 | 对象、数组、函数 |
举个比较的例子:
javascript
// 原始类型比较
5 === 5; // true
'hi' === 'hi'; // true
// 引用类型比较
{} === {}; // false!两个不同对象
let a = {}; let b = a;
a === b; // true!同一个对象
四、常见问题
为什么字符串很长,也算原始类型?
虽然字符串可能很长,但 JS 引擎会做内部优化(比如使用指针),但从语言语义 上,字符串是不可变的原始值。任何"修改"都会生成新字符串。
javascript
let s = 'hello';
s = s + ' world'; // 创建了新字符串,原 'hello' 未被修改
如何真正复制一个对象,而不是共享?
浅拷贝:只复制第一层属性
javascript
let newObj = { ...oldObj };
// 或
let newObj = Object.assign({}, oldObj);
深拷贝:递归复制所有层级(需注意循环引用等问题)
javascript
let deepCopy = JSON.parse(JSON.stringify(obj)); // 有局限(不能拷贝函数、undefined 等)
// 推荐使用 Lodash 的 _.cloneDeep()
五、总结
- 原始类型:值直接存在栈里,赋值是复制值,互不影响。
- 引用类型:栈里存地址,堆里存对象,赋值是复制地址,共享同一对象。
- 理解存储机制,是掌握 JS 赋值、函数传参、状态管理、性能优化的基础!
下次当你看到:
javascript
let a = obj;
a.x = 10;
console.log(obj.x); // 为什么变了?
你就知道:因为 a 和 obj 指向的是同一个对象!
本文首发于公众号:程序员刘大华,专注分享前后端开发的实战笔记。关注我,少走弯路,一起进步!
📌往期精彩
《代码里全是 new 对象,真的很 Low 吗?我认真想了一晚》
《Java 开发必看:什么时候用 for,什么时候用 Stream?》