vue3中添加全局防抖指令

学习过程记录

1. 背景与需求

在开发Vue项目时,大家可能都会遇到一个常见的问题:用户频繁点击按钮触发多次API请求。这不仅会增加服务器负担,还可能导致数据混乱。为了解决这个问题,这里实现一个全局的防抖指令。

2. 防抖原理理解

首先,需要理解防抖的核心原理:

  • 防抖(Debounce):指在事件被触发n秒后再执行回调,如果在这n秒内事件被再次触发,则重新计时
  • 这种技术常用于处理用户输入、搜索、窗口调整等高频触发的事件

3. Vue3自定义指令学习

Vue3的自定义指令与Vue2有所不同,主要体现在:

  1. 生命周期钩子变化:

    • bind 改为 mounted
    • updatecomponentUpdated 合并为 updated
    • unbind 改为 beforeUnmount
  2. 指令的参数获取方式:

    // 获取传入的回调函数
    const func = binding.value;
    // 获取等待时间(arg),默认1000ms
    const wait = Number(binding.arg) || 1000;
    // 是否立即执行(修饰符)
    const immediate = binding.modifiers.immediate;

4. 实现思路

  1. 为使用指令的元素绑定一个点击事件处理函数
  2. 在处理函数中实现防抖逻辑
  3. 支持自定义延迟时间和立即执行选项
  4. 确保不同元素之间的防抖行为互不影响

5. 关键技术点

  1. 元素属性扩展:通过TypeScript扩展HTMLElement接口,为元素添加自定义属性

    declare global {
      interface HTMLElement {
        __debounce_handler__?: EventListener;
        __debounce_timer__?: number | null;
      }
    }
  2. 闭包与事件处理:使用闭包创建独立的处理函数,保存状态

    const createHandler = (func: Function, wait: number, immediate: boolean): EventListener => {
      return function(event: Event) {
        // 防抖逻辑
      };
    };
  3. 资源清理:在指令销毁前清理定时器和事件监听,防止内存泄漏

    beforeUnmount(el: HTMLElement) {
      // 清除定时器和事件监听
    }

6. 坑与解决方案

  1. 问题:直接传入带参数的函数会在页面加载时立即执行

    <!-- 错误用法 -->
    <button v-debounce="selectTopic(item)">选择</button>

    解决方案:使用箭头函数包裹

    <!-- 正确用法 -->
    <button v-debounce="() => selectTopic(item)">选择</button>
  2. 问题:多个元素使用同一指令时防抖行为相互影响

    解决方案:为每个元素创建独立的定时器和处理函数

    // 为每个元素创建独立的定时器变量
    el.__debounce_timer__ = null;
    // 为每个元素创建独立的处理函数
    el.__debounce_handler__ = createHandler(func, wait, immediate);

7. 全局注册

最后,在main.ts中注册全局指令:

import { createApp } from 'vue'
import App from './App.vue'
import debounceDirective from './directives/debounce'

const app = createApp(App)
app.directive('debounce', debounceDirective)
app.mount('#app')

8. 后续优化

  1. 考虑扩展支持更多事件类型,如input、change等
  2. 添加节流(Throttle)功能,作为防抖的补充
  3. 增加更多调试选项,方便开发时排查问题

完整代码

/**
 * 防抖指令
 * 用法:v-debounce:500.immediate="handler"
 * 参数:
 *   - 数字参数表示延迟时间,默认1000ms
 * 修饰符:
 *   - immediate: 是否立即执行,默认为false
 * 
 * 注意事项:
 *   - 在有参数的指令中,必须使用箭头函数包裹处理函数,否则会在页面加载时立即执行
 *     错误用法: v-debounce="selectTopic(item)"      // 会在元素加载时立即调用函数
 *     正确用法: v-debounce="() => selectTopic(item)" // 只有点击时才会调用函数
 *   - 不要在同一个元素上多次使用此指令
 *   - 每个使用此指令的元素都有独立的防抖行为,互不影响
 * 
 * 如果是不带参数的函数,正常使用指令即可:
 * v-debounce.immediate="clickSend"
 */

// 扩展HTMLElement类型,添加自定义属性
declare global {
  interface HTMLElement {
    __debounce_handler__?: EventListener;
    __debounce_timer__?: number | null;
  }
}

// 定义Vue指令绑定的参数类型
interface DirectiveBinding {
  value: Function;
  arg?: string;
  modifiers: {
    [key: string]: boolean;
  };
  instance: any;
  oldValue?: Function;
}

const debounceDirective = {
  /**
   * 指令挂载时调用
   * @param {HTMLElement} el - 指令绑定的元素
   * @param {DirectiveBinding} binding - 指令绑定的值
   */
  mounted(el: HTMLElement, binding: DirectiveBinding) {
    // 为每个元素创建独立的定时器变量
    el.__debounce_timer__ = null;
    
    // 创建处理函数
    const createHandler = (func: Function, wait: number, immediate: boolean): EventListener => {
      return function(event: Event) {
        // 使用元素自身的定时器,确保每个元素有独立的定时器
        const callNow = immediate && !el.__debounce_timer__;
        
        // 清除此元素自己的定时器
        if (el.__debounce_timer__) {
          clearTimeout(el.__debounce_timer__);
        }
        
        // 设置新的定时器,存储在元素自己的属性中
        el.__debounce_timer__ = window.setTimeout(() => {
          if (!immediate) {
            func.call(binding.instance, event);
          }
          el.__debounce_timer__ = null;
        }, wait);
        
        // 如果是立即执行,并且没有活动的定时器,则立即调用
        if (callNow) {
          func.call(binding.instance, event);
        }
      };
    };
    
    // 获取传入的回调函数
    const func = binding.value;
    // 获取等待时间(arg),默认 1000ms
    const wait = Number(binding.arg) || 1000;
    // 是否立即执行(修饰符)
    const immediate = binding.modifiers.immediate;

    // 为每个元素创建独立的处理函数
    el.__debounce_handler__ = createHandler(func, wait, immediate);
    
    // 添加点击事件监听器
    el.addEventListener('click', el.__debounce_handler__);
  },
  
  /**
   * 指令更新时调用,确保更新handler
   * @param {HTMLElement} el - 指令绑定的元素 
   * @param {DirectiveBinding} binding - 指令绑定的值
   */
  updated(el: HTMLElement, binding: DirectiveBinding) {
    // 如果函数引用发生变化,需要更新处理函数
    if (binding.value !== binding.oldValue && el.__debounce_handler__) {
      // 移除旧的事件监听
      el.removeEventListener('click', el.__debounce_handler__);
      
      // 获取最新的配置
      const func = binding.value;
      const wait = Number(binding.arg) || 1000;
      const immediate = binding.modifiers.immediate;
      
      // 创建新的处理函数
      const createHandler = (func: Function, wait: number, immediate: boolean): EventListener => {
        return function(event: Event) {
          const callNow = immediate && !el.__debounce_timer__;
          
          if (el.__debounce_timer__) {
            clearTimeout(el.__debounce_timer__);
          }
          
          el.__debounce_timer__ = window.setTimeout(() => {
            if (!immediate) {
              func.call(binding.instance, event);
            }
            el.__debounce_timer__ = null;
          }, wait);
          
          if (callNow) {
            func.call(binding.instance, event);
          }
        };
      };
      
      // 更新处理函数
      el.__debounce_handler__ = createHandler(func, wait, immediate);
      
      // 重新添加事件监听
      el.addEventListener('click', el.__debounce_handler__);
    }
  },
  
  /**
   * 指令卸载前调用
   * @param {HTMLElement} el - 指令绑定的元素
   */
  beforeUnmount(el: HTMLElement) {
    // 清除定时器
    if (el.__debounce_timer__) {
      clearTimeout(el.__debounce_timer__);
      el.__debounce_timer__ = null;
    }
    
    // 移除事件监听
    if (el.__debounce_handler__) {
      el.removeEventListener('click', el.__debounce_handler__);
      // 删除附加的属性
      el.__debounce_handler__ = undefined;
      el.__debounce_timer__ = undefined;
    }
  }
}

export default debounceDirective;