全局防抖方案的设计思路与实现:原型劫持的完整方案(零侵入)

0 阅读2分钟

有个历史遗留的老项目规范不够好,事件触发按钮较多且未作局部loading和防抖处理,当前任务是想给全局按钮自动添加防抖,用以优化用户体验和服务负载。

几种方案

  • 组件维度进行防抖设计,给当前交互对应的事件包裹防抖
  • 业务或者整个子应用可以进行自定义指令实现
  • 给当前使用的UI组件,二次封装使其拥有防抖功能
  • 对于我们这个,考虑全局劫持方案最优
    • ✅ 零侵入性:不需要修改任何现有代码
    • ✅ 全局覆盖:自动应用于所有按钮,包括动态生成的
    • ✅ 维护性好:集中管理,一处修改全局生效
    • ✅ 框架无关:纯JavaScript实现,不依赖特定框架

一、EventTarget 是一个 JavaScript 接口,表示可以接收事件的对象。

EventTarget 接口定义了三个主要的方法:

  • addEventListener():用于绑定事件监听器。
  • removeEventListener():用于移除已绑定的事件监听器。
  • dispatchEvent():用于分发事件,即触发事件。

image.png

二、识别目前元素,我们这个项目仅限制button类型

  • 验证当前是否为有效DOM元素element instanceof HTMLElement
  • 识别几种常用的按钮
    function isButton(element) {
        return element instanceof HTMLElement && (
            element.tagName === 'BUTTON'
            || (element.tagName === 'INPUT' && ['button', 'submit'].includes(element.type))
            || element.getAttribute('role') === 'button'
        );
    }

三、防抖

    function debounce(func, wait) {
        let timeout;
        return function (...args) {
            clearTimeout(timeout);
            timeout = setTimeout(() => func.apply(this, args), wait);
        };
    }
四、核心劫持逻辑
  EventTarget.prototype.addEventListener = function (type, listener, options) {
    if (type === 'click' && isButton(this)) {
      // 避免重复创建防抖函数
      if (debounceMap.has(this)) {
        const buttonMap = debounceMap.get(this);
        if (buttonMap.has(listener)) {
          const existingDebounced = buttonMap.get(listener);
          return originalAddEventListener.call(this, type, existingDebounced, options);
        }
      }

      const debouncedListener = debounce(listener, delay);

      if (!debounceMap.has(this)) {
        debounceMap.set(this, new Map());
      }
      debounceMap.get(this).set(listener, debouncedListener);
      // 注册防抖后的监听器
      return originalAddEventListener.call(this, type, debouncedListener, options);
    }
    //非目标事件保持原样
    return originalAddEventListener.call(this, type, listener, options);
  };

五、完整示例及使用

  • 在项目入口文件main.js中进行使用即可
  • import { debounceClick } from './debounce-click.js';
// 保存原始方法的全局变量
let originalAddEventListener;
let originalRemoveEventListener;

export function debounceClick(delay = 300) {
  if (window._eventTargetHijackEnabled) {
    console.warn('重复初始化');
    return;
  }

  // 备份原始方法
  if (!window._originalAddEventListener) {
    window._originalAddEventListener = EventTarget.prototype.addEventListener;
    window._originalRemoveEventListener = EventTarget.prototype.removeEventListener;
  }

  originalAddEventListener = window._originalAddEventListener;
  originalRemoveEventListener = window._originalRemoveEventListener;

  const debounceMap = new WeakMap();

  function isButton(element) {
    return (
      element instanceof HTMLElement &&
      (element.tagName === 'BUTTON' ||
        (element.tagName === 'INPUT' && ['button', 'submit'].includes(element.type)) ||
        element.getAttribute('role') === 'button')
    );
  }

  // 防抖函数
  function debounce(func, wait) {
    let timeout;
    return function (...args) {
      clearTimeout(timeout);
      timeout = setTimeout(() => func.apply(this, args), wait);
    };
  }

  // 劫持addEventListener
  EventTarget.prototype.addEventListener = function (type, listener, options) {
    if (type === 'click' && isButton(this)) {
      // 避免重复创建防抖函数
      if (debounceMap.has(this)) {
        const buttonMap = debounceMap.get(this);
        if (buttonMap.has(listener)) {
          const existingDebounced = buttonMap.get(listener);
          return originalAddEventListener.call(this, type, existingDebounced, options);
        }
      }

      const debouncedListener = debounce(listener, delay);

      if (!debounceMap.has(this)) {
        debounceMap.set(this, new Map());
      }
      debounceMap.get(this).set(listener, debouncedListener);

      return originalAddEventListener.call(this, type, debouncedListener, options);
    }
    return originalAddEventListener.call(this, type, listener, options);
  };

  // 劫持removeEventListener,不然会导致无法移除劫持处理后的事件监听器
  EventTarget.prototype.removeEventListener = function (type, listener, options) {
    if (type === 'click' && isButton(this) && debounceMap.has(this)) {
      const buttonMap = debounceMap.get(this);

      if (buttonMap.has(listener)) {
        const debouncedListener = buttonMap.get(listener);
        buttonMap.delete(listener);

        if (buttonMap.size === 0) {
          debounceMap.delete(this);
        }

        return originalRemoveEventListener.call(this, type, debouncedListener, options);
      }
    }
    return originalRemoveEventListener.call(this, type, listener, options);
  };

  window._eventTargetHijackEnabled = true;
  console.log(`劫持防抖启用(delay: ${delay}ms)`);
}

// 回滚原始
export function disableEventTargetHijack() {
  if (!window._eventTargetHijackEnabled) {
    console.warn('EventTarget劫持未启用,无需禁用');
    return;
  }

  // 恢复原始方法
  if (window._originalAddEventListener) {
    EventTarget.prototype.addEventListener = window._originalAddEventListener;
  }
  if (window._originalRemoveEventListener) {
    EventTarget.prototype.removeEventListener = window._originalRemoveEventListener;
  }

  // 清理所有无用的全局变量
  delete window._originalAddEventListener;
  delete window._originalRemoveEventListener;
  delete window._eventTargetHijackEnabled;

  console.warn('EventTarget劫持禁用完成');
}