你写过 let obj = { a: 1 },但你真的知道 { a: 1 } 在内存里经历了什么吗?
JavaScript 的内存管理看起来“全自动”:变量一声明,内存就分配;不用了,垃圾回收(GC)自动清理。于是很多人(包括曾经的我)误以为:“前端不用管内存。”
直到某天,我搞不清浅拷贝和深拷贝的区别,闭包导致内存泄漏却浑然不觉,才意识到:不是 JS 不需要内存管理,而是我们假装看不见它。
其实,理解 JS 内存机制,是打通引用类型、闭包、原型甚至性能优化任督二脉的关键。接下来,我们就掀开这层“自动”的面纱,看看 V8 引擎背后到底发生了什么。
JavaScript 内存模型
JavaScript 的内存模型主要分为两部分:栈(stack) 和 堆(heap) 。
- 栈 用于存储原始类型(如
number、boolean、string等)的变量,以及对象的引用地址; - 堆 则用来存放复杂数据结构的实际内容,比如对象、数组和函数;
基础数据类型与栈内存:小而快,值在手
JavaScript 的基础数据类型包括:
number、string、boolean、null、undefined(ES6 后还有 symbol 和 bigint)。
它们有个共同特点:大小固定,因此被直接存放在栈内存中(闭包等特殊情况除外)。
你操作的就是值本身——这叫 “按值访问” 。
面试高频题:“基本类型存在哪?”
答不上来的,可能还在用let a = b以为是“引用复制” 😅
栈是怎么工作的?想象一个乒乓球筒 🏓
| |
| 5 | ← 最后放进去,最先拿出来
| 4 |
| 3 |
| 2 |
| 1 | ← 最先放进去,得等上面全拿走才能用
|_____|
这就是典型的 后进先出(LIFO, Last In First Out) ——
栈内存的存取规则,和这个乒乓球盒子一模一样。
想拿底层的 1?抱歉,请先把 2~5 依次取出。
正因如此,栈的操作极快,适合存储那些“小而确定”的基础值。
引用类型与堆内存:
你以为你在操作对象?其实你只拿到了它的“快递单号”
JavaScript 的引用类型(如 Object、Array、Function)大小不固定,没法塞进栈里。
它们的真实数据被存放在 堆内存(heap) 中。
但 JS 不让你直接碰堆——你拿到的只是一个 引用(reference) ,可以粗略理解为“堆中对象的地址”,这个地址本身存在 栈 里。
所以当你写:
var obj = { name: 'Alice' };
obj这个变量 → 存在 栈;{ name: 'Alice' }这个对象 → 存在 堆;obj的值,其实是类似0x0012ff7c这样的“门牌号”,指向堆里的真实数据。
换句话说:你手里握着的不是对象,而是对象的“快递单号” 。
所有对对象的操作,都是通过这个单号去“取件”。
堆 vs 栈:书架 vs 乒乓球筒 📚 vs 🏓
- 栈像乒乓球筒:后放的先拿,必须按顺序(LIFO),适合小而快的基础值。
- 堆像图书馆书架:书(对象)随便放,但只要你有“书名”(引用),就能直接定位——无需搬开整排书。
这也解释了为什么 JSON 对象的 key 顺序无关紧要:只要键能命中,值就到手。
面试高频题:值复制 vs 引用复制
来看两个经典例子:
✅ Demo 1:基础类型(值复制)
var a = 20;
var b = a;
b = 30;
// 问:a 是多少?
答案:20。
因为 b = a 是把 20 这个值完整拷贝了一份,a 和 b 在栈里是两个独立的盒子,互不影响。
🔥 Demo 2:引用类型(引用复制)
var m = { a: 10, b: 20 };
var n = m;
n.a = 15;
// 问:m.a 是多少?
答案:15!
因为 n = m 只是把“快递单号”复制了一份。m 和 n 虽然在栈里是两个变量,但都指向堆里同一个对象。改一个,全变。
💡 记住:基本类型复制的是值,引用类型复制的是地址。
一张图看懂区别(文字版脑补)
[栈] [堆]
a → 20 —
b → 20 —
m → 0x100 → { a: 15, b: 20 }
n → 0x100 ↗
m 和 n 地址相同 → 共享同一个对象。
为什么这很重要?
搞懂栈和堆,你就打通了 JavaScript 的任督二脉:
- 为什么浅拷贝会“污染”原对象?
- 闭包为什么会造成内存泄漏?
- 为什么
{} === {}是false?
这些谜题,根源都在 “你操作的是值,还是指向值的指针?”
别小看这一层抽象——它正是 JS 灵活又容易踩坑的根源。
内存的生命周期:分配 → 使用 → 回收
在 JavaScript 中,每一块内存都走过三段旅程:
- 分配(Allocation) :声明变量、函数或对象时,JS 引擎自动为你分配内存;
- 使用(Usage) :读写变量、调用函数——这就是你在“消费”内存;
- 回收(Deallocation) :当内存不再需要,垃圾回收器(GC)会自动清理它。
来看个极简例子:
var a = 20; // ① 分配:在栈中为数字 20 开辟空间
alert(a + 100); // ② 使用:读取 a 的值
a = null; // ③ “释放”:断开引用,为 GC 铺路
前两步很直观,但第三步值得深挖:a = null 真的是“释放内存”吗?
其实不是。JS 没有手动释放内存的机制。你只是把变量 a 指向了 null,切断了它对原值的引用。如果这个值在堆中(比如对象),且再无其他引用指向它,GC 才会在未来某个时刻把它回收。
所以,我们能做的,只是“告诉引擎:我不需要它了”,剩下的交给 GC。
顺带一问:null 和 undefined,内存层面有啥区别?
undefined:表示“未定义”——变量已声明但未赋值,系统默认占位符;null:表示“空值”——开发者主动清空引用的信号。
有趣的是:
typeof null // "object" ← 历史 bug,沿用至今
typeof undefined // "undefined"
这个著名的“bug”源于 JS 早期实现:null 的内部类型标签被错误地归为对象。但它在内存中其实只是一个特殊的原始值,并不指向任何对象。
所以别被
typeof null === 'object'骗了——它不是对象,只是个“空指针”的象征。
再问:const 声明的变量真的“不可变”吗?
const foo = {};
foo.prop = 123; // ✅ 可以!
foo = {}; // ❌ TypeError: "foo" is read-only
为什么?
因为 const 保证的是变量绑定不可变,而不是对象内容不可变。
foo是一个栈中的引用,指向堆里的{};const锁死了foo这个“门牌号”不能再换(不能重新赋值);- 但堆里的对象本身,依然可以被修改!
想真正冻结对象?用
Object.freeze(foo)。
延伸思考
- 构造函数创建的对象,生命周期由引用决定;
- 立即执行函数(IIFE)内部的变量,通常在执行完就“失联”,很快被 GC;
- 闭包之所以能“记住”外部变量,正是因为那些变量仍有活跃引用,无法被回收。
理解内存的生命周期,不是为了背概念,而是为了写出更可控、更高效、更少内存泄漏的代码。
毕竟,在 JS 的世界里,看不见的内存,往往藏着最深的坑。
小结:看不见的内存,决定代码的“体重”
JavaScript 的内存管理看似“全自动”,实则暗流涌动。
- 栈存小而快的原始值,堆放大而活的对象;
- 基本类型按值传递,引用类型靠“快递单号”操作;
const锁的是变量名,不是对象内容;null是你主动放手,undefined是系统默认留白;- 而真正的回收权,始终握在 垃圾回收器 手中。
理解这些,你不仅能答对面试题,更能写出轻盈、健壮、不易泄漏的代码。尤其在构建大型应用时,内存意识就是性能底线。
别再假装看不见内存了——
优秀的前端,既要会写 UI,也要懂引擎怎么“呼吸”。