还在写普通防抖?

5,296 阅读8分钟

不会防抖?没有关系,我们先从普通防抖开始

一:防抖意义及其工作原理

防抖是一种函数调用的优化策略,主要用于避免在短时间内连续多次触发某个函数,从而减轻系统负担。在面试中,也是常考的前端性能优化策略,防抖的主要思想是在一系列连续的事件触发时,只在一段时间之后执行一次处理函数。如果在这段时间内又有新的事件触发,则会重新计时。

  1. 初始化: 用户触发事件(如按钮点击、输入框输入等)。
  2. 设置定时器: 第一次触发事件时,设置一个定时器,计划在一定时间(比如1秒)后执行处理函数。
  3. 重复触发: 如果在定时器到期之前又触发了事件,则清除之前的定时器,并重新设置一个新的定时器。
  4. 最终执行: 只有当定时器到期而没有新的事件触发时,才会执行处理函数

二:普通防抖

下面根据防抖的核心,来一个最朴素的防抖

  1. 获取按钮元素:

    • let btn = document.getElementById('btn');: 获取 ID 为 btn 的按钮元素。
  2. 定义处理函数 handle:

    • function handle(e) { ... }: 这个函数会在用户点击按钮时执行。它接收一个事件对象 e 作为参数,但在这个例子中并没有使用这个参数。函数内部目前仅打印 "提交" 到控制台。
  3. 定义防抖函数 debounce:

    • function debounce(fn) { ... }: 这个函数接受一个函数 fn 作为参数。

    • let timer = null;: 定义一个 timer 变量,用于保存定时器的引用。

    • return function(e) { ... }: 返回一个新的匿名函数,这个匿名函数将在每次点击时被调用。

      • clearTimeout(timer);: 清除之前设置的定时器,以防用户在1秒内多次点击按钮。
      • timer = setTimeout(fn, 1000);: 设置一个新的定时器,在1秒后调用传入的函数 fn
  4. 添加点击事件监听器:

    • btn.addEventListener('click', debounce(handle));: 为按钮添加一个点击事件监听器,监听器的回调函数是 debounce(handle) 的结果。
    <script>
        let btn = document.getElementById('btn');
        function handle() {
            // AJAX 请求
            console.log('提交'); 
        }
        // 防抖函数
        function debounce(fn) {
            let timer = null;
    
            return function() {
                //如果第二次时间没到1s,就销毁上一次的定时器
                clearTimeout(timer);
                timer = setTimeout(fn, 1000);//回调
            };
        }
        // 添加点击事件监听器
        btn.addEventListener('click', debounce(handle));
    </script>
    

    根据以上代码,我们能够达到一个防抖的基本要求,其中闭包的利用,是实现这个功能的核心,如果友友们对闭包不太理解,可以参考这篇文章:深入理解 JavaScript 执行机制和闭包。下面我来解释下这里的闭包作用:

    1. 闭包的定义:

    • 闭包是一个函数与它相关的引用环境组合在一起所形成的实体。在上面代码中,返回的匿名函数与 debounce 函数的作用域形成了一个闭包。

    1. 变量 timer 的作用域:

    • timer 是在 debounce 函数的作用域中定义的,但它可以在返回的匿名函数中访问。

    • 当 debounce 函数执行完毕后,通常情况下它内部的局部变量会被垃圾回收器回收,但由于返回的匿名函数仍然引用着 timer,所以 timer 不会被回收。

    1. 闭包的作用:

    • 闭包使得 timer 变量可以在多次调用返回的匿名函数之间保持状态。每次点击按钮时,返回的匿名函数都可以访问到同一个 timer 变量,从而实现防抖的效果。

三:优化普通防抖

不知道友友们有没有发现一个问题,在上述防抖代码中,我们在handle函数中输出this,会发现这里的this指向了全局,这不对啊,这个this应该是指向btn才对,做了一个防抖节流,把人家this指向给掰弯了(如果友友们对this的指向不太理解,可以参考这篇文章:this this,你到底指向谁)

原因很简单,当定时器到期时,fn(即 handle)会被调用。在定时器的回调函数中,如果没有明确绑定 this 的值,那么在非严格模式下,this 会默认指向全局对象(通常是 window),那么我们现在要怎么做才能正确的将handle中的this指回btn呢?

  1. 找出能够指向btn的函数:匿名函数(return function(e) { ... })是在 debounce 函数内部定义的,它是在按钮点击事件触发时执行的,由于它是作为事件监听器的一部分被执行的,因此它的 this 值会自动指向触发事件的元素,即 btn

  2. 利用显示绑定强行掰弯handle里的this,使其指向btn

    • 在定时器的回调函数中直接使用 call 或 apply 方法来调用 handle 函数,并将 this 显式地绑定为 btn 元素。

    • 使用箭头函数来简化代码,并确保 this 始终指向正确的元素。

    此外,我们再加上事件参数,以下是两份完整的优化后代码:

使用 call 或 apply方法

let btn = document.getElementById('btn');
function handle(e) {
    // AJAX 请求
    console.log('提交', this); 
}
// 防抖函数
function debounce(fn) {
    let timer = null;

    return function(e) {
    const that = this//匿名函数的this执行btn
        // 如果第二次时间没到1秒,就销毁上一次的定时器
        clearTimeout(timer);
        timer = setTimeout(function() {
            fn.call(that, e); // 使用 `call` 显式绑定到匿名函数的 `this` 值
        }, 1000);
    };
}
// 添加点击事件监听器
btn.addEventListener('click', debounce(handle));

使用箭头函数

let btn = document.getElementById('btn');
function handle(e) {
    // AJAX 请求
    console.log('提交', this); 
}
// 防抖函数
function debounce(fn) {
    let timer = null;

    return function(e) {
        // 如果第二次时间没到1秒,就销毁上一次的定时器
        clearTimeout(timer);
        timer = setTimeout(() => {
             fn.call(this, e); // 使用 call 方法确保 handle 中的 this 指向 btn
        }, 1000);
    };
}
// 添加点击事件监听器
btn.addEventListener('click', debounce(handle));

四:高级防抖

聊了这么久基础,终于进入难点了,简单的防抖还不足以打动面试官,我们要学习更高级,性能更好的防抖。

手写高级防抖

  1. 定义防抖函数 debounce:

    • function debounce(func, wait, immediate) { ... }: 这个函数接受三个参数:

      • func: 要防抖的函数。
      • wait: 等待的时间(毫秒)。
      • immediate: 布尔值,表示是否立即执行函数。
  2. 内部变量:

    • var timeout, result;: 定义了两个变量:

      • timeout: 用于保存定时器的引用。
      • result: 用于保存函数 func 的执行结果。
  3. 返回新的函数:

    • return function() { ... }: 返回一个新的匿名函数,这个匿名函数将在每次调用时被调用。

      • var context = this;: 保存当前函数的 this 值。

      • var args = arguments;: 保存传递给当前函数的所有参数。

      • if (timeout) clearTimeout(timeout);: 如果存在定时器,则清除它。

      • if (immediate) { ... } else { ... }: 根据 immediate 的值决定执行逻辑。

        • 如果 immediate 为 true,则尝试立即执行 func,并在 wait 时间后清除定时器。
        • 如果 immediate 为 false,则在 wait 时间后执行 func
  4. 使用防抖函数:

    • debounce(getUserAction, 1000, true);: 调用防抖函数,传入 getUserAction 函数作为处理函数,设置等待时间为 1000 毫秒,并立即执行处理函数
function debounce(func, wait, immediate) {
    var timeout, result; // 自由变量空间
    // 真正要执行的函数
    return function() { // 二传手
        var context = this;
        var args = arguments;
        if (timeout) clearTimeout(timeout); // 清除定时器
        if (immediate) {
            var callNow = !timeout;//立即执行一次
            timeout = setTimeout(function() {
                timeout = null; // 释放
            }, wait);
            if (callNow) result = func.apply(context, args);
        } else {
            timeout = setTimeout(function() {
                result = func.apply(context, args);
            }, wait);
        }
        return result;
    };
}
// 使用防抖函数
debounce(getUserAction, 1000, true);

与普通防抖的不同

  • 立即执行模式:

    • 新版本的防抖函数支持立即执行模式,即如果 immediate 为 true,则在首次触发事件时立即执行 func,并在 wait 时间后清除定时器。这在某些场景下非常有用,例如当需要立即响应用户的第一次动作,但后续动作需要防抖处理时。
  • 更灵活的参数:

    • 新版本的防抖函数接受第三个参数 immediate,使得开发者可以根据具体需求选择是否立即执行处理函数。
  • 更完整的实现:

    • 新版本的防抖函数不仅返回处理函数的结果,而且在返回前会清除定时器,确保内存资源得到释放。

为什么更优秀

  • 灵活性:

    • 支持立即执行和延迟执行两种模式,提供了更多的选择性。
  • 资源管理:

    • 在立即执行模式下,通过设置定时器来清除 timeout 变量,确保了内存资源的有效管理。
  • 用户体验:

    • 立即执行模式可以提供更好的用户体验,因为它可以在第一次触发事件时立即响应,之后再进行防抖处理。