一、闭包的引出
1.1 作用域链和调用栈
作用域链是 JavaScript 引擎在查找变量时使用的 动态链表结构 ,它由多个 变量对象(Variable Object) 组成,每个变量对象对应一个作用域。当访问一个变量时,引擎会从当前作用域开始,逐级向上查找,直到找到变量或到达全局作用域。
作用域链的查找规则
-
JavaScript先在当前作用域中查找变量,如果没有找到,就会向上一级作用域中查找,直到找到全局作用域,还没有找到就会报错。
-
作用域链的上一级由outer指针决定,outer指针指向当前作用域嵌套(在作用域一章有介绍)的外层。
js
function foo() {
var a = 1
let b = 2
{
let b = 3
var c = 4
let d = 5
console.log(a)// 输出:1 当前作用域没有a,就会去上一级作用域找
console.log(b)// 输出:3 当前作用域有b,就会直接使用
}
console.log(b)// 输出:2 输出当前作用域的b
console.log(c)// 输出:4 var声明的c发生变量提升
console.log(d)// 报错:ReferenceError: d is not defined let声明的d不会发生变量提升,不能访问内层的d
}
foo()
在 JavaScript 中,调用栈 是一种后进先出(LIFO)的数据结构,用于存储代码执行过程中创建的执行上下文。每个函数调用都会创建一个新的执行上下文,并被压入调用栈的顶部;当函数执行完毕后,其执行上下文会从栈顶弹出。它负责跟踪代码的执行流程、函数调用关系以及变量的作用域。
执行上下文是 JavaScript 执行一段代码时的环境,它定义了变量和函数的作用域,并包含四个核心组件:
- 变量环境(Variable Environment) :存储变量和函数的初始定义(ES6 之前的主要存储位置)。
- 词法环境(Lexical Environment) :ES6 引入的概念,主要存储
let
和const
声明的变量。 - 作用域链(Scope Chain) :由当前变量对象和外层作用域的变量对象组成。
- this 指针:指向当前执行上下文的对象。(暂时不介绍)
执行上下文的类型
- 全局执行上下文:代码开始执行时创建的第一个上下文,全局变量和函数存储在此。
- 函数执行上下文:每次函数调用时创建,包含函数内部的变量和参数。
- Eval 执行上下文 :
eval()
函数执行时创建(极少使用)。
调用栈的工作流程
js
function greet(name) {
return `Hello, ${name}!`;
}
function sayHi() {
const message = greet("Alice");
console.log(message);
}
sayHi();
- 全局调用了
sayHi()
函数,创建sayHi()
函数执行上下文并压入调用栈 - 执行函数
sayHi()
,发现调用了greet()
函数,创建greet()
函数执行上下文并压入调用栈 - 执行
greet()
函数,返回Hello, Alice!
,greet()
函数执行完毕,执行上下文会从栈顶弹出。 - 继续执行
sayHi()
函数,message
被赋值Hello, Alice!
,执行console.log(message);
输出:Hello, Alice! sayHi()
函数执行完毕,执行上下文会从栈顶弹出。程序也执行完毕。
1.2 为什么要有闭包
接下来让结合我们上面学习的作用域链和调用链相关知识分析下面代码的执行过程,这里也需要用到之前预编译的相关知识,不了解或忘记的可以复习JavaScript从入门到入土(3):预编译 。
js
function outer() {
let count = 0;
function inner() {
count++;
console.log(count);
}
return inner;
}
const counter = outer();
counter();
- 编译全局,调用栈结构图如下图所示

- 执行全局,调用
outer()
函数,创建outer()
函数执行上下文并压入调用栈 - 编译函数,调用栈结构图如下图所示

- 执行函数,
count
赋值0,返回函数inner()
给全局变量counter
。outer()
函数执行完毕,执行上下文从栈顶弹出。

- 继续执行全局

继续分析我们知道调用counter()
函数即调用inner()
函数,于是创建inner函数执行上下文。调用栈如上图所示。
但继续执行inner函数我们会发现一个问题,outer()
已经销毁了,存在于outer函数执行上下文的变量count还能被访问到吗?
答案是能,因为有闭包的存在,这也是为什么要有闭包的原因。
根据作用链的查找规则,内部函数一定有权利访问外部函数的变量。而根据调用栈的调用规则,一个函数执行完后函数执行上下文一定会被销毁。那么当函数A内部声明了一个函数B,而函数B拿到函数A外面执行(例如上面程序)的情况时。为了保证上面两个规则正常执行,函数A在执行完毕后会将B需要访问的变量保存到一个集合中,这个集合就是闭包。
引入了闭包这个帮手后,我们可以补充相关内容得到更准确的执行过程。
- 遇到
inner
函数定义时:
inner
函数被创建,同时捕获outer
的词法环境(闭包形成)。inner
的作用域链为:[inner 词法环境] → [outer 词法环境] → [全局词法环境]
。
- 返回
inner
函数:
outer
执行完毕,其执行上下文从调用栈弹出。- 但
inner
函数的闭包 保留了对outer
词法环境的引用,因此count
变量不会被销毁。
由此我们可以得到当前的inner函数执行上下文实际如下图所示。

执行inner()
接下来的过程就很简单了,对count++
后输出,即输出1。inner()
执行完毕,弹出,全局执行完毕,弹出。
二、闭包的关键特性
1. 变量捕获与内存管理
闭包会捕获外层函数的变量引用,导致这些变量不会被垃圾回收。因此,滥用闭包可能导致内存泄漏。
示例:
js
function createClosure() {
const largeArray = new Array(1000).fill('x'); // 占用大量内存
return function() {
console.log(largeArray.length);
};
}
const closure = createClosure(); // largeArray 不会被回收,因为被闭包引用
2. 循环中的闭包陷阱
循环中使用闭包时,闭包捕获的是变量的引用,而非循环时的值。
错误示例:
js
for (var i = 0; i < 3; i++) {
setTimeout(() => {
console.log(i); // 输出 3, 3, 3(闭包捕获的是同一个 i 变量)
}, 100);
}
正确解法:
- 使用
let
(块级作用域):
js
for (let i = 0; i < 3; i++) {
setTimeout(() => {
console.log(i); // 输出 0, 1, 2(let 为每次循环创建独立的 i)
}, 100);
}
- 使用立即执行函数(IIFE):
js
for (var i = 0; i < 3; i++) {
(function(j) {
setTimeout(() => {
console.log(j); // 输出 0, 1, 2(通过 IIFE 创建独立的 j)
}, 100);
})(i);
}
三、闭包的性能考量
-
内存占用:闭包会保留变量引用,可能导致内存占用增加。
-
垃圾回收 :若闭包不再需要,应手动解除引用(如将闭包设为
null
)。 -
过度使用:避免在循环或频繁调用的函数中创建不必要的闭包。
理解闭包对掌握JavaScript非常重要,如果觉得这篇文章对你有帮助的话就点个赞吧!!