闭包不是魔法,是作用域链的必然结果
很多和我一样的初学者在一开始学习闭包(Closure)的时候觉得是JS的某种特异功能。但是实际上,闭包在ECMAScript 规范中是一个自然产物。
要彻底理解闭包,我们必须拆解 V8 引擎在执行代码的时候的底层逻辑:调用栈(call stack),执行上下文(execution context),以及词法环境 (lexical environment)中outer的引用。
一. 执行上下文与 outer
在 JavaScript 中,每当一个函数被调用,引擎就会为它创建一份执行上下文(Execution Context)并压入调用栈。 每个执行上下文中,都包含一个词法环境(Lexical Environment)。这个环境内部有两个重要组成部分:
- 环境记录(Environment Record):存放当前函数内部声明的变量和函数。
- 外部环境引用(outer):指向它在词法上(写代码的位置)的外层执行上下文。 正是这个 outer 引用,构成了我们常说的作用域链(Scope Chain)。当引擎在当前函数的环境中找不到某个变量时,就会顺着 outer 指向的外部环境一路向上查找,直到全局环境。
底层铁律: outer 的指向,在函数'定义(声明)'的时候就已经决定了,而不是在函数执行(调用)的时候决定。这就是“词法作用域”。
二.从内存视角拆解一个标准闭包
我们用一段最经典的闭包代码,来看看当它被 V8 引擎执行时,内存和调用栈里究竟发生了什么:
function createCounter() {
let count = 0;
function change() {
count++;
console.log(count);
}
return change;
}
const counter = createCounter();
counter(); // 1
1. 执行 createCounter() 时
- createCounter 的执行上下文被压入调用栈。
- 它的词法环境中,变量 count 被初始化为 0,同时定义了函数 change。
- 注意:此时 change 函数作为一个对象被创建,由于它在源码里写在 createCounter 内部,V8 引擎在创建它时,会赋予它一个隐藏属性 [[Scopes]],这个属性会保持对当前 createCounter 词法环境的引用。
2. createCounter() 执行完毕并返回时
- 按照常规逻辑,一个函数执行完,它的执行上下文就会从调用栈弹出并销毁,释放内存。
- 但是! 它的内部函数 change 被返回了,并被全局变量 counter 引用。
- 因为 counter(即 change)还活着,而 counter 的 [[Scopes]] 属性死死勾住了 createCounter 的词法环境。
3. V8 引擎的破例:Closure 对象的诞生
V8 发现 createCounter 虽然退栈了,但它里面的 count 变量还在被内部函数引用着。于是,垃圾回收机制(GC)不会清理这段内存。 V8 会把 change 函数用到的外部变量(这里是 count)打包,在堆内存(Heap)中创建一个专门的对象,这个对象就叫 Closure(闭包)。
三. 调用闭包函数时的 outer 查找规则
现在,我们执行 counter()(即调用 change 函数):
- V8 创建 change 的执行上下文,压入调用栈。
- 此时,change 的词法环境被创建,它的 outer 引用指向哪里?
- 指向它出生时的那个外层环境(即保留在堆内存中的 createCounter 的 Closure 空间)。
- 执行 count++:
- 引擎先在 change 本地环境中找 count,没找到。
- 顺着 outer 链条,进入 createCounter 的闭包环境,找到了 count,将其修改为 1。 当 counter() 执行完,change 的上下文弹栈销毁,但那个堆内存中的 Closure 闭包空间依然存在。下一次你再调用 counter(),它依然顺着 outer 找到同一个 count 变量,实现累加。
四.为什么要从底层理解闭包?
如果只停留在比喻层面,你很难解释下面这两个高级前端面试必考的“深水区”问题:
1. 内存泄漏的本质是什么?
如果闭包函数(如上面的 counter)一直存活在全局作用域中(没有被置为 null),那么它通过 outer 间接引用的整条作用域链上的变量都无法被垃圾回收。这相当于在堆内存里钉死了一块空间,用得多了就会导致内存泄漏。
2. V8 引擎的闭包优化
现代 V8 引擎非常智能。如果外层函数有一百个变量,但内部函数只用到了一个,V8 只会把用到的那个变量放进 Closure 对象中,其余没用到的变量在父函数弹栈时依然会被无情销毁。这种精细化的内存控制,只有理解了底层原理才能真正体会。
闭包是语言设计的必然
闭包不是动态注入的补丁,它是 “函数作为一等公民(First-class Function)” 与 “词法作用域(Lexical Scope)” 碰撞后的必然产物。 只要 JavaScript 允许函数作为返回值,且作用域由书写位置决定,那么通过 outer 引用将父级环境锁死在堆内存中的“闭包机制”,就是维持程序逻辑正确的唯一解。