执行上下文栈(ecstack)只存储执行上下文引用,变量实际存储在堆中的词法环境记录里;闭包通过[[environment]]引用这些堆对象,实现变量共享与长期存活,而非拷贝值或残留栈帧。

执行上下文栈(ECStack)不存变量,只存执行上下文引用
很多人误以为函数执行时,局部变量被“压入”执行上下文栈。实际上,ECStack 里只保存 ExecutionContext 对象的引用(比如 { LexicalEnvironment, VariableEnvironment, ThisBinding }),而这些对象内部的 LexicalEnvironment 才真正持有变量绑定。关键点在于:这些环境记录(Environment Record)本身是对象,它们被分配在堆内存中——不是栈上。
为什么必须放堆?因为闭包需要长期存活。例如:
function outer() {
let x = 10;
return function inner() { return x; };
}
const fn = outer(); // outer 执行结束,EC 被弹出 ECStack
fn(); // 仍能访问 x → x 必须没被销毁
此时 outer 的词法环境不能随执行上下文一起销毁,否则 inner 就读不到 x。所以 JS 引擎把该环境记录作为堆对象保留,由 inner 的 [[Environment]] 内部槽引用它。
闭包捕获的是词法环境记录,不是变量值的拷贝
闭包不是“复制一份变量”,而是持有对原始环境记录的引用。这意味着多个闭包可以共享同一变量:
function makeCounter() {
let count = 0;
return [
() => ++count,
() => count
];
}
const [inc, get] = makeCounter();
inc(); inc();
console.log(get()); // 2
这里 inc 和 get 共享同一个 count 绑定,因为它们的 [[Environment]] 指向同一个堆上的 DeclarativeEnvironmentRecord。
- 如果变量是基本类型(如
number、string),修改它会直接更新堆中该环境记录里的值 - 如果是对象,闭包持有的是对象的堆地址,所有闭包看到的是同一份对象实例
- 不存在“闭包变量栈帧残留”这种说法——栈帧(stack frame)在函数返回后就没了,只有堆中环境记录活着
var 声明与函数提升不影响闭包捕获逻辑
var 声明会进入 VariableEnvironment,而 let/const 进入 LexicalEnvironment,但二者在闭包场景下都表现为堆中可被长期引用的环境记录。区别仅在于初始化时机和作用域规则:
-
var x = 1在函数创建时就绑定到VariableEnvironment,且被提升;但闭包仍引用这个堆中的环境记录,不是栈上某个临时位置 -
let y = 2在进入块时才初始化(TDZ),但一旦初始化完成,其绑定也落在堆中同类型的环境记录里 - 无论用哪种声明方式,只要被闭包引用,对应绑定就不会被 GC 回收——直到所有引用它的闭包脱离作用域
容易被忽略的内存泄漏点:无意中延长环境记录生命周期
闭包让环境记录活在堆中,本是正确行为;但若闭包意外被全局或长生命周期对象持有,就会导致整块环境记录(及其所有变量)无法释放:
let globalRef = null;
function leaky() {
const hugeArray = new Array(1000000).fill('data');
const obj = { method() { return hugeArray.length; } };
globalRef = obj.method; // 闭包持有了 hugeArray
}
leaky();
// hugeArray 此时无法被 GC,哪怕你只想要一个轻量 method
这类问题不会报错,但内存占用持续增长。排查时要关注:[[Environment]] 链是否意外延伸过长,尤其注意事件监听器、定时器回调、暴露给外部的函数引用。
真正难调试的,是那些看似无害的闭包——它们安静地把一堆不该存在的数据钉在堆里,等你发现时,已经积压了几十 MB。










