前端别再装了!你根本不懂 JS 内存

72 阅读7分钟

你写过 let obj = { a: 1 },但你真的知道 { a: 1 } 在内存里经历了什么吗?

JavaScript 的内存管理看起来“全自动”:变量一声明,内存就分配;不用了,垃圾回收(GC)自动清理。于是很多人(包括曾经的我)误以为:“前端不用管内存。”

直到某天,我搞不清浅拷贝和深拷贝的区别,闭包导致内存泄漏却浑然不觉,才意识到:不是 JS 不需要内存管理,而是我们假装看不见它。

其实,理解 JS 内存机制,是打通引用类型、闭包、原型甚至性能优化任督二脉的关键。接下来,我们就掀开这层“自动”的面纱,看看 V8 引擎背后到底发生了什么。

JavaScript 内存模型

JavaScript 的内存模型主要分为两部分:栈(stack)堆(heap)

  •  用于存储原始类型(如 numberbooleanstring 等)的变量,以及对象的引用地址;
  •  则用来存放复杂数据结构的实际内容,比如对象、数组和函数;

基础数据类型与栈内存:小而快,值在手

JavaScript 的基础数据类型包括:
numberstringbooleannullundefined(ES6 后还有 symbolbigint)。

它们有个共同特点:大小固定,因此被直接存放在栈内存中(闭包等特殊情况除外)。
你操作的就是值本身——这叫 “按值访问”

面试高频题:“基本类型存在哪?”
答不上来的,可能还在用 let a = b 以为是“引用复制” 😅

栈是怎么工作的?想象一个乒乓球筒 🏓

|     |
|  5  | ← 最后放进去,最先拿出来
|  4  |
|  3  |
|  2  |
|  1  | ← 最先放进去,得等上面全拿走才能用
|_____|

这就是典型的 后进先出(LIFO, Last In First Out) ——
栈内存的存取规则,和这个乒乓球盒子一模一样。

想拿底层的 1?抱歉,请先把 2~5 依次取出。
正因如此,栈的操作极快,适合存储那些“小而确定”的基础值。

引用类型与堆内存:

你以为你在操作对象?其实你只拿到了它的“快递单号”

JavaScript 的引用类型(如 ObjectArrayFunction)大小不固定,没法塞进栈里。
它们的真实数据被存放在 堆内存(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 这个值完整拷贝了一份,ab 在栈里是两个独立的盒子,互不影响。

🔥 Demo 2:引用类型(引用复制)

var m = { a: 10, b: 20 };
var n = m;
n.a = 15;
// 问:m.a 是多少?

答案:15
因为 n = m 只是把“快递单号”复制了一份。mn 虽然在栈里是两个变量,但都指向堆里同一个对象。改一个,全变。

💡 记住:基本类型复制的是值,引用类型复制的是地址


一张图看懂区别(文字版脑补)

[栈]                [堆]
a → 20              —
b → 20              —

m → 0x100           → { a: 15, b: 20 }
n → 0x100           ↗

mn 地址相同 → 共享同一个对象。


为什么这很重要?

搞懂栈和堆,你就打通了 JavaScript 的任督二脉:

  • 为什么浅拷贝会“污染”原对象?
  • 闭包为什么会造成内存泄漏?
  • 为什么 {} === {} 是 false

这些谜题,根源都在 “你操作的是值,还是指向值的指针?”

别小看这一层抽象——它正是 JS 灵活又容易踩坑的根源。

内存的生命周期:分配 → 使用 → 回收

在 JavaScript 中,每一块内存都走过三段旅程:

  1. 分配(Allocation) :声明变量、函数或对象时,JS 引擎自动为你分配内存;
  2. 使用(Usage) :读写变量、调用函数——这就是你在“消费”内存;
  3. 回收(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,也要懂引擎怎么“呼吸”。