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

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

几种方案

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

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

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

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

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

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

三、防抖

javascript 复制代码
    function debounce(func, wait) {
        let timeout;
        return function (...args) {
            clearTimeout(timeout);
            timeout = setTimeout(() => func.apply(this, args), wait);
        };
    }
四、核心劫持逻辑
javascript 复制代码
  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';
JavaScript 复制代码
// 保存原始方法的全局变量
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劫持禁用完成');
}
相关推荐
真夜2 小时前
又遇到生产与开发环境结果不一致问题。。。
前端·javascript·http
掘金安东尼2 小时前
低代码工具很多,为什么 RollCode 更像一套「页面生产平台」
前端·javascript·面试
baozj2 小时前
前端大文件上传的另一种提速思路
前端·javascript
进击的尘埃2 小时前
Copilot 补全不听话?从 RAG 注入到采纳率量化,把 AI 补全调教成你的形状
javascript
不知名。。。。。。。。2 小时前
仿muduo库实现高并发服务器-----Channel模块 和 Poller模块
开发语言·前端·javascript
014-code2 小时前
Vue 中 data 为什么是函数而不是对象?
前端·javascript·vue.js
Never_Satisfied2 小时前
在JavaScript / HTML中,判断指定的元素是否含有某个类
开发语言·javascript·html
未来之窗软件服务2 小时前
自己写算法(十)js加密UUID保护解密——东方仙盟化神期
java·javascript·算法·代码加密·东方仙盟算法
浮桥2 小时前
uniapp页面列表列表请求hook记录
前端·javascript·uni-app