别再被防抖坑了!从 0 手写 debounce,彻底搞懂 this、闭包和定时器陷阱!

319 阅读5分钟

「刚刚学会了防抖,结果一到this指向我懵了!」 前端面试中,debounce 就是个常驻选手,自己用 OK,但一手写就开始原形毕露:

  • this 怎么就丢了?
  • 定时器 ID 到底放哪儿?
  • 闭包是怎么救场的?

不慌,这篇手把手拆解,写完这一个小 debounce,你对 闭包this定时器机制直接一网打尽,面试官都得点头说「行」!


debounce 是啥?别被人问住了

咱先别一上来就写代码,先问自己一个问题:

debounce 到底解决了什么问题?

想象几个场景

场景 1:搜索框输入

用户在搜索框里输入「ChatGPT」,如果每输入一个字母就发一次请求,后台服务器分分钟起飞 ,白白浪费带宽。

场景 2:滚动监听

监听 scroll 事件,页面滑动一下就疯狂触发 N 次,DOM 操作、接口调用…性能一夜回到解放前。

场景 3:窗口 resize

有些组件需要根据浏览器窗口大小自适应,resize 事件也是连环触发。


所以,debounce(防抖)就是「延迟执行」:

触发 n 次,只执行最后一次!!!


最简单的 debounce:先别管 this,跑起来再说

别怕,先从最简化版本开刀 👇

js
复制编辑
function debounce(fn, delay) {
  let timer = null; // 用闭包保存上次的定时器 ID

  return function() {
    clearTimeout(timer); // 清除上一次的定时器
    timer = setTimeout(fn, delay); // 重新设一个新的
  };
}

解释一下:

  • 外层 timer 是闭包变量,永远跟着返回的函数。
  • 每次调用时,先把前一个定时器砍掉(clearTimeout)。
  • 重新设一个新的 setTimeout

这就能保证:

delay 时间内,如果有新的触发,就会把前面的清掉,只执行最后一次。


你以为结束了?this 让你泪流满面

别急,面试不会只让你写这么简单的。

要是有个对象,里面要用到 this 怎么办?咱看个实际点的例子

复制编辑
const obj = {
  count: 0,
  add() {
    console.log('原始 count:', this.count);
    this.count += 1;
    console.log('更新后 count:', this.count);
  }
};

const debouncedAdd = debounce(obj.add, 500);
debouncedAdd(); // ????

猜猜会发生什么?

this 丢了!

浏览器会报:

Cannot read property 'count' of undefined

为啥?

因为 setTimeout 里回调函数的 this 默认是 window(非严格模式),或者 undefined(严格模式)。

咱原来以为 thisobj,结果跑到别的地方去了。


🧙‍♂️ 所以,老司机怎么保住 this

有三板斧:

  • bind
  • call
  • apply

防抖里最常用的是 callapply,搭配闭包把 this 保下来。

咱把刚刚的 debounce 升级一下

function debounce(fn, delay) {
  return function(args) {
    // 外层函数里的 this 就是调用者
    const that = this;

    clearTimeout(fn.id);

    fn.id = setTimeout(function() {
      fn.call(that, args);
    }, delay);
  };
}

这里多了几个关键点:

  • var that = this:把外层的 this 存到变量里。
  • 定时器回调里,fn.call(that),把 this 重新指定回来。

现在谁调 incthis 就是谁!


来看完整示例,this 正确绑定

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Debounce this Demo</title>
</head>
<body>
  <script>
    function debounce(fn, delay) {
      return function(args) {
        var that = this;
        clearTimeout(fn.id);
        fn.id = setTimeout(function() {
          fn.call(that, args);
        }, delay);
      };
    }

    let obj = {
      count: 0,
      inc: debounce(function(vel) {
        console.log('count before:', this.count);
        this.count += vel;
        console.log('count after:', this.count);
      }, 500)
    };

    obj.inc(2);
  </script>
</body>
</html>

执行顺序:

  • obj.inc(2) 调用时,thisobj
  • 内层 that 记录了 obj
  • 定时器到点后,fn.call(that),完美绑定回来。

定时器 ID 挂到 fn 上,好用但不优雅

有没有注意到?这里把 id 挂到了 fn 上:

fn.id = setTimeout(...)

为啥这么写?

JS 里函数是一等对象,想挂啥都行,临时挂个 id 省事。

但要注意:

如果多处用同一个 fn,可能互相覆盖。

严格生产里更推荐:

✅ 用闭包变量保存 id(局部,不怕冲突)。

✅ 或者用 Symbol 给函数打标签,不会被外界误用。

但面试手写,挂 fn.id 是老招,面试官能看懂也懒得挑刺。


如果要传多个参数怎么办?

还记得前面示例是 args 吗?那是单参数的,多个参数要用 ...apply

function debounce(fn, delay) {
  return function(...args) {
    var that = this;
    clearTimeout(fn.id);
    fn.id = setTimeout(function() {
      fn.apply(that, args);
    }, delay);
  };
}

apply 可以把数组展开传进去,完美支持多参数场景。


真·生产级防抖还有哪些花活?

手写只是入门,真要落地还得会玩进阶版:

立即执行(leading edge)
默认防抖是“尾触发”,但有些需求要“先触发一次”。
典型场景:输入框实时搜索,先返回一个缓存结果。

最大等待时间(maxWait)
有些需求不能等太久,一定时间内至少执行一次。
lodash 的 debounce 就支持这个。

和 throttle(节流)组合
有些场景先防抖后节流,比如滚动监听。


##当然可以!这是针对你关于 this 丢失与绑定问题的文章小结版,写得简洁明了,适合直接放在文章里帮助读者抓重点:


小结(重难点!!!):this 丢失的真相与正确绑定姿势

在防抖函数的实现中,this 的指向至关重要。常见坑点在于:

  • 千万别在 debounce 外层函数里保存 this
    因为调用 debounce 本身时,this 通常是全局对象(window)或 undefined,保存的就不是我们想要的上下文。
  • 真正正确的做法,是在防抖返回的那个函数内部捕获 this
    这是因为调用防抖函数(如 obj.inc())时,this 会正确指向调用者 obj,这里保存的 this 才是有效的上下文。
  • 定时器回调函数中,this 又会丢失(指向全局或 undefined) ,必须用 callapply 显式绑定回正确的 this

总结一句话:

this 的指向由调用时决定,保存 this 要靠闭包捕获调用防抖函数时的上下文,再通过 call / apply 绑定到延迟执行的回调里。”

掌握这三步,this 丢失问题迎刃而解,防抖写起来更稳妥,面试官问也不用慌!


今天这锅,咱端稳了!

  • 防抖(debounce)的核心场景和作用
  • this 在回调里怎么保住不丢
  • call / apply 的使用姿势
  • 定时器 ID 怎么挂、怎么管
  • 多参数怎么传递
  • 面试手写避坑指南

彩蛋互动

你平时是自己写防抖,还是直接 lodash 一把梭?
或者面试真被问过 this 和闭包吗?

评论区告诉我,你最想看下次我手写哪个工具函数?


结尾

如果这篇对你有帮助,麻烦点个小小的 ❤️ 和 收藏,
别被防抖坑了,让我们一起把面试官抖没脾气!