对象和原始值到底存在哪?赋值和传参时,内存里发生了什么?为什么 React 强调“不可变更新”,而Vue3 可以直接修改 ref 的值?这些问题的答案,都藏在 JavaScript 内存的两个核心区域里:栈(Stack) 和 堆(Heap),它们就像生活中的旅馆前台和仓库。
一、旅馆前台 vs 仓库——内存的两个“部门”
我们可以把JavaScript 引擎看作一家旅馆。
1. 栈:旅馆前台
旅馆前台存放的是小件、轻便、存取快速的东西——比如信件、钥匙。前台空间有限,但取放极快。更重要的是,退房(函数执行结束)时,前台的物品会自动被清空,不需要专门打扫。
在 JavaScript 中,栈负责存放:
- 原始值:
number、string、boolean、null、undefined、symbol、bigint - 指向堆中对象的引用地址(也就是对象的“房间号”)
原始值大小固定、生命周期明确(随函数调用产生和销毁),非常适合放在栈里。
2. 堆:仓库
旅馆后面有一个大仓库,存放大件、笨重、需要长期保存的物品——比如家具、行李。仓库空间很大,但存取比前台慢,而且需要专门的人定期去清理长期不用的东西(垃圾回收)。
在 JavaScript 中,堆负责存放:
- 引用类型:对象(
Object)、数组(Array)、函数(Function)、闭包变量等
这些数据的大小不固定,可能非常庞大,而且经常需要在函数返回后依然存活(比如被挂载在全局对象上,或者被事件回调引用)。
两者区别:栈像旅馆前台,快而小,存原始值和房间号;堆像仓库,慢而大,存对象本身。
二、原始值与引用值——从前台和仓库的角度理解
1. 原始值:直接存在前台
let age = 25;
let name = 'weedsfly';
let isAdmin = true;
这些值都直接存储在栈上,每个变量占据一小块固定大小的空间。赋值时,复制的是值本身。
let a = 10;
let b = a; // 把 a 的值 10 复制一份给 b
b = 20;
console.log(a); // 10,不受影响
a 和 b 各自拥有独立的栈空间,互不干扰。
2. 引用值:存在仓库,前台只留房间号
let user = { name: '张三', age: 30 };
user 变量存在栈上,但存的是对象在堆中的内存地址(房间号)。真正的对象 { name: '张三', age: 30 } 躺在仓库里。
当你“复制”一个引用值时:
let another = user; // 复制的是房间号,不是对象本身
another.name = 'weedsfly';
console.log(user.name); // 'weedsfly' - 被影响了!
another 和 user 拿的是同一个房间号,指向仓库里的同一个对象。无论通过哪个变量去改房间里的东西,另一个变量也能看到变化。
这就是为什么 const 声明的对象内容可以被修改——const 锁住的是栈上的房间号,不是仓库里的物品。
const obj = { count: 0 };
obj.count = 1; // 合法,房间号没变
obj = {}; // 报错,尝试更换房间号
三、函数传参:到底是值传递还是引用传递?
这是一个经典的面试题。从内存模型的角度看,答案很清晰:JavaScript 永远是值传递,但传递的值可能是原始值,也可能是引用地址。
function change(num, obj) {
num = 100;
obj.name = 'Changed';
}
let count = 1;
let person = { name: 'Original' };
change(count, person);
console.log(count); // 1 — 未改变
console.log(person.name); // 'Changed' — 被改变了
count是原始值,change内部得到的是一个值副本,修改不影响外部。person是引用值,change内部得到的是房间号的副本,但这个房间号依然指向堆里的同一个对象。所以修改对象内容,外部也会看到。
如果你在函数内部把 obj 指向一个新对象:
function change(obj) {
obj = { name: 'New' }; // 把房间号换成了另一个
}
let person = { name: 'Original' };
change(person);
console.log(person.name); // 'Original',外部不受影响
因为你改变的只是内部变量持有的房间号,外部变量持有的房间号没有变。
四、这些知识如何影响日常开发?
1. React 的不可变更新
React 依赖 Object.is 浅比较 来判断状态是否变化。如果你直接修改对象内部属性:
// 错误
const [user, setUser] = useState({ name: '张三' });
user.name = 'weedsfly';
setUser(user); // 引用地址没变,React 认为状态未更新,不会重渲染
从内存的角度,你只改了仓库里的东西,栈上的房间号没变,React 无法察觉。
正确做法是创建一个新对象,替换房间号:
// 正确
setUser({ ...user, name: 'weedsfly' }); // 新对象,新房间号
React 比较栈上的引用地址,发现变了,就知道要更新组件了。这也解释了为什么 Redux 的 reducer 必须返回新 state,而不是修改旧 state。
2. Vue 3 的响应式为什么可以“直接修改”?
Vue 3 使用 Proxy 代理对象。当你修改 state.count 时,Vue 拦截了这个操作,内部其实并没有依赖引用地址比较来判断变化。它通过依赖收集和派发更新,直接追踪到了哪个属性被修改。
所以 Vue 3 不需要像 React 那样强制不可变更新,但这并不意味着内存模型失效——对象依然在堆里,ref 和 reactive 依然持有的是引用。
3. 深拷贝 vs 浅拷贝
如果你不小心只复制了房间号(浅拷贝),就可能导致意想不到的副作用:
const original = { user: { name: '张三' } };
const copy = { ...original }; // 浅拷贝:只复制了 user 的房间号
copy.user.name = 'weedsfly';
console.log(original.user.name); // 'weedsfly',被影响了
要想完全断开引用,需要深拷贝,即为嵌套对象也创建新的仓库实体。
const deepCopy = JSON.parse(JSON.stringify(original)); // 其中一种深拷贝方式
五、总结
理解栈和堆,就理解了 JavaScript 数据存储和传递的核心规则。记住这几个要点:
- 栈 存原始值和引用地址,小而快,随函数调用自动清理。
- 堆 存对象和数组,大而灵活,由垃圾回收器管理。
- 赋值和传参都是值复制:原始值复制数据本身,引用值复制房间号。
- React 的不可变更新要求你替换栈上的房间号;Vue 3 的 Proxy 则不用。
- 浅拷贝只复制第一层引用地址,深拷贝才能完全断开引用链。
下次当你写出 setState({ ...prev }) 或者困惑“为什么 const 对象还能被修改”时,不妨想想旅馆前台和仓库的比喻——栈上只是换了一张房卡,仓库里的东西还在那里。