“调用栈和堆的协作关系?”
“闭包的数据如何存储?”
这些问题背后,都是同一件事:你在和 JS 的内存机制打交道。这篇文章不讲玄学,只帮你搭一套清晰、够用的心智模型,让你明白其中的道理
1. JS 到底是什么语言?先把“动态弱类型”说明白
你的示例里有一段代码,大概是这样玩的:
var bar;
console.log(typeof bar); // undefined
bar = 12;
console.log(typeof bar); // "number"
bar = '极客时间';
console.log(typeof bar); // "string"
bar = true;
console.log(typeof bar); // "boolean"
bar = null;
console.log(typeof bar); // "object" 经典 bug
bar = { name: '极客时间' };
console.log(typeof bar); // "object"
从这段就能看出几个关键点:
- 动态语言:变量声明的时候不用写类型,运行时才决定类型
- 弱类型:同一个变量,可以随便换类型,数字、字符串、对象都行
typeof null === 'object'是 JS 设计遗留的问题,教材里常见的那个“坑”
也正因为是动态弱类型,JS 引擎在执行时,需要:
- 跟踪每个变量当前是什么类型
- 决定这个值应该放在什么地方(栈里还是堆里)
这就把我们自然带到了“内存空间”话题。
2. 代码是怎么进内存的?三块空间先搞清楚
可以先粗略把 JS 运行时的内存分成三块:
-
代码空间:程序的代码从硬盘读出来,放到内存里
-
栈内存:调用栈、执行上下文都在这里
- 体积小
- 连续空间,切换快、好管理
-
堆内存:对象、数组等大块数据在这里
- 空间大
- 分配和回收都要多花点时间
V8 这样的引擎,会用栈来维护程序执行期间的执行上下文状态。
执行上下文的切换,本质上就是栈顶指针的移动,所以栈空间要小而精简,才能频繁、快速地切换。
于是就有了常见那句总结:
- 简单数据类型 → 放栈里(直接存)
- 复杂数据类型 → 放堆里(栈里放的是“地址”)
下面用几段你已经写好的小代码,把这个讲清楚。
3. 简单数据类型:在栈里是“值拷贝”
看这段关于基本类型的小例子:
function foo() {
var a = 1; // 赋值
var b = a; // 拷贝
a = 2;
console.log(a); // 2
console.log(b); // 1
}
foo();
在内存里的感觉可以理解为:
- 栈里创建一块空间存
a = 1 b = a这一步,是把值 1 再拷贝一份到b自己那块栈空间- 后面
a = 2只是把a那一格改成 2 b那一格还是 1,互不影响
所以:
- 简单类型(
number、string、boolean、undefined、symbol、bigint等) - 在栈里就是值本身,赋值就是再拷贝一份
你在另一个例子里也写了:
var a = "极客时间";
var b = a;
var d = a;
这里 a、b、d 在栈里各自占一格,里面都直接存的是那个字符串的引用值,行为也是“值拷贝”的语义。
4. 复杂数据类型:在栈里是“地址”,在堆里才是对象
再看你写的这段关于对象的代码:
function foo() {
var a = { name: '极客时间' }; // 引用式赋值
var b = a; // 引用式拷贝
a.name = '极客邦';
console.log(a);
console.log(b);
}
foo();
结合注释里那句:
栈内存中是地址,堆内存中是对象(引用)
可以这样理解:
-
在堆里:
- 有一个对象
{ name: '极客时间' }
- 有一个对象
-
在栈里:
a这块空间里 存的是“这个对象在堆里的地址”b = a→ 把这个地址又拷贝了一份给b
所以当你:
a.name = '极客邦';
是对堆里那一个对象动手:
a和b都指向它- 打印出来时,两边看到的都是修改后的结果
这一点和基本类型的“值拷贝”是完全不一样的语义。
5. 执行上下文、调用栈和内存之间的关系
再往下,你的笔记里提到了执行上下文的几个关键部分:
-
调用栈
-
执行上下文对象
- 变量环境
- 词法环境
- outer 词法作用域链(闭包相关)
this
可以把整个流程连起来看:
-
代码加载进来
- 放到“代码空间”里
-
开始执行时
- 创建全局执行上下文,压入调用栈
- 栈里此时有一帧:全局上下文
-
每次调用一个函数
-
为这个函数创建一个新的执行上下文
-
里面会有它自己的:
1.变量环境(
var声明的变量、函数声明等)2.词法环境(
let、const声明的变量)3.指向外部作用域的引用(outer 链)
4.
this -
然后把这个执行上下文压入调用栈
-
-
函数执行完
- 对应的那一层栈帧弹出
- 这一层里的局部变量(简单类型)就可以随着这次弹出释放
- 复杂类型如果已经没有任何变量再引用它,对应的堆内存对象就会在后面逐步被引擎回收
这就是那句总结:
回收的时候,栈回收(指针偏移),堆内存中的对象没有变量引用就可以慢慢回收。
6. 用一个闭包例子,把“词法环境”和“堆里的 closure”串起来
你有一段很典型的代码,用来讲“闭包和内存”的:
function foo() {
var myName = "极客时间";
let test1 = 1;
const test2 = 2;
var innerBar = {
setName: function(newName) {
myName = newName;
},
getName: function() {
console.log(test1);
return myName;
}
}
return innerBar;
}
var bar = foo();
bar.setName("极客邦");
console.log(bar.getName());
配合你的笔记,可以这样拆:
1)先编译,再执行
-
先编译 foo 函数,创建全局执行上下文
-
在编译阶段,对 foo 里的代码做了一次词法扫描:
-
发现有内部函数(setName、getName)
-
发现这些内部函数用到了外部变量:
myName、test1、test2
-
2)发现有闭包,要在堆里准备“额外的东西”
-
JS 引擎判断:这里有闭包
-
会在堆内存里为 foo 相关的东西准备一个结构,可以理解成一个
closure(foo):- 把内部函数本身放到堆里
- 把内部函数依赖的外部变量(
myName、test1、test2)也一起保存下来
你的笔记里总结得很直接:
- 第一步:需要扫描内部函数,放到堆内存中
- 第二步:把内部函数引用的外部变量保存到堆中
3)为什么 foo 已经执行完,myName 还活着?
流程往下走:
-
调用 foo() 时:
- 为 foo 创建执行上下文,压栈
- 在这个上下文里创建
myName、test1、test2、innerBar
-
foo 返回
innerBar这个对象给外面的变量 bar -
从此以后,bar.setName、bar.getName 这两个函数:
- 虽然在“语法上”看起来是在全局被调用
- 但它们背后都带着对
closure(foo)的引用 closure(foo)里保存着myName、test1等变量的那块环境
所以:
- 虽然 foo 的那一层栈帧已经弹出了
- 但因为外面还有 bar 在引用那几个内部函数
- 这些内部函数又需要访问
myName、test1 - 于是与之相关的那部分数据就不能被回收,会被保留在堆里
这就是“闭包”的内存意义:让某些本来会随着函数结束而销毁的变量,延长了生命周期。
7. 小结:这几件事想清楚,就算是入门 JS 内存了
简单收个尾,把你代码里涉及的点再串一下:
-
JS 是动态弱类型语言
- 变量类型可以在运行时不断变化
typeof null === 'object'是历史“坑”
-
内存分区和职责
- 代码空间:放代码
- 栈内存:调用栈、执行上下文,小而快
- 堆内存:对象、数组等大块数据,分配/回收都要时间
-
简单类型 vs 复杂类型
- 简单类型:栈里直接存值,赋值就是“值拷贝”
- 复杂类型:堆里存对象,栈里存地址,赋值就是“引用拷贝”
-
执行上下文 & 调用栈
- 全局 + 每次函数调用都会有一个执行上下文
- 切换本质是栈顶指针移动
-
闭包与内存
- 编译阶段发现内部函数和它依赖的外部变量
- 在堆里为它们准备 closure 结构
- 只要外面还有地方引用这些内部函数,对应的外部变量就会继续“活着”