JavaScript学习笔记:17.闭包

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. 闭包的形成条件(缺一不可)

闭包不是“写个嵌套函数就是闭包”,必须满足三个条件:

  1. 函数嵌套:存在内部函数和外部函数(嵌套关系);
  2. 变量引用:内部函数引用了外部函数的变量/参数(管家拿了钥匙);
  3. 外部暴露:外部函数执行后,内部函数被外部引用(管家出门了,没被留在房间里)。
// 满足三个条件:闭包形成
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的执行过程,看懂闭包的“魔法”:

  1. 调用closureCounter()(外部函数执行):
    • 创建外部函数作用域,声明count=0
    • 外部函数返回内部函数(此时内部函数引用了count);
    • 外部函数执行完毕,但因为内部函数还引用着它的变量,作用域不会被垃圾回收(房间没被清空)。
  2. 调用counter()(内部函数执行):
    • 内部函数执行时,先在自己的作用域找count,找不到;
    • 顺着作用域链,找到外部函数的作用域(因为闭包保留了引用);
    • 访问并修改count,实现累加。
  3. 多次调用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的“特殊功能”,而是作用域和作用域链的自然产物——只要满足“嵌套函数、引用外部变量、外部暴露内部函数”三个条件,闭包就会自动形成。

记住三个核心点:

  1. 闭包是“带钥匙的管家”:保留外部函数的变量,即使外部函数执行完;
  2. 闭包的核心用法:私有变量、防抖节流、函数工厂;
  3. 闭包的使用原则:按需使用,及时释放,避免引用过大变量和循环陷阱。

闭包是JS的“进阶门槛”,掌握它不仅能写出更优雅、更安全的代码,还能理解很多前端框架和工具的底层实现(比如React的hooks、Vue的响应式)。下一篇笔记,咱们会聊JS的“异步进阶”——Async/Await,解锁更简洁的异步编程方式。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

阿蒙Armon

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值