作为刚学 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 沟通的一种方式而已。
作者:归于尽
链接:https://juejin.cn