在学习 JavaScript 的过程中,许多开发者常被“按值传递还是按引用传递”、“闭包到底存放在哪”、“全局与局部作用域的本质是什么”等概念搞得昏头转向。网络上不少文章在解释这些概念时往往遮遮掩掩,拿高层抽象掩盖底层物理逻辑。
本文将抛弃繁复的抽象教条,采用“栈表单与堆房间”以及“主线大地图与临时副本”的物理模型,带你从底层内存映射的角度彻底理清 JS 的变量存储、参数传递与作用域运行机制。
一、 变量与数据存放在哪里?(栈表单与堆房间模型)
要理解 JS 的数据存储,最直观的方式是将栈内存(Stack)想象成一张严格排列的 Excel 表单,而将堆内存(Heap)想象成一座后方大仓库(堆房间)。
【栈内存 (Stack - 栈表单)】 【堆内存 (Heap - 堆房间)】
(空间小、读写极快、结构严谨) (空间巨大、按需分配)
变量地址 变量名 单元格存的内容 堆门牌号 真实存储的数据实体
───────────────────────────────── ─────────────────────────────
0x002 [ a ] │ 123 (原始值) │ ... ...
───────────────────────────────── ─────────────────────────────
0x003 [ obj ] │ 0x010 (堆地址)│──────┐ 0x010 │ { name: "张三", │
───────────────────────────────── │ │ age: 18 } │
└───────────────> │ (Key-Value 实体)│

- 原始值类型(Primitive Values)
在 JS 中,Number、String、Boolean、Null、Undefined、Symbol、BigInt 这 7 种原始值,其数据体积小且固定。
- 存储位置:数据本身直接填在栈表单的单元格里(即变量插槽内部)。
- 物理特性:操作原始值时,引擎直接在栈插槽中读写值本身。
- 引用类型(Objects / Arrays / Functions)
对象、数组、函数等引用类型数据大小不固定,可能随时动态扩容,无法直接塞进结构严谨的栈表格中。
- 存储位置:真实的 Key-Value 属性、方法实体均保存在堆内存的独立房间(如 0x010)里。
- 栈表单里存什么:变量在栈表单单元格里,只保存那个堆房间的门牌号(内存地址 0x010)。
- 内存分布一览表
| 栈地址 | 变量名 | 数据类型 | 单元格中存的实际内容 | 物理数据所在地 |
|---|---|---|---|---|
| 0x002 | a | 原始值 | 123 | 栈单元格内 |
| 0x003 | obj | 引用类型 | 0x010 (堆地址) | 堆内存 0x010 房间 |
| 0x004 | arr | Array | 0x011 (堆地址) | 堆内存 0x011 房间 |
| 0x005 | fn | Function | 0x012 (堆地址) | 堆内存 0x012 房间 |
二、 传递与共享:到底怎么界定?
JavaScript 官方规范和底层实现只有一个核心结论:JavaScript 只有按值传递(Call by Value)。
当把一个变量作为参数传给函数时,发生的动作只有一个:将该变量在“栈表单”单元格里装的内容物理复制一份,丢给函数的形参。
// 栈表单分配:num 的单元格写入 100
let num = 100;
// 栈表单分配:person 的单元格写入堆地址 0x010
let person = { name: "张三" };
function update(a, b) {
// 1. a 拿到的是 num 的值副本(100),修改 a 仅改变形参 a 的栈插槽,对外部 num 无影响
a = 200;
// 2. b 拿到的是 person 单元格里的堆地址副本(0x010)
// 通过 b.name 修改属性,是沿着地址找到 0x010 堆房间改内容,外部 person 会受到影响(共享堆内存)
b.name = "李四";
// 3. 重新给 b 赋值:在堆中开辟新房间 0x020,把 b 的栈插槽改为 0x020
// 这切断了形参 b 与外部 person 的地址关联,后续修改不影响外部
b = { name: "王五" };
}
// 调用函数:把 num 的值(100) 和 person 的地址(0x010) 复制给形参 a 和 b
update(num, person);
console.log(num); // 输出: 100 (原始值按值复制,互不干扰)
console.log(person.name); // 输出: "李四" (通过共享的堆地址改写了堆房间内容)
- 原始值传递:复制的是“数值本身”,两个变量完全独立,不存在共享。
- 引用类型传递:复制的是“堆地址”。因为形参和实参拿着同一个门牌号,指向堆内存里的同一个房间,这种现象在计算机科学中常被称为按共享传递(Call by Sharing)。
三、 全局与局部作用域:大地图与临时副本
如果将“栈表单”与“堆房间”套用到代码运行状态中,作用域的本质就是代码执行时访问账本的权限控制。
======================= 作用域栈帧模型 =======================
【局部作用域 (副本附页)】 ── 函数调用时压入栈顶,执行完立刻出栈销毁
变量名 值
[ sum ] 30
【全局作用域 (主表大地图)】 ── 程序启动即建立,贯穿始终
变量名 值
[ a ] 10
[ b ] 20
- 全局作用域(主表 / 大地图)
- 运行机制:程序一启动,栈内存底部就会生成一张全局“主表”。没有写在任何函数内部的代码,直接在全局主表上运行。
- 生命周期:贯穿整个程序的生命周期,直到网页关闭或进程退出才会销毁。
- 局部作用域(临时附页 / 副本)
- 运行机制:每次调用函数时,引擎会在栈顶平铺压上一张全新的“临时附页”(局部栈帧)。函数内部声明的变量和形参,全记录在这张附页里。
- 生命周期:函数执行完毕后,这张临时附页会被直接撕掉丢弃(出栈),内部的局部变量随之销毁。
- 闭包的物理真相
正常情况下,函数执行完后其栈帧附页会被撕掉。但如果内部函数引用了外部函数的变量:
- 引擎在销毁该栈帧前,会检测到变量被闭包捕获。
- 引擎将该变量从即将销毁的栈帧附页上“抢救”下来,打包移入堆内存中一个叫 Context 的独立房间。
- 即使局部栈帧销毁,内部函数依然保留着去该堆房间提取数据的钥匙,这就是闭包占用内存且不会被垃圾回收的物理原因。
四、 软件工程真相:我们在编辑器/调试器里看到的是什么?
探究底层的过程中,一个终极问题是:物理内存里真的有 let a = 123 里的 a 吗?
答案是没有。 - 真实物理内存(RAM):只有二进制数据(0 和 1)以及物理/虚拟内存地址,根本不存在“变量名”这几个字符。在编译阶段,变量名早已被替换为基于栈帧指针的内存偏移量。
- 符号表(Symbol Table):JS 引擎在后台维护着一张暗号对照表,将人类可读的变量名映射到物理内存的地址偏移上。
- 调试器与 UI 投射:我们在 VS Code、Chrome DevTools 控制台里看到的高亮代码、对象展开树以及变量状态,本质上是调试器根据引擎后台的物理内存账本与符号表,实时渲染出来的一套可视化 UI 投影。
结语
理解 JavaScript 的内存与作用域,关键在于清晰区分物理内存存储与高层语言语法: - 栈表单存局部变量卡位、原始值数据以及堆地址;堆房间存对象实体的 Key-Value 和函数体。
- JS 只有按值传递,传引用类型时复制的是堆地址,表现为按共享传递。
- 全局作用域是长存的主表,局部作用域是随调随建、用完即毁的临时栈帧附页。
建立起这套清晰的内存模型后,无论是分析闭包、对象引用修改,还是排查内存泄漏问题,都能一眼看透代码在计算机底层的真实流动。
浙公网安备 33010602011771号