很多 JavaScript 学习者对“闭包”的定义都背得很熟:函数内部返回一个函数,内部函数还能访问外部函数的变量。但真到项目里,循环绑定事件、定时器里的旧数据、组件卸载后还在执行的异步回调,问题一个接一个。闭包不是“函数套函数”这个语法现象,它是一整套作用域机制在运行时留下的痕迹。这篇文章我打算从作用域链和执行上下文讲起,再用真实项目里的场景收尾,希望能帮你把“闭包”从名词变成可观测、可调试的东西。
1. 闭包不是“函数套函数”,是作用域链在保留现场
很多人把闭包理解为“外层函数套内层函数,内层函数访问外层变量”的写法,这个理解在今天看来太表面了。要真正踩稳闭包,得先搞清楚 JavaScript 凭什么能在一个函数执行完之后,还能让另一个函数继续访问它的变量。答案的起点是词法作用域。
1.1 词法作用域:函数出生时就定好了“找变量”的路线
JavaScript 使用的是词法作用域,也叫静态作用域。一个函数能访问哪些变量,在它被定义的那一刻就已经确定了,而不是在它被调用的时候才决定。这个“定义时的位置”,就是整个变量查找链路的起点。
我刚开始学的时候也犯过迷糊,总觉得函数在哪个地方被调用,就应该看到哪个地方的变量。但实际不是这样。看这个例子:
javascript复制const name = 'outside';
function outer() {
const name = 'inside';
function inner() {
console.log(name);
}
inner();
}
outer(); // 输出 'inside'
inner 定义在 outer 内部,所以它的作用域链是:自己的环境 -> outer 的环境 -> 全局环境。无论之后你把 inner 拿到哪里去调用,它看到的还是这条固定的查找路线。换句话说,函数出生在哪个作用域,它一辈子都记得那个作用域。
这个“出生地”概念非常关键,因为闭包不是从“返回函数”这种写法里生出来的,而是从“函数定义时的环境引用”里生出来的。如果你写了一个函数返回另一个函数,但内层函数没有引用外层变量,那其实不算真正意义上的闭包场景,最多算一个高阶函数。
1.2 闭包形成的过程,以及“闭的是哪一层”
我们看一段最典型的闭包代码:
javascript复制function createCounter() {
let count = 0;
return function () {
count += 1;
return count;
};
}
const counterA = createCounter();
const counterB = createCounter();
console.log(counterA()); // 1
console.log(counterA()); // 2
console.log(counterB()); // 1
createCounter 每次被调用,都会生成一块独立的局部环境,count 分别属于不同环境。counterA 和 counterB 各自闭着各自的 count。这就是“闭的是环境,不是变量值”的含义。
这里有两个很容易忽略的点。
第一,闭包捕获的不是 count 在某一时刻的值,而是 count 这个名字与当前存储位置之间的绑定关系。之后你给 count 赋什么新值,闭包看到的就是新值。
第二,createCounter 执行完之后,它的执行上下文已经从调用栈弹出了,但堆内存里的那份词法环境还在,因为 inner 函数对象的内部引用还指着它。这个“栈上销毁,堆上保留”的状态,是很多新手理解闭包的坎。
我习惯用一个比喻来解释:你把一份合同的原件放在保险柜里,然后把保险柜钥匙交给了另一个人。即使你人离开了办公室,保险柜本身并不会消失,因为钥匙还在别人手里。闭包也是这样,外部函数执行完相当于人走了,但内部函数还握着那段环境的钥匙。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 深入执行上下文:闭包能“记住”外层变量,凭的是什么
知道了闭包是“环境的引用”,还需要弄清楚这个环境在 JavaScript 引擎里到底长什么样,以及为什么函数执行完环境还能存活。
2.1 函数执行上下文的创建与销毁:栈上的“弹栈”不等于一切都消失
每次调用函数,JavaScript 引擎都会创建一个函数执行上下文,并把它压入调用栈。函数正常结束后,这个上下文从栈上弹出。这一段过程大多数人都知道。
但
