作为刚学 JS 三个月的新手,我最近被闭包折腾得够呛。刷面经时发现这是高频考点,查文档又被 “函数与词法环境的组合” 这种抽象描述绕晕。直到上周用闭包实现了一个小需求 ——“记住用户上一次输入的搜索词”,才突然打通任督二脉。今天就用最接地气的方式,分享我梳理的闭包知识体系。
一、闭包到底是个啥?先别急着看定义
刚学的时候,我总把闭包想象成什么 “高级结构”,直到翻到《你不知道的 JavaScript》里的一句话: “闭包是当函数可以记住并访问其词法作用域,即使函数是在当前词法作用域之外执行时” 。
翻译成人话就是:
一个函数在定义它的作用域之外被调用时,仍然能访问原作用域的变量,这对 “函数 + 变量” 的组合就是闭包。
举个最常见的例子:
function outer() {
let count = 0;
function inner() {
count++;
console.log(count);
}
return inner;
}
const counter = outer();
counter(); // 1
counter(); // 2
这里inner
就是闭包。它被outer
返回后,在全局作用域执行(脱离了定义时的作用域),但依然能访问outer
里的count
变量。每次调用counter
,count
都会累加, 这说明count
没有被销毁,而是被闭包 “保存” 了。
问题:为什么count
没被销毁?
-
JS 的垃圾回收机制:
当函数执行完毕,其作用域内的变量本应被回收(如outer
中的count
)。但闭包inner
引用了count
,导致 **outer
的作用域对象无法被释放 **,形成一个「持久化的作用域链」。用形象的比喻:
inner
就像一个「背包客」,离开outer
的「老家」时,把count
装进了背包。即使走到全局作用域,背包里的count
依然存在。 -
词法环境的结构:
每个函数创建时都会生成一个「词法环境」,包含:- 环境记录:保存变量(如
count
) - 外部环境引用:指向外层作用域(
outer
的词法环境指向全局,inner
的词法环境指向outer
)。
当inner
被返回时,它的词法环境被保留,形成闭包的核心 ——跨作用域的变量引用通道。
- 环境记录:保存变量(如
划重点:闭包形成的三个条件
- 存在函数嵌套(外层函数包裹内层函数);
- 内层函数引用了外层函数的变量 / 函数;
- 内层函数逃逸到外部作用域(如被返回、赋值给全局变量、回调函数等)。
再看一个颠覆认知的例子:
let x = 10;
function foo() {
console.log(x); // 输出10,而非20
}
function bar() {
let x = 20;
foo(); // 为什么不输出20?
}
bar();
这里foo
定义在全局作用域,它的词法作用域链固定为 全局作用域。即使在bar
中调用,foo
依然访问的是全局的x
。这说明闭包的作用域链是静态绑定的,与调用位置无关。
解析:
-
foo
的作用域链在定义时确定:
foo
定义在全局作用域,其词法作用域链为:全局作用域 →null
(顶层作用域)。无论在哪里调用foo
(如bar
内部),它永远只能访问定义时的作用域链。 -
bar
的作用域链与foo
无关:
bar
的作用域链为:bar
函数作用域 → 全局作用域。foo
在bar
中调用时,只会沿着自己的作用域链向上查找,不会进入调用者bar
的作用域。
二、为什么需要闭包?它能解决什么问题?
我们可能会疑惑:JS 有作用域链,直接用全局变量不行吗?为啥非得用闭包?
举个真实需求:做一个搜索框,需要 “记住用户上一次输入的内容”。如果用全局变量:
let lastInput = '';
function saveInput(input) {
lastInput = input;
}
// 问题:lastInput暴露在全局,可能被其他代码意外修改
这时候闭包的优势就体现了 ——它能创建私有变量,避免污染全局:
function createInputSaver() {
let lastInput = '';
return function saveInput(input) {
lastInput = input;
console.log(lastInput)
return lastInput;
};
}
const saver = createInputSaver();
saver('第一次输入'); // '第一次输入'
saver('第二次输入'); // '第二次输入'
这里lastInput
是 “私有” 的,只有saveInput
函数能修改它,完美解决了全局变量的隐患。
闭包的典型应用场景
- 数据私有:如上面的输入记忆功能、模块模式(封装工具库);
- 函数记忆:缓存计算结果(比如斐波那契数列的记忆化优化);
- 事件绑定:在循环中为元素绑定事件时保留当前循环的值(经典面试题);
三、闭包的 “坑”:内存泄漏?其实是你用错了
刚学闭包时,总听人说 “闭包会导致内存泄漏”。后来查 MDN 才知道:闭包本身不会导致内存泄漏,不合理使用才会。 比如,如果你在全局作用域里创建了一个闭包,且这个闭包一直被引用(比如作为事件监听函数),那么它的作用域就不会被垃圾回收。如果这个作用域里有大量无用数据,才会导致内存占用过高。 举个反面案例:
function badClosure() {
const bigData = new Array(10000).fill('数据'); // 大数组
return function() {
console.log(bigData.length);
};
}
const fn = badClosure();
console.log('结束')
正确做法是:当闭包不再使用时,解除对它的引用(比如将变量设为null
),这样闭包的作用域就会被回收。
function badClosure() {
const bigData = new Array(10000).fill('数据'); // 大数组
return function() {
console.log(bigData.length);
};
}
const fn = badClosure();
fn = null; // 手动解除引用,让垃圾回收机制回收
关键点分析:
-
闭包的作用域保留:
badClosure
函数内部创建了一个大数组bigData
,并返回一个闭包函数。- 闭包会保留其外部词法环境(即
bigData
的引用),因此即使badClosure
执行完毕,bigData
也不会被回收,只要闭包存在。
-
手动解除引用:
- 当执行
fn = null
时,原本由fn
引用的闭包函数失去了所有引用。 - 此时闭包本身成为垃圾回收的候选对象,闭包被回收后,其保留的
bigData
引用也会被释放,最终bigData
被垃圾回收。
- 当执行
四、闭包与 this 指向:最容易懵的组合拳
学闭包时,我发现它经常和this
混在一起考。比如下面这段代码:
const obj = {
name: '对象',
getClosure() {
return function() {
console.log(this.name);
};
}
};
const closure = obj.getClosure();
closure(); // 输出undefined
这里closure
是闭包吗?是的,它定义在getClosure
的作用域里,被返回后在全局执行。但this.name
输出undefined
,是因为this
的指向和闭包无关!
划重点:闭包保存的是词法作用域中的变量,而this
是动态绑定的。
上面的例子中,closure
在全局执行时,this
指向全局对象(浏览器里是window
)。如果window
没有name
属性,就会输出undefined
。
那怎么让this
指向obj
?有两种常见方法:
- 箭头函数(继承定义时的
this
) :
const obj = {
name: '对象',
getClosure() {
return () => {
console.log(this.name); // 输出'对象'
};
}
};
const closure = obj.getClosure();
closure();
箭头函数没有自己的this
,它的this
是定义时外层作用域的this
(这里getClosure
的this
指向obj
)。
- 普通函数手动绑定:
const obj = {
name: '对象',
getClosure() {
const self = this;
return function() {
console.log(self.name); // 输出'对象'
};
}
};
const closure = obj.getClosure();
closure();
显式捕获this
值,转化为闭包可访问的变量self
。
五、闭包面试题:从 “循环绑定事件” 到 “函数柯里化”
闭包是面试高频考点,常见题目有:
题目 1:循环中绑定事件,点击按钮输出当前索引
错误代码:
// HTML:5个按钮,class都是btn
const btns = document.querySelectorAll('.btn');
for (var i = 0; i < btns.length; i++) {
btns[i].addEventListener('click', function() {
console.log(i); // 点击所有按钮都输出5
});
}
原因:var
声明的i
是全局变量,循环体每次迭代修改的是同一个变量,循环结束后i
的值是 5。点击事件触发时,闭包访问的i
已经是 5 了。
正确解法(用闭包保存当前i
的值):
方法1:立即执行函数创建闭包
for (var i = 0; i < btns.length; i++) {
(function(j) { // j是当前循环的i值
btns[i].addEventListener('click', function() {
console.log(j);
});
})(i);
}
方法2:用let声明i
for (let i = 0; i < btns.length; i++) {
btns[i].addEventListener('click', function() {
console.log(i);
});
}
let
声明的i
在每次循环中都是新的变量,相当于为每个事件处理函数创建了独立的闭包。
题目 2:实现一个加法函数,支持add(1)(2)(3)
输出 6
这是典型的柯里化问题,核心是利用闭包保存参数:
function add(a) {
return function(b) {
return function(c) {
return a + b + c;
};
};
}
console.log(add(1)(2)(3)); // 6
执行流程拆解:
-
第一次调用
add(1)
:- 进入
add
函数,参数a=1
存入当前作用域。 - 返回内部函数
function(b)
,该函数的闭包捕获a=1
。 - 此时形成第一个闭包:
{ a: 1, b: undefined }
- 进入
-
第二次调用
(2)
:- 进入
function(b)
,参数b=2
存入作用域。 - 返回内部函数
function(c)
,闭包捕获a=1
和b=2
。 - 形成第二个闭包:
{ a: 1, b: 2, c: undefined }
- 进入
-
第三次调用
(3)
:- 进入
function(c)
,参数c=3
存入作用域。 - 计算
a+b+c=6
,闭包使命结束。
- 进入
进阶柯里化(任意参数)
function curriedAdd(...initialArgs) {
const args = [...initialArgs];
function inner(...newArgs) {
return newArgs.length === 0
? args.reduce((sum, num) => sum + num, 0)
: curriedAdd(...args, ...newArgs);
}
return inner;
}
const add = curriedAdd();
console.log(add(1)(2)(3)()) //6
这里inner
函数通过闭包保存了args
数组,每次调用时将新参数存入数组,直到无参数时计算总和。
六、总结:闭包的正确打开方式
学完闭包,我总结了三个关键点:
- 闭包的本质:函数对其词法作用域的 “记忆”,即使函数在作用域外执行;
- 核心价值:创建私有变量、隔离作用域,避免全局污染;
- 注意事项:合理管理闭包的生命周期(不用时解除引用),避免内存占用过高。
最后我想说的是:刚开始理解闭包时,别被 “词法环境”“作用域链” 这些术语吓退。多写小 demo(比如计数器、输入记忆),观察变量的变化,慢慢就能找到感觉。毕竟,编程不是背定义,而是 “用代码和计算机对话”。闭包,不过是我们和 JS 沟通的一种方式而已。
互动话题:你学闭包时遇到过哪些坑?欢迎在评论区分享你的故事~(悄悄说:我第一次写闭包时,把return
写错位置,导致函数没返回,调试了好长时间😂)