闭包详细解析

闭包详细解析

闭包 (Closure)是一个函数和它在创建时能够访问到的外部变量(即它的词法环境)的组合。

本质上是因为 JavaScript 独特的词法作用域 (Lexical Scoping) 规则:无论一个函数在哪里被调用,它都能访问到它被定义时所在的作用域中的变量。

"我举一个最经典的计数器例子来说明闭包的作用。

javascript 复制代码
function createCounter() {
  let count = 0; // 这是一个外部函数的局部变量

  // 下面这个返回的函数,就是一个闭包
  return function() {
    count++;
    return count;
  };
}

const counter1 = createCounter();
console.log(counter1()); // 输出: 1
console.log(counter1()); // 输出: 2

const counter2 = createCounter();
console.log(counter2()); // 输出: 1

在这个例子中,createCounter 函数返回了一个内部的匿名函数。这个匿名函数就是闭包。

闭包常见的用法

1. 数据封装与私有变量 (Data Encapsulation & Private Variables)

这是闭包最经典、最基础的应用,它允许我们在 JavaScript 中模拟**"私有变量"**,实现面向对象编程中的封装特性。

  • 场景:你需要创建一个模块或对象,它内部有一些状态(变量),但你不希望这些状态被外部随意修改,而是希望通过你提供的特定方法来访问和操作。

  • 实现:使用一个立即执行函数表达式 (IIFE) 创建一个独立的作用域,将私有变量和方法包裹在其中,只暴露一个或多个公共方法(闭包)给外部。

  • 代码示例 (一个简单的模块)

    javascript 复制代码
    const myModule = (function() {
      // --- 私有作用域 ---
      let privateCounter = 0; // 这是一个私有变量,外部无法访问
      const privateMessage = "Hello, this is private.";
    
      function privateIncrement() {
        privateCounter++;
      }
    
      // --- 暴露给外部的公共 API ---
      return {
        // publicIncrement 是一个闭包,它可以访问 privateCounter 和 privateIncrement
        publicIncrement: function() {
          privateIncrement();
        },
        // publicGetValue 也是一个闭包
        publicGetValue: function() {
          return privateCounter;
        }
      };
    })();
    
    myModule.publicIncrement();
    myModule.publicIncrement();
    console.log(myModule.publicGetValue()); // 输出: 2
    
    console.log(myModule.privateCounter); // 输出: undefined (无法直接访问)
  • 价值 :在 ES6 class 和模块化标准普及之前,这是实现前端模块化的唯一标准方式。即使在今天,这种思想在封装复杂逻辑时仍然非常有用。

2. 函数柯里化与高阶函数 (Currying & Higher-Order Functions)

闭包是实现函数柯里化(将一个多参数函数转换成一系列单参数函数)和创建高阶函数的基础。

  • 场景:你需要创建一个"定制化"的函数。比如,一个通用的 check 函数,你想根据不同的正则表达式,生成一系列专用的验证函数,如 isEmail, isPhoneNumber。

  • 实现:创建一个外部函数,它接收一部分参数(比如正则表达式),然后返回一个内部函数(闭包)。这个内部函数会使用外部函数传入的参数来完成最终的工作。

  • 代码示例 (函数工厂)

    codeJavaScript

    javascript 复制代码
    function createValidator(regex) {
      // 返回的这个函数就是一个闭包,它"记住"了传入的 regex
      return function(value) {
        return regex.test(value);
      };
    }
    
    const isEmail = createValidator(/^\S+@\S+.\S+$/);
    const isPhoneNumber = createValidator(/^\d{11}$/);
    
    console.log(isEmail('test@example.com')); // 输出: true
    console.log(isEmail('invalid-email'));    // 输出: false
    console.log(isPhoneNumber('12345678901')); // 输出: true
  • 价值 :极大地提高了代码的复用性灵活性。你可以创建出功能强大、可配置的工具函数。


3. 防抖 (Debounce) 和 节流 (Throttle)

这两个前端性能优化的核心工具,其内部实现都严重依赖于闭包。

  • 场景

    • 防抖:在用户频繁触发一个事件时(比如 resize, input),只在最后一次触发后的指定时间内执行一次回调。常用于搜索框的自动完成。
    • 节流:在用户频繁触发一个事件时(比如 scroll),确保回调函数在指定的时间间隔内最多只执行一次。常用于无限滚动加载。
  • 实现 :闭包在这里用来保存一个"状态" ------通常是一个定时器 ID (timeoutId) 或一个时间戳 (lastExecutionTime)

  • 代码示例 (简化的防抖)

    codeJavaScript

    javascript 复制代码
    function debounce(func, delay) {
      let timeoutId; // 这个变量被闭包"记住"了
    
      // 返回的这个函数是闭包
      return function(...args) {
        // `this` 和 `args` 需要被正确地传递
        const context = this; 
    
        // 每次触发,都先清除之前的定时器
        clearTimeout(timeoutId);
    
        // 然后设置一个新的定时器
        timeoutId = setTimeout(() => {
          func.apply(context, args);
        }, delay);
      };
    }
    
    // 使用
    const handleInput = (event) => {
      console.log("Fetching suggestions for:", event.target.value);
    };
    
    const debouncedInputHandler = debounce(handleInput, 500);
    
    document.getElementById('search-box').addEventListener('input', debouncedInputHandler);
  • 价值:没有闭包来持久化 timeoutId,防抖和节流根本无法实现

闭包可能带来内存泄露

1. 问题的本质
  • 核心原因 :闭包的"记忆"特性,源于它会持续引用其外部函数的作用域。
  • 垃圾回收 (GC) 的困境:JavaScript 的垃圾回收机制会定期清理那些"不再被需要"的内存。判断一个对象是否"被需要",通常是看它是否还能从根对象(如 window)被访问到。
  • 闭包如何导致泄漏 :如果一个闭包本身 被一个生命周期很长 的对象(如全局变量、DOM 元素、未清除的定时器)所引用,那么这个闭包就无法被回收 。而这个闭包又引用着它庞大的外部作用域,导致那个作用域以及其中的所有变量也都无法被回收,即使它们在逻辑上已经不再需要了。

内存泄露的常见情况

1、未清除的定时器 / 回调引用(setInterval、setTimeout、requestAnimationFrame)

成因 :忘记 clearInterval/clearTimeout/cancelAnimationFrame,导致闭包一直持有引用。
修复 :在不需要时明确取消;组件卸载时清理;用 once 或条件停止。

2. DOM 节点被移除但JS仍持有引用(Detached DOM nodes)

成因 :把 DOM 节点缓存到 JS 变量或闭包中,但节点已从 DOM 树移除,GC 无法回收。尤其常见于事件监听器没移除。
示例

ini 复制代码
const node = document.getElementById('big');
document.body.removeChild(node);
// 但变量 node 或事件处理器仍引用它

检测 :内存快照中看到大量 Detached DOM nodes。
修复 :移除节点前先 removeEventListener,避免把节点长期保存在全局结构中;使用弱引用(WeakRef)或 WeakMap 存储与节点相关的元数据。


3. 事件监听器未移除

成因 :在元素或全局对象上注册事件但没有在销毁时移除,导致回调函数和其闭包无法释放。
示例

arduino 复制代码
element.addEventListener('click', handler);
// 元素被移除,但 handler 仍在 window/某对象引用链上

检测 :堆快照、Timeline 中持续增长的回调函数引用。
修复 :组件/模块卸载时同步 removeEventListener,或使用委托(事件委托能减少监听器数量)。

4. 缓存 / Map / Set 无限制增长

成因 :缓存没有淘汰策略,或者 key 是普通对象导致强引用。
示例

scss 复制代码
const cache = new Map();
function get(k) {
  if (!cache.has(k)) cache.set(k, compute(k)); // 永远保存
  return cache.get(k);
}

检测 :内存随使用增长且不下降;检查缓存大小。
修复 :使用 LRU、TTL、最大容量;对于以对象为键的缓存,优先使用 WeakMap/WeakSet(它们不会阻止 key 被回收)。

5. 未释放的外部资源(WebSocket、Worker、FileHandles 等)

成因 :网络连接、Worker、媒体流等未关闭或未终止。
示例

arduino 复制代码
const ws = new WebSocket(url);
// 页面卸载或不再需要时没有 ws.close()

检测 :Network/Threads/Workers 面板显示活动连接,或内存/线程泄露。
修复 :显式 close / terminate,在卸载时清理

节流 防抖

防抖的概念

在事件被触发 n 秒后再执行回调,如果在这 n 秒内事件又被触发,则重新计时。

节流 (Throttle) - 技能冷却
  • 比喻:想象一下你在玩一个游戏,你有一个大招,它的"技能冷却时间"是 10 秒。

    • 规则 :你释放了一次大招(执行函数)后,这个技能会立即进入冷却
    • 结果 :在接下来的 10 秒冷却时间内 ,无论你多疯狂地按技能键(触发事件),技能都不会被再次释放。只有等冷却时间结束后,你才能释放下一次大招。
  • 技术定义在 n 秒的时间间隔内,事件处理函数最多只执行一次。

javascript 复制代码
/**
 * 防抖函数 (Debounce)
 * @param {Function} func - 需要进行防抖处理的函数。
 * @param {number} delay - 延迟时间,单位毫秒。
 * @returns {Function} - 返回一个新的防抖函数。
 */
function debounce(func, delay) {
  // 1. 利用闭包来保存一个定时器 ID
  let timeoutId = null;

  // 2. 返回一个新的函数,这个函数就是我们实际绑定给事件的函数
  return function(...args) {
    // 3. 保存 `this` 上下文
    const context = this;

    // 4. 如果之前已经设置了定时器,就先清除掉它
    // 这就是"重置计时器"的核心
    if (timeoutId) {
      clearTimeout(timeoutId);
    }

    // 5. 设置一个新的定时器
    timeoutId = setTimeout(() => {
      // 6. 当定时器触发时,调用原始函数
      // 并确保 `this` 指向和参数都正确传递
      func.apply(context, args);
    }, delay);
  };
}

// --- 使用示例 ---
// const debouncedSearch = debounce((query) => {
//   console.log(`Searching for: ${query}`);
// }, 500);

// const inputElement = document.getElementById('search-input');
// inputElement.addEventListener('input', (event) => {
//   debouncedSearch(event.target.value);
// });
javascript 复制代码
/**
 * 节流函数 (Throttle) - 定时器版
 * @param {Function} func - 需要进行节流处理的函数。
 * @param {number} delay - 时间间隔,单位毫秒。
 * @returns {Function} - 返回一个新的节流函数。
 */
function throttle(func, delay) {
  // 1. 利用闭包保存一个"开关"状态
  let timer = null;

  // 2. 返回一个新的函数
  return function(...args) {
    const context = this;

    // 3. 如果"开关"是开着的(即 timer 存在),说明还在冷却中,直接返回
    if (timer) {
      return;
    }

    // 4. 如果"开关"是关着的,就执行操作
    timer = setTimeout(() => {
      // 5. 执行原始函数
      func.apply(context, args);
      
      // 6. 执行完毕后,重置"开关",以便下一次可以进入
      timer = null;
    }, delay);
  };
}

// --- 使用示例 ---
// const throttledScroll = throttle(() => {
//   console.log('Window is scrolling!');
// }, 1000);

// window.addEventListener('scroll', throttledScroll);

三、应用场景:什么时候用防抖,什么时候用节流?

使用防抖 (Debounce) 的场景:

核心:你只关心最终的结果,中间过程可以被忽略。

  • 搜索框输入 (Search Box Input) :用户在输入框中连续输入时,我们不希望每输入一个字母就去请求一次 API。我们只希望在用户停止输入一小段时间后,才去发起搜索请求。
  • 窗口大小调整 (Window Resize) :当用户拖拽调整浏览器窗口大小时,resize 事件会高频触发。我们通常只关心用户调整完毕后的最终窗口尺寸,然后进行一次重新布局计算。
  • 表单验证:当用户在输入框中填写内容时,不需要实时验证,而是在他输入暂停时再进行验证,减轻校验压力。
使用节流 (Throttle) 的场景:

核心:你希望在一个时间段内,有规律地、稀疏地执行某个操作,中间过程很重要。

  • 页面滚动 (Scroll Events)

    • 无限滚动加载 :当用户滚动页面时,我们不需要在滚动的每一像素都去检查是否到达底部,而是可以每隔 200ms 检查一次。
    • 滚动位置的计算:比如实现一个"返回顶部"按钮,在滚动一定距离后才显示。
  • DOM 元素拖拽 (Drag Events) :在拖拽一个元素时,mousemove 事件会高频触发。我们不需要在每一像素的移动都去更新元素的位置,而是可以每隔 16ms (大约 60fps) 更新一次,就能实现流畅的动画效果。

  • 游戏中的射击:用户按住射击键,子弹应该以一个固定的频率(比如每秒 10 发)射出,而不是无限快。

总结

防抖 (Debounce) 节流 (Throttle)
核心思想 延迟执行 ,并且在延迟期间如果再次触发,则重置计时 在一个固定的时间间隔 内,只允许执行一次
关注点 最后一次操作。 第一次操作(在每个时间段内)。
比喻 电梯关门 技能冷却
应用场景 搜索框、窗口缩放 滚动加载、DOM 拖拽
相关推荐
孟陬1 天前
AI 每日心得——AI 是效率杠杆,而非培养对象
前端
漆黑骑士1 天前
Web Component
前端
San301 天前
深入理解 JavaScript 事件机制:从事件流到事件委托
前端·javascript·ecmascript 6
行走在顶尖1 天前
基础随记
前端
Sakura_洁1 天前
解决 el-table 在 fixed 状态下获取 dom 不准确的问题
前端
best6661 天前
Vue3什么时候不会触发onMounted生命周期钩子?
前端·vue.js
best6661 天前
Javascript有哪些遍历数组的方法?哪些不支持中断?那些不支持异步遍历?
前端·javascript·面试
特级业务专家1 天前
Chrome DevTools 高级调试技巧:从入门到真香
前端·javascript·浏览器
爱学习的程序媛1 天前
【Web前端】Angular核心知识点梳理
前端·javascript·typescript·angular.js
小时前端1 天前
前端架构师视角:如何设计一个“站稳多端”的跨端体系?
前端·javascript·面试