栈和堆:JavaScript 内存的“旅馆”和“仓库”

0 阅读6分钟

对象和原始值到底存在哪?赋值和传参时,内存里发生了什么?为什么 React 强调“不可变更新”,而Vue3 可以直接修改 ref 的值?这些问题的答案,都藏在 JavaScript 内存的两个核心区域里:栈(Stack)堆(Heap),它们就像生活中的旅馆前台和仓库。

Slide 4_3 - 24.png

一、旅馆前台 vs 仓库——内存的两个“部门”

我们可以把JavaScript 引擎看作一家旅馆

1. 栈:旅馆前台

旅馆前台存放的是小件、轻便、存取快速的东西——比如信件、钥匙。前台空间有限,但取放极快。更重要的是,退房(函数执行结束)时,前台的物品会自动被清空,不需要专门打扫。

在 JavaScript 中,负责存放:

  • 原始值numberstringbooleannullundefinedsymbolbigint
  • 指向堆中对象的引用地址(也就是对象的“房间号”)

原始值大小固定、生命周期明确(随函数调用产生和销毁),非常适合放在栈里。

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,不受影响

ab 各自拥有独立的栈空间,互不干扰。

2. 引用值:存在仓库,前台只留房间号

let user = { name: '张三', age: 30 };

user 变量存在栈上,但存的是对象在堆中的内存地址(房间号)。真正的对象 { name: '张三', age: 30 } 躺在仓库里。

当你“复制”一个引用值时:

let another = user; // 复制的是房间号,不是对象本身
another.name = 'weedsfly';
console.log(user.name); // 'weedsfly' - 被影响了!

anotheruser 拿的是同一个房间号,指向仓库里的同一个对象。无论通过哪个变量去改房间里的东西,另一个变量也能看到变化。

这就是为什么 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 那样强制不可变更新,但这并不意味着内存模型失效——对象依然在堆里,refreactive 依然持有的是引用。

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 对象还能被修改”时,不妨想想旅馆前台和仓库的比喻——栈上只是换了一张房卡,仓库里的东西还在那里。