为什么别人用闭包那么溜?这 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,追踪变量变化。

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

相关推荐
柚子8162 分钟前
scroll-marker轮播组件不再难
前端·css
你的人类朋友42 分钟前
🫏光速入门cURL
前端·后端·程序员
01传说1 小时前
vue3 配置安装 pnpm 报错 已解决
java·前端·vue.js·前端框架·npm·node.js
小李飞飞砖1 小时前
React Native 组件间通信方式详解
javascript·react native·react.js
小李飞飞砖1 小时前
React Native 状态管理方案全面对比
javascript·react native·react.js
烛阴2 小时前
Python装饰器解除:如何让被装饰的函数重获自由?
前端·python
千鼎数字孪生-可视化2 小时前
Web技术栈重塑HMI开发:HTML5+WebGL的轻量化实践路径
前端·html5·webgl
凌辰揽月2 小时前
7月10号总结 (1)
前端·css·css3
天天扭码3 小时前
很全面的前端面试——CSS篇(上)
前端·css·面试
EndingCoder3 小时前
搜索算法在前端的实践
前端·算法·性能优化·状态模式·搜索算法