闭包,JavaScript中又一个让人难以理解的概念,我们时常有意或无意的使用到了它,当我们无法分辨出它的时候,有可能对项目引发这些问题:内存泄露、变量污染、代码维护困难和性能消耗。为此,当它出现的时候我们要认出它,理解它的原理,这样它对项目的影响才在我们的掌控范围内。
1、什么是闭包?
闭包 是JavaScript
中的核心概念,它描述了一个函数与其词法作用域的绑定关系,它允许函数在函数定义的作用域范围外调用时,依然可以访问并操作作用域内的变量。
其实我们更应该叫它为作用域闭包 ,因为闭包就是词法作用域中的变量在外部作用域被使用(虽说不能直接修改,须通过内部的函数进行修改)的现象,所以这么叫也更能理解它的原理。
所以弄清楚词法作用域的概念和规则,闭包的神秘面纱也就能轻易揭开了。
2、词法作用域
同大部分语言一样,JavaScript
代码在编译的时候第一步就是将代码分词/词法化 ,编译器会将var a = 7
这段代码分解为var、a、=、7
这些词法单元。词法作用域就是定义在分词阶段的作用域,它会保证词法分析器处理代码时保持作用域不变,它由我们写代码将变量或者块作用域写在哪里决定。
2.1、作用域结构
直接上图,这样很清晰的展示了作用域的结构关系以及查找规则

这段代码很好的展示了多个作用域之间的关系,我们很直观的通过代码的背景色,由浅入深的将这些代码分为了层层嵌套的3个作用域,它们像套娃一样,大的包住小的。正如前面所说,词法作用域也遵循这样的作用域规则,这种结构关系正如我们写这段代码希望它们应该有的结构关系。
2.2、查找规则
还是以上面那段代码为例,具体分析一下console.log(a,b,c)
这段代码中的变量查找过程:
- 首先是
a
变量,引擎在对a
标识符(也就是变量)进行查找时,首先会在使用a
的作用域(3号作用域)中进行查找,当前作用域没有a
的声明,自然往上一层作用域(2号作用域)找,注意,2号作用域有一个隐式的声明:var a = 2
,这是a
作为函数的形参在调用时被声明并赋值的。2号作用域将a
的值给了引擎。 - 第二个是
b
变量,引擎同样在3号作用域中没能找到它,因此也向2号作用域提出查找请求,2号作用域立马就找到了b
标识符,取出它的值(6)给到了引擎。 - 最后是
c
变量,虽说没有直观的声明语句,但还是同a
变量一样,在3号作用域中有个标识符c
的隐式声明:var c = 18
,引擎立马就拿到,不再向上一层找了。
关于变量的查找过程,我观察到以下几点:
- 作用域查找只能是单向的查找,并且只能从内向外找;
- 首先会在当前作用域中查找,一旦找到,不再向上层查找;
- 函数的形参会作为函数内部作用域中的一员被隐式声明。
- 第2点的延伸,我们可以在多层嵌套的作用域中定义同名的标识符,可以达到"遮蔽"效应,内部标识符"遮蔽"外部的标识符。
3、闭包的设计哲学与执行上下文
3.1、闭包的示例
-
返回函数:
javascriptfunction createFun() { var age = 17; function showAge() { console.log(age); } return showAge; } let myAge = createFun(); myAge(); // 17 ---看,这就是闭包
通常,在调用完
createFun
函数后,基于垃圾回收机制,函数内部的属性会被销毁,但是在createFun
函数内部我们创建了一个闭包,闭包是由函数内部的函数(showAge
)以及函数声明所在的词法作用域组成。这个闭包包含了createFun
作用域内的所有局部变量。当我们调用createFun()
后,创建了showAge
函数的实例引用,这个实例有showAge
声明时的词法作用域引用,age
变量就在这个词法作用域当中,所以当我们执行myAge()
时,console.log(age)
被正常执行(基于词法作用域的查找规则)。 -
函数作为参数传递:
javascriptfunction main() { var food = 'tofu'; function showFood() { console.log(food); // tofu } transmit(showFood); } function transmit(fn) { fn(); // 这也是闭包 } main();
transmit(showFood)
,当执行这一段代码时,将main
函数内部作用域中的showFood
函数被当做参数传递给外部transimit
函数时,同时也将这个词法作用域也传递了出去,因为showFood
函数创建了闭包,所以在transmit
函数内部执行showFood
(现在叫fn
)时,可以正常的执行console.log(food)
。
3.2、闭包的设计思想
-
JavaScript
基于函数式编程的核心思想,即函数作为一等公民,能和变量一样被传递和返回,再加上保留词法作用域的引用就设计出了闭包。看看下面这个例子: -
通过保留作用域的引用,可以实现以下特性:
-
封装性
闭包允许函数保留函数创建时的词法环境,形成私有作用域,能像类或者模块一样,有属于自己的私有变量
-
灵活性
函数能在自己声明的作用域以外进行调用,支持回调和柯里化等高阶应用。
-
3.3、执行上下文及词法环境
-
每个闭包函数在调用时,
JavaScript
引擎会创建一个执行上下文,包含创建时的词法环境以及调用时的词法环境,形成了作用域链。 -
执行上下文包含:
- 词法环境:函数创建时的作用域中的存储变量和函数声明。
- 外部环境引用:在调用函数的词法环境中指向父级词法环境(函数创建的作用域),形成作用域链。
-
闭包的本质是函数与其词法环境的绑定。即使外部函数执行完毕,只要内部函数(闭包)仍被引用,其词法环境就不会被垃圾回收。
4、闭包的应用
4.1、模块化
javascript
function counter(a) {
let count = a; // 私有变量
return {
increment: () => count++,
getCount: () => count,
};
}
const c1 = counter(1);
const c2 = counter(7);
c1.increment();
c1.getCount(); // 2
c2.increment();
c2.getCount(); // 8
在本例中,通过counter
工厂函数,创建出独立的两个对象c1、c2
,他们之间互不相扰,利用闭包原理,在increment
和getCount
函数被创建时,它们的作用域不被销毁,生成了独立的词法作用域。
4.2、函数柯里化
javascript
function addition(a) {
return function(b) {
return a + b
}
}
var fun1 = addition(1);
var res1 = fun1(3);
console.log(res1); // 4
var fun2 = addition(8)
var res2 = fun2(7);
console.log(res2); // 15
利用闭包原理,在返回了引用参数a
的函数中,将参数a
记录在了独立的词法作用域中,在调用后不被立即销毁,以备后续使用并记录第一次调用时的参数,利用这一原理,将本来用2个参数的函数分解成了两个只包含1个参数的函数。
5、闭包的陷阱及解决方案
-
内存泄漏
- 问题:闭包长期引用大对象(如DOM元素)导致无法回收。
- 解决 :手动解除引用(如将变量设为
null
)。
javascript
function leaky() {
const bigData = new Array(1e6).fill('*');
return () => console.log('Leaking...');
}
const leak = leaky();
// 不再需要时手动清理
leak = null;
-
循环中的闭包
-
问题:循环内异步操作因闭包捕获变量最终值。
javascriptfor(var i = 0;i < 3; i++) { setTimeout(() => console.log(i), 100); // 3,3,3 }
-
解决 :使用
let
块级作用域或立即执行函数(IIFE)。
javascriptfor (let i = 0; i < 3; i++) { setTimeout(() => console.log(i), 100); // 0,1,2(let修复) }
-
6、闭包的优缺点
优点 | 缺点 |
---|---|
实现数据封装与私有化 | 内存占用高(需保留作用域链) |
支持高阶函数与函数式编程范式 | 可能引发内存泄漏 |
灵活传递函数上下文 | 调试困难(变量来源不直观) |
最佳实践
- 优先使用模块化模式替代全局闭包。
- 避免在闭包中保留大型对象或DOM引用。
- 使用工具(如Chrome DevTools Memory面板)检测内存泄漏。
7、结语
闭包无处不在,我们只需要在它出现的时候发现它,利用它,最后销毁它。我认为闭包是一个工具,和我们生活中使用的工具一样,我们得手握工具说明书,认真阅读,并多多使用它,把它发挥到极致。希望本文能帮助各位同仁掌握闭包原理,哪怕是一点点,我就深感欣慰。最后,如本文传达了不正确的知识,还是希望能有朋友和大牛能及时指正,在此谢过。