为什么别人用闭包那么溜?这 8 个场景照着用就对了

本篇用 8 个实战场景说清楚:从类的私有属性到防抖节流,从事件监听 to 函数柯里化。每个场景都有代码,还有我踩过的坑,保证全是大白话。看完就知道,闭包没那么难。

一、类的封装

刚学面向对象时,我想给类加私有属性,发现 JavaScript 里居然没有private关键字。后来才知道,用闭包就能实现类似的效果。

比如我写了个TodoList类,想让todos数组只能通过方法访问,不能被外部直接修改:

js 复制代码
function createTodoList() {
  // 这个变量就是"私有属性",外部访问不到
  let todos = [];
  // return 返回的是公开方法
  return {
    
    add: function(text) {
      if (text.trim()) {
        todos.push({ id: Date.now(), text, done: false });
      }
    },
   
    getAll: function() {
      return [...todos];
    },
   
    toggle: function(id) {
      todos = todos.map(todo => 
        todo.id === id ? { ...todo, done: !todo.done } : todo
      );
    }
  };
}
// 使用
const todoList = createTodoList();
todoList.add('学习闭包');
console.log(todoList.getAll()); // 能获取到数据
// 尝试直接访问私有属性
console.log(todoList.todos); // undefined,访问不到!

这里的todos变量就是通过闭包保护起来的:

  • 外部无法直接修改todos,只能通过返回对象上的add、toggle等方法操作,保证了数据安全

  • 这些方法即使在外部调用,也能记住并访问todos变量,这就是闭包的作用

我之前踩过一个坑,一开始在返回的对象里直接暴露了getTodos: () => todos,结果外部通过todoList.getTodos().push(...)就能修改原数组。后来改成返回副本[...todos]才解决,这也算是封装的细节吧。

二、防抖

做搜索框时遇到个问题:用户输入还没完成,搜索请求就发出去了,既浪费资源又影响体验。这时候防抖就能派上用场 ------让函数在事件停止触发后延迟 n 秒执行,如果 n 秒内再次触发,则重新计时

防抖的核心就是用闭包保存定时器 ID:

js 复制代码
function debounce(fn, delay) {
  // 用闭包保存定时器ID
  let timer = null;
  return function(...args) {
    if (timer) clearTimeout(timer);
    timer = setTimeout(() => {
      fn.apply(this, args);
      timer = null;
    }, delay);
  };
}
// 搜索框输入
const searchInput = document.getElementById('search');
function handleSearch(value) {
  console.log('搜索:', value);
}
// 包装成防抖函数,输入停止300ms后才执行
searchInput.addEventListener('input', debounce(function(e) {
  handleSearch(e.target.value);
}, 300));

这里的timer变量被闭包保存着,每次触发事件时:

  • 如果 300ms 内再次输入,就会清除上一个定时器,重新计时

  • 只有停止输入 300ms 后,才会执行handleSearch

我试过把delay设成 0,结果发现还是有效果 ------ 能让事件在当前同步代码执行完后再执行,解决了一些 DOM 更新时机的问题。

三、节流

和防抖不同,节流是让函数在指定时间内只执行一次,比如滚动事件、 resize 事件,太频繁触发会导致页面卡顿。

同样用闭包保存状态:

js 复制代码
function throttle(fn, interval) {
  // 记录上次执行时间
  let lastTime = 0;
  return function(...args) {
    const now = Date.now();
    if (now - lastTime >= interval) {
      fn.apply(this, args);
      lastTime = now;
    }
  };
}
// 监听滚动
window.addEventListener('scroll', throttle(function() {
  console.log('滚动位置:', window.scrollY);
}, 100)); // 每100ms最多执行一次

这里的lastTime被闭包保存,每次滚动时:

  • 计算当前时间和上次执行时间的差

  • 只有超过 100ms 才会执行,保证了函数不会执行太频繁

从浏览器控制台看打印结果可以看到,不加节流时,滚动一次页面可能触发几十次事件;加了 100ms 的节流后,最多触发 10 次,大大减轻了性能压力。防抖和节流的区别可以简单记:防抖是 "最后一次才执行",节流是 "每隔一段时间必执行一次"

四、绑定上下文

刚学事件监听时,我经常遇到this指向错误的问题。比如在类里写了个方法,绑定到按钮点击事件上,结果this变成了按钮元素,不是类实例。

这时候可以用闭包固定上下文,解决this指向问题:

js 复制代码
class User {
  constructor(name) {
    this.name = name;
    // 绑定this
    this.showName = this.bindThis(this.showName);
  }
  // 用闭包绑定上下文的方法
  bindThis(fn) {
    const that = this; 
    return function(...args) {
      return fn.apply(that, args);
    };
  }
  showName() {
    console.log('姓名:', this.name);
  }
}
const user = new User('张三');
const btn = document.getElementById('btn');
btn.addEventListener('click', user.showName); // 张三

这里的bindThis方法返回了一个闭包,保存了that(即类实例),无论在什么上下文调用,fn都会以that作为this执行。

除了这种方式,还可以用箭头函数(本身没有 this,会继承外层 this)或Function.prototype.bind,但本质上都是通过闭包或类似机制保存了上下文。我个人在类里初始化时用bind比较多,代码更清晰。

五、事件监听器

给 DOM 元素绑定事件时,如果用匿名函数,后续想移除监听就麻烦了 ------ 因为找不到函数引用。这时候可以用闭包保存事件处理函数:

js 复制代码
function createEventListener() {
  // 用闭包保存事件处理函数
  function handleClick(e) {
    console.log('点击了:', e.target);
  }
  return {
    // 绑定事件
    add: function(element) {
      element.addEventListener('click', handleClick);
    },
    // 移除事件
    remove: function(element) {
      element.removeEventListener('click', handleClick);
    }
  };
}
const listener = createEventListener();
const btn = document.getElementById('btn');
// 绑定事件
listener.add(btn);
// 不需要时移除
setTimeout(() => {
  listener.remove(btn);
  console.log('已移除点击事件');
}, 5000);

这里的handleClick被闭包保存着,add和remove方法都能访问到它,保证了移除事件时能找到正确的函数引用。

六、记忆函数

如果有一个计算量大的函数,多次调用时可以用闭包缓存结果,避免重复计算。这种函数叫记忆函数(memoization)。

比如计算斐波那契数列:

js 复制代码
function createMemoFn(fn) {
  // 用闭包缓存计算结果
  const cache = {};
  return function(n) {
    if (cache[n] !== undefined) {
      console.log(`从缓存获取${n}的结果`);
      return cache[n];
    }
    const result = fn(n);
    cache[n] = result;
    console.log(`计算并缓存${n}的结果`);
    return result;
  };
}
// 计算斐波那契数列(递归,计算量大)
function fibonacci(n) {
  if (n <= 1) return n;
  return fibonacci(n - 1) + fibonacci(n - 2);
}
// 创建记忆函数
const memoFib = createMemoFn(fibonacci);
// 第一次计算,会缓存
console.log(memoFib(10)); // 55
// 第二次计算,直接用缓存
console.log(memoFib(10)); // 55
// 计算新值,会缓存
console.log(memoFib(11)); // 89

这里的cache对象被闭包保存,每次调用memoFib时:

  • 先查缓存,如果有结果直接返回

  • 没有就计算并存入缓存,下次再用

我测试过,计算memoFib(30)时,普通fibonacci要算几十万个节点,而记忆函数只需算 30 次,性能提升天差地别。不过这种方法适合参数有限且重复调用的场景,不然缓存会无限增大。

七、函数柯里化

柯里化(Currying)是把接收多个参数的函数,变成一系列接收单个参数的函数。比如add(1,2,3)变成add(1)(2)(3),这也需要闭包来保存中间参数。

举个例子:

js 复制代码
// 柯里化函数
function curry(fn) {
  // 保存已接收的参数
  const args = [];
  // 返回一个新函数,接收剩余参数
  return function temp(...newArgs) {
    args.push(...newArgs);
    // 如果参数数量够了,就执行原函数
    if (args.length >= fn.length) {
      return fn.apply(this, args);
    } else {
      return temp;
    }
  };
}
// 原函数计算三个数的和
function add(a, b, c) {
  return a + b + c;
}
// 柯里化后
const curriedAdd = curry(add);
// 可以分多次传参
console.log(curriedAdd(1)(2)(3)); // 6
console.log(curriedAdd(1, 2)(3)); // 6
console.log(curriedAdd(1)(2, 3)); // 6

这里的args数组被闭包保存,每次调用temp时:

  • 收集传入的参数

  • 如果参数够了(达到原函数add的参数长度 3),就执行并返回结果

  • 不够就返回temp函数,继续接收参数

柯里化的好处是可以 "分步传参",在一些场景下很有用。比如做表单验证时,可以先固定验证规则,再传入不同的输入值。

八、立即执行函数

最后说个简单但常用的 ------ 立即执行函数(IIFE),它能创建一个独立作用域,避免污染全局变量,本质上也是利用了闭包的特性。

js 复制代码
// 立即执行函数,执行后销毁
(function() {
  // 局部变量,不会污染全局
  const message = 'Hello';
  console.log(message); // Hello
})();
// 全局访问不到内部变量
console.log(typeof message); // undefined
const module = (function() {
  const privateData = '秘密';
  return {
    getPrivateData: function() {
      return privateData; // 闭包访问私有变量
    }
  };
})();
console.log(module.getPrivateData()); // 秘密

在以前IIFE 是避免全局污染的主要方式。现在虽然有了 ES6 Module,但理解 IIFE 有助于理解闭包和作用域。

最后

说了这么多场景,其实闭包的本质很简单:函数在定义的作用域之外执行时,依然能访问定义时的作用域里的变量

但使用闭包要注意:

  1. 内存泄漏风险:闭包会让变量一直被引用,不会被垃圾回收。比如在 DOM 元素上绑定事件处理函数,如果元素被移除但事件没解绑,闭包会导致元素无法回收。解决方法是及时移除事件监听,或在不需要时手动解除引用。

  2. 性能影响:过度使用闭包可能会增加内存占用,复杂场景下要权衡。

  3. 调试难度:闭包会让变量作用域变得复杂,调试时可能不知道变量被哪个闭包修改了。我一般会在关键地方加console.log,追踪变量变化。

希望这篇文章能帮你像我一样,从 "一脸懵" 到 "熟练用",真正理解闭包的价值。如果有哪里说的不对,欢迎评论区指正,一起进步!

相关推荐
贰月不是腻月14 分钟前
凭什么说我是邪修?
前端
中等生17 分钟前
一文搞懂 JavaScript 原型和原型链
前端·javascript
前端李二牛18 分钟前
现代化图片组件设计思路与实现方案
前端·html
黑椒牛肉焖饭19 分钟前
web第一次作业
前端·javascript·html
程序员清风27 分钟前
Context7 MCP,让Cursor告别代码幻觉!
java·后端·面试
一枚前端小能手36 分钟前
Vue3 开发中的5个实用小技巧
前端
Sawtone36 分钟前
shadcn/ui:我到底是不是组件库啊😭图文 + 多个场景案例详解 shadcn + tailwind 颠覆性组件开发,小伙伴直呼高端
前端·面试
柏成36 分钟前
qiankun 微前端框架🐳
前端·javascript·vue.js
Sherry00740 分钟前
终极指南:彻底搞懂 React 的 useMemo 和 useCallback!(译)
前端·react.js
穷小白1 小时前
Vue3与Ue通信
前端·javascript