防抖(Debounce)的底层机制:闭包与this指向的深度解析 🚀

76 阅读13分钟

防抖(Debounce)的底层机制:闭包与this指向的深度解析 🚀

引言:为什么需要防抖?

防抖最常见的场景也是我们经常用到的浏览器的搜索建议。不知道你们有没有发现这个现象,我们想要在百度里面搜索一些内容,他下面会给我们推荐出相应的包含你输入字符的内容。

屏幕录制_2025-07-09_181216.gif

推荐内容的展示功能是通过用户输入框输入触发的,推荐内容的展示功能的实现非常耗时的,要是频繁触发,很容易导致服务器宕机,因此我们很有必要对输入框输入事件进行防抖,在日常生活中可以观察到在我们每输入几个字段,停顿一定的时间,下面的推荐内容都会发生改变,但是当我们以较快的速度有目标地输入内容时,推荐的内容是不会发生较大的改变的,这便是我们今天要介绍的防抖

在现代Web开发中,我们经常需要处理高频触发的事件,如输入框输入、窗口大小调整、滚动事件等。这些事件可能每秒触发数十次甚至上百次!如果每次事件触发都执行回调函数,会导致:

  1. 性能问题:过多的DOM操作导致页面卡顿
  2. 资源浪费:不必要的API请求增加服务器压力
  3. 用户体验下降:界面响应变慢,操作不流畅

防抖(Debounce)技术正是解决这类问题的银弹!它确保事件停止触发后一定时间才执行回调,避免不必要的重复操作。本文将从底层原理剖析防抖的实现机制,重点解析闭包应用和this指向问题。

防抖的核心原理

在连续触发的事件中,只有在最后一次事件发生后的一定时间间隔内没有新的事件触发时,才执行相应的处理函数。

防抖的核心思想是:延迟执行,重置计时。想象电梯关门的过程:

🛗 当有人进入电梯时,电梯不会立即关门,而是等待一段时间(比如10秒)。如果在这10秒内又有人进入,计时器会重置,重新等待10秒。只有当10秒内无人进入时,电梯才会关门。

image.png

防抖的底层实现

让我们从最基础的防抖实现开始,逐步深入分析:

function debounce(fn, delay) {
    return function(args) {
        // 保存正确的this指向
        var that = this;
        // 清除上一次的定时器
        clearTimeout(fn.id);
        // 设置新的定时器
        fn.id = setTimeout(function() {
            // 通过apply调用函数,绑定正确的this
            fn.call(that, args);
        }, delay);
    }
}

防抖演示

屏幕录制_2025-07-09_183204.gif

可以很明显地看到在没有进行防抖的输入框内每一次的keyup事件的触发,都会进行函数调用,而在进行了防抖的输入框内输入,则会极大地减少函数调用的触发(上述演示中,为了让对比更加清晰,我将delay参数设置较大,在日常生活中一般较小,符合用户的输入习惯),如果这不仅仅是个打印输入框内的值,而是发送请求,或者调整dom结构等非常耗时的函数呢,那使用防抖后的性能开销就减少了更多,体验也就更明显。

关键点解析

代码片段作用解析技术要点
return function(args)返回闭包函数闭包保存fn和delay
var that = this保存当前this解决this丢失问题
clearTimeout(fn.id)清除上一次定时器重置计时机制
fn.id = setTimeout(...)设置新定时器函数作为对象的特性
fn.call(that, args)执行回调正确绑定this和参数

闭包的应用

防抖函数完美展示了闭包的威力:

  1. 自由变量捕获:返回的函数捕获了fndelay
  2. 状态保持:通过fn.id保存定时器ID
  3. 私有状态:定时器ID无法从外部访问
// 使用示例
const debouncedFn = debounce(ajax, 300);

  • 每次调用debouncedFn时:
      1. 访问闭包中的fn(ajax)和delay(300)
      1. 操作闭包中的fn.id

this指向的陷阱与解决

在防抖实现中,this指向是最容易出错的点:

let obj = {
    count: 0,
    inc: debounce(function(val) {
        // 如果没有处理this,这里将指向window!
        this.count += val;
        console.log(this.count);
    }, 500)
};

obj.inc(2); // 正确输出2
this丢失的原因
  1. 函数调用方式setTimeout中的函数默认指向全局对象(浏览器中为window)
  2. 中间函数层:防抖包装创建了额外的函数层
解决方案
function debounce(fn, delay) {
    return function(args) {
        // 关键!保存调用时的this
        var that = this;
        
        clearTimeout(fn.id);
        fn.id = setTimeout(function() {
            // 使用call绑定正确的this
            fn.call(that, args);
        }, delay);
    }
}

防抖的具体实现

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>防抖节流</title>
</head>
<body>
    <h2>没有防抖</h2>
    <input type="text" id="inputA">
    <br>
    <br>
    <h2>进行了防抖</h2>
    <input type="text" id="inputB">
    <br>
    <br>
    <script>
        let inputA = document.getElementById('inputA')
        let inputB = document.getElementById('inputB')
        //google sugguest ajax api call
        function ajax(content){
            console.log('ajax request'+content)
        }
        //函数的参数或者返回值也是函数,高阶函数
        //通用函数 抽象, fn 任何函数减少执行频率
        function debounce(fn,delay){
            
            return function(args){
                //定时器返回ID
                //fn是自由变量
                //fn 一等对象
                //fn.id添加函数的属性
                //自由变量是什么
                clearTimeout(fn.id)
                fn.id=setTimeout(function(){
                    fn(args)
                },delay)//fn.id,定时器的把柄
            }
        }

        inputA.addEventListener('keyup',function(event){
            //如果执行的是耗时任务
            //google suggest搜索建议 如果这个触发频率太高 服务器宕机
            //图片懒加载 scroll+getBoundingClientRect触发的频率太高
            //console.log(event.target.value)
            //为什么减少触发频率? 性能,减少服务器压力
            // 没有必要,用户的意图 单词为单位
            ajax(event.target.value);
        })
        //高阶函数 将耗时函数->作为闭包的自由变量
        //返回一个新函数 频繁执行
        let debounceAjax = debounce(ajax,2000)
        inputB.addEventListener('keyup',function(event){
            debounceAjax(event.target.value)
        })
    </script>
</body>
</html>

屏幕录制_2025-07-09_183204.gif

通过监听输入框的keyup事件,触发ajax函数的运行(ajax函数模拟耗时功能的实现),但我们并没有直接多次触发耗时性的任务,比如下面这种

 inputA.addEventListener('keyup',function(event){
            //如果执行的是耗时任务
            //google suggest搜索建议 如果这个触发频率太高 服务器宕机
            //图片懒加载 scroll+getBoundingClientRect触发的频率太高
            //console.log(event.target.value)
            //为什么减少触发频率? 性能,减少服务器压力
            // 没有必要,用户的意图 单词为单位
            ajax(event.target.value);
        })

而是通过多次触发debounceAjax不耗时的函数,来选择性(只有在最后一次事件发生后的一定时间间隔内没有新的事件触发)是否执行ajax()这个耗时性函数。因此事件监听触发函数,计时器的次数没有变,只是来了一招狸猫换太子,将耗时性函数换成一个执行简单的计时器,包装成一个高级函数(参数或者返回值为函数的函数),通过计时器清除减少ajax()这个耗时性函数的执行

inputB.addEventListener('keyup',function(event){
            debounceAjax(event.target.value)
        })

下面是一些常见问题的解答:

为什么要用闭包

如果没有闭包

// 不使用闭包的尝试(有严重缺陷)
function debounceWithoutClosure(fn, delay) {
    let timerId; // 问题:所有防抖函数共享同一个timerId
    clearTimeout(timerId);
    timerId = setTimeout(function() {
        fn();
    }, delay);
}

// 使用方式
inputB.addEventListener('keyup', function(event) {
    debounceWithoutClosure(() => ajax(event.target.value), 2000);
});
  1. 无法保存状态:每次调用debounceWithoutClosure都会重置timerId,导致防抖失效
  2. 多个防抖函数相互影响:如果有多个防抖函数,它们会共享同一个timerId

因为我们需要:

  1. 保留状态(如定时器 ID)
  2. 延迟执行原始函数

闭包允许我们在返回的新函数中访问外部作用域中的变量(如 fn, delay 等),从而实现防抖的逻辑。

如果没有闭包,你就无法做到这些:

  • 每次按键都要记住之前的定时器 ID 并清除它;
  • 要保证每次调用的是同一个 fn.id 变量;
  • 延迟触发原始函数;

这些都需要闭包来保持变量的状态。

闭包的核心优势在于:为每个被防抖的函数创建独立的作用域。每次调用debounce(ajax, 2000)时,都会生成一个新的闭包,包含:

  • 独立的fn引用
  • 独立的delay
  • 独立的timerId(在你的实现中是fn.id

为什么要使用fn.id?

  • 为什么需要清除定时器?

    当用户快速连续地触发事件(例如快速键入文字)时,如果不取消之前的定时器而只是不断地设置新的定时器,那么每个定时器都会在其延迟期满后执行目标函数。这意味着即使用户已经完成了输入,如果存在多个未完成的定时器,它们仍然会按照各自的延迟时间结束后依次执行目标函数。这不仅浪费资源,还可能导致不希望的结果(如发送重复的网络请求)。

  • fn.id 的作用

    在 JavaScript 中,setTimeoutclearTimeout 是一对用于管理定时任务的方法。当你调用 setTimeout 设置一个定时器时,它返回一个唯一的 ID,这个 ID 可以被用来通过 clearTimeout 方法来取消该定时器。在防抖函数中,fn.id 被用来存储最近一次设置的定时器 ID。这样,在每次事件触发时,都可以通过 clearTimeout(fn.id) 来取消上一次设置的定时器,然后重新设置一个新的定时器。这样做的结果是只有当用户停止触发事件超过指定的时间间隔后,目标函数才会被执行。

  • id为什么可以放在fn上,作为它的属性fn.id

    在JavaScript中,函数是一等公民(first-class citizens),这意味着它们可以被赋值给变量、存储在数据结构中、作为参数传递给其他函数,以及从函数中返回。更重要的是,函数也是对象(具体来说,是Function对象),这意味着你可以在函数上定义属性。(当然,你也可以在debounce函数内声明一个id,再通过返回函数调用,使用闭包一样可以达到存储id的目的,不过封装再函数内,将函数看成对象之间定义属性,更可以装逼,显得你深度理解了JS内函数的底层)

为什么不能直接写成这样?:

 inputB.addEventListener('keyup', debounce(ajax, 2000)(event.target.value));

或者更简单地:

inputB.addEventListener('keyup', debounceAjax(event.target.value));

  • 1.事件监听器的执行机制

addEventListener 的第二个参数应该是一个 函数引用,而不是一个 函数调用的结果

例如:

✅ 正确(传入函数本身):

inputB.addEventListener('keyup', myFunction);

❌ 错误(立即执行了函数):


inputB.addEventListener('keyup', myFunction()); // ❌ 这会立刻执行,不是传函数

  • 2、防抖函数返回的是一个新函数

这是关键点之一:

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

这个 debounce 函数返回了一个新的函数(闭包),它会保存一些状态(比如定时器 ID),并延迟执行原始函数 fn

所以当你调用:

let debounceAjax = debounce(ajax, 2000);

你得到的是一个新的函数,这个函数已经“封装”了延迟逻辑。


  • 3、举个错误的例子

如果你写成这样:

inputB.addEventListener('keyup', debounce(ajax, 2000)(event.target.value));

这段代码会在页面加载时就执行 debounce(ajax, 2000)(...),然后把它的返回值(即 undefined,因为 fn(args) 没有返回值)作为事件处理函数传给 addEventListener,这显然是不对的。

箭头函数与普通函数的区别

防抖的应用场景

  1. 搜索建议:用户停止输入后再发送请求

    searchInput.addEventListener('input', debounce(fetchSuggestions, 300));
    
  2. 窗口调整:调整完成后再计算布局

    window.addEventListener('resize', debounce(calculateLayout, 200));
    
  3. 表单验证:用户停止输入后再验证

    emailInput.addEventListener('input', debounce(validateEmail, 500));
    
  4. 按钮防重:防止重复提交

    submitButton.addEventListener('click', debounce(submitForm, 1000));
    

防抖的变体与增强

立即执行版本

有时我们需要首次触发立即执行,后续触发才防抖:


/**
 * 创建一个“立即执行型”的防抖函数。
 * 该函数在事件被触发时会**立即执行一次**,之后在指定的 delay 时间内重复触发将不会再次执行。
 *
 * @param {Function} fn - 需要进行防抖处理的目标函数。
 * @param {number} delay - 防抖延迟时间,单位为毫秒。
 * @returns {Function} 返回一个新的防抖包装函数。
 */
function debounceImmediate(fn, delay) {
    // 定时器标识,用于控制是否允许 fn 执行
    let timer = null;

    // 返回新的防抖函数
    return function(...args) {
        // 保存 this 上下文,确保 fn 在调用时能正确使用 this
        const context = this;

        // 判断是否是第一次触发或已过冷却期
        const shouldCallNow = !timer;

        // 清除之前的定时器,防止重复触发
        clearTimeout(timer);

        // 如果是首次触发或冷却期已过,则**立即执行** fn
        if (shouldCallNow) {
            fn.apply(context, args);
        }

        // 设置新的定时器,在 delay 时间后重置 timer,允许下一次立即执行
        timer = setTimeout(() => {
            timer = null;
        }, delay);
    };
}

🧠 行为说明(对比普通防抖)

类型触发时机示例场景
普通防抖(non-immediate)在停止触发后等待 delay 时间才执行输入框搜索建议、窗口调整等
立即执行型防抖(immediate)第一次触发立刻执行,之后在 delay 时间内不再执行按钮点击限制、提交操作去重

带取消功能的防抖


/**
 * 创建一个可以取消的防抖函数。
 * @param {Function} fn - 需要进行防抖处理的函数。
 * @param {number} delay - 延迟执行的时间,单位为毫秒。
 * @returns {Function} 返回一个新的函数,该函数具有防抖功能,并且可以通过调用 .cancel 方法来取消定时器。
 */
function debounceCancelable(fn, delay) {
    // 用于存储定时器ID,初始值为null表示没有设置任何定时器。
    let timer = null;
    
    /**
     * 实际的防抖函数,当被调用时会根据设定的延迟时间决定是否执行传入的fn函数。
     * 如果在延迟时间内再次调用此函数,则重置计时器。
     */
    function debounced(...args) {
        // 保存当前this上下文,确保fn在setTimeout回调中能够正确访问到调用debounced时的this。
        const context = this;
        
        // 清除之前的定时器(如果有),确保只有在最后一次触发事件后经过指定的延迟时间才会执行fn。
        clearTimeout(timer);
        
        // 设置新的定时器,在延迟delay毫秒后执行fn函数。
        timer = setTimeout(() => {
            // 使用apply方法以正确的this上下文和参数列表执行fn函数。
            fn.apply(context, args);
        }, delay);
    }
    
    /**
     * 取消当前的防抖操作。如果存在未完成的定时器,则清除它,并将timer设为null。
     */
    debounced.cancel = () => {
        if (timer !== null) {
            // 如果有活动的定时器,则清除它。
            clearTimeout(timer);
            // 将timer设置为null,标记当前没有活跃的定时器。
            timer = null;
        }
    };
    
    // 返回创建的防抖函数实例,该实例包含一个额外的cancel方法用于取消定时器。
    return debounced;
}

性能优化建议

  1. 合理设置delay时间

    • 输入类:200-500ms
    • 滚动/调整大小:100-200ms
    • 按钮点击:1000ms(防重复提交)
  2. 避免内存泄漏

    // 组件卸载时取消防抖
    useEffect(() => {
      const debouncedFn = debounce(fn, 300);
      
      return () => {
        debouncedFn.cancel && debouncedFn.cancel();
      };
    }, []);
    

总结

防抖技术通过闭包定时器管理实现了对高频事件的有效控制,核心要点包括:

  1. 闭包应用:保存函数引用和定时器状态
  2. 定时器管理:清除重置机制是关键
  3. this绑定:通过闭包保存执行上下文
  4. 参数传递:确保原始参数正确传递

终于写完了啊啊啊啊

Suggestion.gif

在下一篇文章中,我们将深入探讨节流(Throttle) 的实现机制,对比防抖与节流的差异,并分析它们在不同场景下的最佳实践。敬请期待!