同样是赋值,一个改了原值不变,一个改了原值跟着变:
let a = 1
let b = a
b = 2
console.log(a) // 1,互不影响
let o1 = { n: 1 }
let o2 = o1
o2.n = 2
console.log(o1.n) // 2,改的是同一块内存
这个差异通常被归结为一句话:"基本类型存在栈里,引用类型的数据存在堆里,变量在栈里存了一个地址。"这句话在面试里够用,但只对了一半。这篇文章先把标准答案讲清楚,再说清楚它哪里不准确。
一、JS 的数据分两类
- 原始类型(Primitive):
Undefined、Null、Boolean、Number、String、Symbol、BigInt,共 7 种。不可变,按值访问。 - 引用类型(Object):对象、数组、函数、日期、正则、
Map/Set等,统称Object。可变,按引用(地址)访问。
let a = 1
let b = a
b = 2
console.log(a) // 1
let o1 = { n: 1 }
let o2 = o1
o2.n = 2
console.log(o1.n) // 2
复制时,原始类型复制的是值本身,引用类型复制的是地址,两个变量最终指向堆上同一个对象。
二、栈和堆分别是什么
这里的"栈"和"堆"是内存区域,不是数据结构教科书里的同名概念,但确实借用了它们的特点。
| 维度 | 栈(Stack) | 堆(Heap) |
|---|---|---|
| 存放内容 | 执行上下文、局部变量、函数调用信息 | 动态分配的对象、数组等引用类型数据 |
| 分配/释放 | 由引擎自动管理,随函数调用进入和退出 | 动态分配,交给垃圾回收器(GC)回收 |
| 访问速度 | 快(连续内存,push/pop 语义) | 慢(需要查找、可能产生碎片) |
| 容量 | 小,溢出会报 Maximum call stack size exceeded | 大,受可用内存限制 |
| 大小确定 | 编译期/进入函数时基本确定 | 运行时才知道,可动态增长 |
栈像一个叠盘子的容器,进出的顺序固定,所以又快又简单;堆像一块自由仓库,对象往哪放、放多大运行时才知道,灵活但管理成本高。
三、标准答案:变量怎么存
按常见说法:
let count = 42 // count 的值 42 直接放在栈上
let user = { name: 'x' } // user 在栈上存一个地址,对象 { name: 'x' } 放在堆上
用图表示:
栈 堆
┌──────────────┐ ┌──────────────────┐
│ count = 42 │ │ │
├──────────────┤ ├──────────────────┤
│ user = 0xA1 ─┼───────▶│ { name: 'x' } │
└──────────────┘ │ @ 0xA1 │
└──────────────────┘
于是就有了它的三条推论:
- 原始类型赋值是"拷贝值",改了互不影响。
- 引用类型赋值是"拷贝地址",两个变量共享同一个对象。
- 函数传参时,原始类型传值,引用类型传地址(是值的拷贝,不是引用传递)。
四、函数传参的例子
function change(primitive, obj) {
primitive = 10 // 只改了函数内的副本
obj.n = 99 // 通过地址改了堆上的对象
}
let x = 1
let o = { n: 1 }
change(x, o)
console.log(x) // 1,没变
console.log(o.n) // 99,变了
注意一个常见误区:如果把参数指向新对象,外部也不会跟着变,因为函数内改的是栈上那个地址副本:
function reassign(obj) {
obj = { n: 5 } // 只是让函数内的副本指向新对象
}
let y = { n: 1 }
reassign(y)
console.log(y.n) // 1,外部不受影响
JS 只有按值传递。引用类型"传引用"的说法,准确讲是"传地址这个值"。
五、这句话哪里不准确
面试答上面那套没问题,但再深一层要补两点,避免被追问翻车。
第一,V8 里字符串其实是堆上的对象。 String 是原始类型没错,但引擎实现里字符串(尤其是拼接产生、较长的字符串)通常以堆对象形式存在,靠不可变和内部化(interning)复用。所以"原始类型都在栈上"并不严格。
const s = 'hi'
s === 'hi' // true,相同字面量会被内部化复用
第二,闭包捕获的变量会被搬到堆上。 局部变量本该随函数栈帧销毁,但如果被闭包引用,它必须活得更久,引擎会把它分配到堆上的上下文对象里,而不是留在栈上:
function counter() {
let count = 0 // 通常的局部变量在栈上
return function () {
count++ // 被闭包捕获后,count 实际上活在堆上的上下文中
return count
}
}
const inc = counter()
inc() // 1
inc() // 2
栈帧在 counter() 返回后就弹出了,count 却还在——这说明它绝不可能只存在栈上。
第三,引擎还会做优化。 局部原始值可能直接放在寄存器里,短命对象会被逃逸分析优化到栈上分配,所以真实的存储位置是引擎实现细节,随版本和优化策略变化。
一句话总结准确版本:变量(包括原始值和引用地址)都活在执行上下文里;可变的对象数据一般在堆上。栈和堆只是对引擎内存管理的简化描述。
六、为什么要有这种划分
如果所有数据都堆在栈上,会有两个问题:
- 栈要求大小在进入时就确定,而对象可以动态增删属性、随时变大,栈放不下。
- 栈帧随函数退出就要销毁,而对象需要更长的生命周期,由 GC 在合适时机回收更合适。
反过来,如果所有数据都放堆上,连 let i = 0 这种临时变量都要走堆分配和 GC,性能会明显变差。所以栈负责小而短命的数据,堆负责大而灵活的数据,各司其职。
七、速查表
| 对比项 | 栈(Stack) | 堆(Heap) |
|---|---|---|
| 主要存放 | 执行上下文、局部变量、调用信息 | 对象、数组等引用类型数据 |
| 管理方式 | 引擎自动分配释放 | GC 回收 |
| 速度 | 快 | 慢 |
| 容量 | 小,会栈溢出 | 大 |
| 大小 | 进入时基本确定 | 运行时动态确定 |
| 访问方式 | 直接按值 | 先取地址再访问 |
| 行为 | 原始类型 | 引用类型 |
|---|---|---|
| 变量里存什么 | 值本身 | 指向堆的地址 |
| 赋值 | 拷贝值 | 拷贝地址 |
| 修改副本 | 不影响原值 | 影响原始对象 |
| 传参 | 传值 | 传地址(按值传递) |
| 可变性 | 不可变 | 可变 |
小结
- 标准答案:基本类型的值在栈上,引用类型的数据在堆上,变量在栈上存地址。
- 赋值和传参的差异,本质是"拷值"还是"拷地址",JS 只有按值传递。
- 更准确的版本:变量活在执行上下文里,可变对象数据在堆上;字符串、闭包捕获的变量、引擎优化都会打破"原始类型都在栈上"的简单说法。
- 划分的动机是分工:栈管小而短命的数据,堆管大而灵活的数据。