JavaScript学习笔记:17.闭包
上一篇用模块搞定了代码的“部门分工”,这一篇咱们来攻克JS中“既神秘又核心”的概念——闭包(Closures)。你可能听过“闭包能保留变量”“闭包会导致内存泄漏”,但始终没搞懂它到底是什么;也可能在写防抖节流、私有变量时无意中用过它,却不知道背后的原理。
其实闭包一点都不玄乎,它就像函数的“带钥匙管家”:外部函数(相当于“主人”)执行完后,作用域本应“关门大吉”,但内部函数(“管家”)偷偷揣着“钥匙”(引用外部函数的变量),即使主人走了,管家还能随时打开门,访问里面的“财物”(变量)。今天咱们就用“房间与钥匙”的生活化比喻,把闭包的形成原理、实战用法、优缺点和避坑指南彻底讲透,让你从“模糊感知”到“熟练运用”。
一、先破案:为什么需要闭包?普通函数的痛点
普通函数执行完后,其作用域会被垃圾回收机制销毁,里面的变量也会随之消失,就像主人出门后,房间被清空,再回来啥都没了。但开发中经常需要“保留函数执行后的状态”,比如:
- 计数器:多次调用函数,累加同一个变量(不是每次都从0开始);
- 私有变量:不想让外部直接访问的变量,只能通过特定方法操作;
- 防抖节流:需要记住上一次执行的时间,判断是否触发下一次。
这些场景普通函数搞不定,而闭包能完美解决——它能让函数执行后,作用域不被销毁,变量被“偷偷保留”下来。
看个直观例子:普通函数vs闭包函数
// 普通函数:执行后变量消失,无法累加
function normalCounter() {
let count = 0;
count++;
return count;
}
console.log(normalCounter()); // 1
console.log(normalCounter()); // 1(count每次都重新初始化,无法累加)
// 闭包函数:执行后变量被保留,实现累加
function closureCounter() {
let count = 0; // 外部函数变量
// 内部函数:引用外部函数的count
return function() {
count++;
return count;
};
}
const counter = closureCounter(); // 外部函数执行,返回内部函数(管家带钥匙出门)
console.log(counter()); // 1(管家开门,count=1)
console.log(counter()); // 2(管家再开门,count=2)
console.log(counter()); // 3(变量被持续保留)
核心差异:普通函数的变量随作用域销毁,闭包的变量被内部函数引用,作用域不销毁,变量持续可用。
二、闭包的核心原理:什么是闭包?怎么形成的?
1. 闭包的定义(MDN官方)
闭包是指一个函数以及其捆绑的周边环境状态(lexical environment,词法环境)的引用的组合。简单说,闭包让内部函数可以访问外部函数的作用域,即使外部函数已经执行完毕。
用“房间比喻”翻译:
- 外部函数 = 主人的房间;
- 外部函数变量 = 房间里的财物;
- 内部函数 = 带钥匙的管家;
- 闭包 = 管家+钥匙+房间的组合(即使主人走了,管家还能开门取财物)。
2. 闭包的形成条件(缺一不可)
闭包不是“写个嵌套函数就是闭包”,必须满足三个条件:
- 函数嵌套:存在内部函数和外部函数(嵌套关系);
- 变量引用:内部函数引用了外部函数的变量/参数(管家拿了钥匙);
- 外部暴露:外部函数执行后,内部函数被外部引用(管家出门了,没被留在房间里)。
// 满足三个条件:闭包形成
function outer() {
const outerVar = "我是外部变量"; // 外部函数变量
function inner() {
console.log(outerVar); // 内部函数引用外部变量(条件2)
}
return inner; // 外部暴露内部函数(条件3)
}
const innerFunc = outer(); // 外部函数执行,内部函数被外部引用
innerFunc(); // 打印"我是外部变量"(闭包生效)
// 不满足条件3:无闭包(内部函数没被外部引用)
function outer2() {
const outerVar = "我是外部变量";
function inner2() {
console.log(outerVar);
}
inner2(); // 内部函数在外部函数内执行,没被外部引用
}
outer2(); // 执行后outer2作用域销毁,无闭包
3. 闭包的执行过程:为什么变量能保留?
咱们拆解closureCounter的执行过程,看懂闭包的“魔法”:
- 调用
closureCounter()(外部函数执行):- 创建外部函数作用域,声明
count=0; - 外部函数返回内部函数(此时内部函数引用了
count); - 外部函数执行完毕,但因为内部函数还引用着它的变量,作用域不会被垃圾回收(房间没被清空)。
- 创建外部函数作用域,声明
- 调用
counter()(内部函数执行):- 内部函数执行时,先在自己的作用域找
count,找不到; - 顺着作用域链,找到外部函数的作用域(因为闭包保留了引用);
- 访问并修改
count,实现累加。
- 内部函数执行时,先在自己的作用域找
- 多次调用
counter():重复步骤2,count持续被保留和修改。
简单说:闭包的本质是“作用域链的延长”——内部函数的作用域链,永远保留着对外部函数作用域的引用,即使外部函数执行完。
三、闭包的实战场景:这些功能离不开闭包
闭包不是“炫技工具”,而是很多核心功能的底层实现,以下三个场景几乎是前端开发的“必备技能”。
1. 场景1:实现私有变量(隐藏内部状态)
JS没有原生的私有变量(ES6的#私有字段是后来加的),闭包是传统实现“私有变量”的唯一方式——让变量只能通过特定方法访问,不能被外部直接修改,保证数据安全。
// 用闭包实现“安全的银行账户”(私有变量:余额)
function createBankAccount(initialBalance) {
// 私有变量:外部无法直接访问
let balance = initialBalance;
// 暴露对外接口(闭包函数)
return {
deposit(amount) { // 存款:只能通过这个方法修改余额
if (amount > 0) {
balance += amount;
return `存款成功,余额:${balance}`;
}
return "存款金额无效";
},
withdraw(amount) { // 取款:只能通过这个方法修改余额
if (amount > 0 && amount <= balance) {
balance -= amount;
return `取款成功,余额:${balance}`;
}
return "取款金额无效";
},
getBalance() { // 查询余额:只能通过这个方法访问余额
return `当前余额:${balance}`;
}
};
}
// 使用账户
const account = createBankAccount(1000);
console.log(account.getBalance()); // "当前余额:1000"
console.log(account.deposit(500)); // "存款成功,余额:1500"
console.log(account.withdraw(300)); // "取款成功,余额:1200"
// 外部无法直接访问私有变量
console.log(account.balance); // undefined(无法直接访问)
account.balance = 100000; // 无效,修改的是对象的新属性,不是闭包中的balance
console.log(account.getBalance()); // "当前余额:1200"(余额未变)
2. 场景2:防抖与节流(前端性能优化神器)
防抖(debounce)和节流(throttle)是解决“高频事件触发”(如滚动、输入、点击)的核心方案,它们的底层都依赖闭包保留“上次执行时间”“计时器ID”等状态。
(1)防抖:触发后延迟n秒执行,期间再次触发则重新计时
// 防抖函数(闭包实现)
function debounce(fn, delay) {
let timerId; // 闭包保留计时器ID
return function(...args) {
// 再次触发时,清除之前的计时器
clearTimeout(timerId);
// 重新设置计时器,延迟执行
timerId = setTimeout(() => {
fn.apply(this, args); // 执行原函数
}, delay);
};
}
// 用法:输入框搜索,避免输入时频繁请求接口
const searchInput = document.querySelector('input');
function search(keyword) {
console.log(`搜索:${keyword}`);
// 实际开发中这里是接口请求
}
// 给搜索函数加防抖,延迟500ms
const debouncedSearch = debounce(search, 500);
searchInput.addEventListener('input', (e) => {
debouncedSearch(e.target.value);
});
闭包在这里的作用:保留timerId,让每次触发时都能访问到上一次的计时器,实现“清除旧计时器、设置新计时器”。
(2)节流:n秒内只执行一次,避免频繁触发
// 节流函数(闭包实现)
function throttle(fn, interval) {
let lastTime = 0; // 闭包保留上次执行时间
return function(...args) {
const currentTime = Date.now();
// 距离上次执行时间超过interval,才执行
if (currentTime - lastTime >= interval) {
fn.apply(this, args);
lastTime = currentTime; // 更新上次执行时间
}
};
}
// 用法:滚动事件,避免滚动时频繁触发
function handleScroll() {
console.log('滚动触发');
// 实际开发中这里是滚动加载、位置计算等
}
// 给滚动函数加节流,1秒内只执行一次
const throttledScroll = throttle(handleScroll, 1000);
window.addEventListener('scroll', throttledScroll);
3. 场景3:函数工厂(批量生成带状态的函数)
用闭包批量生成具有相同逻辑但不同状态的函数,比如批量生成“带固定前缀的日志函数”“带特定权限的接口请求函数”。
// 函数工厂:生成带固定前缀的日志函数
function createLogger(prefix) {
// 闭包保留前缀
return function(message) {
const time = new Date().toLocaleTimeString();
console.log(`[${prefix}][${time}] ${message}`);
};
}
// 批量生成不同类型的日志函数
const infoLogger = createLogger('INFO');
const errorLogger = createLogger('ERROR');
const warnLogger = createLogger('WARN');
// 使用
infoLogger('用户登录成功'); // [INFO][14:30:00] 用户登录成功
errorLogger('接口请求失败'); // [ERROR][14:30:05] 接口请求失败
warnLogger('参数格式错误'); // [WARN][14:30:10] 参数格式错误
每个日志函数都保留着自己的prefix,逻辑相同但状态独立,这就是闭包的“状态隔离”能力。
四、闭包的优缺点:一把“双刃剑”
闭包很强大,但不是万能的,它是一把“双刃剑”,有优点也有需要警惕的缺点。
1. 优点
- 保留状态:函数执行后保留变量,实现累加、缓存等功能;
- 数据私有:隐藏内部变量,只暴露指定接口,保证数据安全;
- 逻辑复用:批量生成带状态的函数,减少重复代码。
2. 缺点(重点避坑)
- 内存泄漏风险:闭包引用的外部函数变量不会被垃圾回收,若长期不释放,会占用额外内存,严重时导致内存泄漏;
- 调试困难:闭包延长了作用域链,变量的生命周期变得复杂,调试时难以追踪变量的修改路径。
五、避坑指南:正确使用闭包,避免踩雷
1. 避坑1:避免不必要的闭包,及时释放引用
不需要保留状态时,不要用闭包;闭包不用时,手动解除引用,让垃圾回收机制回收变量:
// 反面例子:长期持有闭包,不释放
const counter = closureCounter();
// ... 不再使用counter,但没有解除引用
// 闭包中的count会一直存在,占用内存
// 正面例子:不用时解除引用
let counter = closureCounter();
counter(); // 使用
counter = null; // 解除引用,闭包中的变量会被垃圾回收
2. 避坑2:循环中的闭包陷阱(经典面试题)
这是闭包最经典的坑:用var声明循环变量,闭包会引用同一个变量,导致所有内部函数执行时拿到的都是最后一个值。
// 反面例子:循环中的闭包陷阱
const arr = [];
for (var i = 0; i < 3; i++) {
arr.push(function() {
console.log(i); // 内部函数引用循环变量i(var声明,全局作用域)
});
}
// 执行数组中的函数
arr[0](); // 3
arr[1](); // 3
arr[2](); // 3(所有函数拿到的都是i的最终值3)
原因:var声明的i是全局变量,循环中所有内部函数引用的都是同一个i,循环结束后i=3,所以执行时都打印3。
解决方案:
- 用
let声明循环变量(let有块级作用域,每次循环都会创建新的i); - 用立即执行函数(IIFE)创建独立作用域。
// 解决方案1:用let声明(推荐)
const arr = [];
for (let i = 0; i < 3; i++) {
arr.push(function() {
console.log(i); // 每个函数引用的是当前循环的i(块级作用域)
});
}
arr[0](); // 0
arr[1](); // 1
arr[2](); // 2
// 解决方案2:用IIFE(兼容旧环境)
const arr = [];
for (var i = 0; i < 3; i++) {
(function(j) {
arr.push(function() {
console.log(j); // 每个函数引用的是IIFE的参数j(独立作用域)
});
})(i); // 立即执行,传入当前i的值
}
arr[0](); // 0
arr[1](); // 1
arr[2](); // 2
3. 避坑3:闭包不要引用过大的变量
闭包会保留外部函数的整个作用域,而不是只保留引用的变量。如果外部函数有很多大变量(比如大数组、大对象),即使内部函数只引用了一个小变量,整个作用域的变量都会被保留,导致内存浪费。
// 反面例子:闭包引用外部函数的大变量
function outer() {
const bigData = new Array(1000000).fill(0); // 大数组,占用大量内存
const smallVar = "我只需要这个";
return function() {
console.log(smallVar); // 只引用了smallVar,但bigData也会被保留
};
}
// 正面例子:只保留需要的变量,避免引用大变量
function outer() {
const smallVar = "我只需要这个";
// 大变量放在不需要的地方,或手动释放
const bigData = new Array(1000000).fill(0);
bigData = null; // 手动释放大变量
return function() {
console.log(smallVar);
};
}
六、总结:闭包的核心价值与本质
闭包的本质是“作用域链的延长与保留”,核心价值是“让函数突破作用域的限制,保留执行状态”。它不是JS的“特殊功能”,而是作用域和作用域链的自然产物——只要满足“嵌套函数、引用外部变量、外部暴露内部函数”三个条件,闭包就会自动形成。
记住三个核心点:
- 闭包是“带钥匙的管家”:保留外部函数的变量,即使外部函数执行完;
- 闭包的核心用法:私有变量、防抖节流、函数工厂;
- 闭包的使用原则:按需使用,及时释放,避免引用过大变量和循环陷阱。
闭包是JS的“进阶门槛”,掌握它不仅能写出更优雅、更安全的代码,还能理解很多前端框架和工具的底层实现(比如React的hooks、Vue的响应式)。下一篇笔记,咱们会聊JS的“异步进阶”——Async/Await,解锁更简洁的异步编程方式。
1133

被折叠的 条评论
为什么被折叠?



