在前端开发中,我们经常会遇到这种场景:用户疯狂点击“点赞”按钮,或者鼠标疯狂滚动页面。如果不做限制,后台接口可能会被瞬间打爆,浏览器也会卡死。

解决这个问题的标准答案是节流(Throttle)。而在实现节流时,最优雅、最经典的手段就是闭包(Closure)

今天,我们用大白话把这俩概念彻底说透。

一、 什么是节流?一句话:技能冷却

打过游戏的都知道,法师放完一个大招后,技能图标会变成灰色(进入 CD 时间)。在这个时间内,你按烂键盘也放不出第二个大招。必须等 CD 转好,才能再次释放。

节流就是给函数加上“技能冷却”。 在指定的时间内,无论用户触发了多少次事件,函数只执行一次。

二、 最经典的节流代码(一看就懂)

直接上代码,这是面试时闭着眼睛都要能写出来的标准模板:

function throttle(fn, delay) {
  // 1. 定义一个“锁”(定时器)
  let timer = null;

  // 2. 返回一个真正会被事件触发的内部函数
  return function(...args) {
    // 3. 如果锁还在(技能CD中),直接拦截,什么都不做
    if (timer) return;

    // 4. 如果没锁,说明可以释放技能!立即上锁,并开始倒计时
    timer = setTimeout(() => {
      fn.apply(this, args); // 真正执行业务逻辑(比如点赞、加载数据)
      timer = null;         // 倒计时结束,开锁(CD转好了)
    }, delay);
  }
}

// 实际使用:给滚动事件加上 300 毫秒的冷却时间
window.onscroll = throttle(function() {
  console.log('页面滚动了!');
}, 300);

三、 为什么这叫闭包?闭包到底干了啥?

很多新手看着上面的代码会发懵:这怎么就闭包了?

闭包的本质就是:内部函数访问了外部函数的变量,并且让这个变量活了下来。

结合上面的代码看:

  1. timer 是在外层 throttle 函数里定义的。
  2. 里层 return 出来的那个匿名函数,偷偷使用了外层的 timer
  3. 重点来了:外层函数 throttle 执行完就结束了,按理说 timer 应该被垃圾回收机制清理掉。但是!里层的函数被绑定到了 window.onscroll 上,它要长期存活。因为里层函数一直抓着 timer 不放,导致 timer 也被迫长期活在内存里。

这就是闭包。 timer 就像被里层函数“关”起来的一个私有变量。

四、 灵魂拷问:不写闭包行不行?

面试官经常会问:“我知道闭包能存状态,那我不用闭包,直接搞个全局变量存 timer 行不行?”

代码大概长这样:

let globalTimer = null; // 全局变量当锁

function badThrottle(fn, delay) {
  if (globalTimer) return;
  globalTimer = setTimeout(() => {
    fn();
    globalTimer = null;
  }, delay);
}

答案是:极其难用。 这种写法有两个致命缺陷:

  1. 全局污染: 你的 globalTimer 暴露在全局环境下,别的文件如果也有个同名变量,代码瞬间崩溃。甚至别人可以在控制台直接输入 globalTimer = null 来破解你的节流。
  2. 一锁锁全家(状态冲突): 这是最致命的。假设你的页面上有两个功能:滚动加载点赞。如果它们共用这个全局 globalTimer,就会发生灾难——当你正在滚动页面(触发了定时器,上了锁)的时候,你去点击“点赞”,会发现点赞按钮失效了!因为锁已经被滚动事件抢走了。

五、 闭包的降维打击:完美的状态隔离

我们再看回闭包写法:

const scrollThrottle = throttle(loadMore, 1000);
const likeThrottle = throttle(like, 1000);

当我们调用两次 throttle 函数时,神奇的事情发生了: 每次调用,都会在内存中开辟一个全新、独立的闭包环境。

  • 滚动事件拥有自己的 timer A。
  • 点赞事件拥有自己的 timer B。

它们不仅都被完美地隐藏在作用域内部(解决了全局污染),而且互不干扰(解决了状态冲突)。你滑你的页面,我点我的赞,大家井水不犯河水。

六、 再补一个高频兄弟:防抖(Debounce)

节流是“技能冷却期间不响应”; 防抖是“等你彻底停下来再执行一次”。

比如搜索框输入联想:你每敲一个字都发请求会非常浪费。更合理的做法是,等用户停止输入一小段时间后,再发一次请求。

下面这段就是经典防抖写法,同样依赖闭包来保存 timer 状态:

function debounce(fn, delay) {
  // 1. 定义在外部作用域的变量
  let timer = null;

  // 2. 返回内部函数
  return function (...args) {
    // 3. 内部函数访问了外部的 timer -> 形成闭包
    if (timer) clearTimeout(timer);

    timer = setTimeout(() => {
      fn.apply(this, args);
      timer = null;
    }, delay);
  };
}

一句话区分:

  • 节流(Throttle):固定时间内最多执行一次。
  • 防抖(Debounce):连续触发时不执行,停止触发后执行一次。

总结

节流里的 timer 不能每次执行时都重置,必须有一个安全的地方把它存起来。 全局变量太危险且容易冲突,而闭包完美地做到了“既隐藏了私有变量,又实现了状态的相互隔离”

这就是前端大佬们不约而同选择闭包来实现节流的根本原因。

Leave a comment