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

有个历史遗留的老项目规范不够好,事件触发按钮较多且未作局部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劫持禁用完成');
}
相关推荐
放下华子我只抽RuiKe510 小时前
React 从入门到生产(四):自定义 Hook
前端·javascript·人工智能·深度学习·react.js·自然语言处理·前端框架
XinZong10 小时前
OpenClaw 实现双重心跳(Heartbeat)+ clawreach虾聊项目实现
javascript
还有多久拿退休金12 小时前
一张栈的图,治好你面试答不出 script 阻塞的病
前端·javascript
zithern_juejin12 小时前
原型与原型链
javascript
008爬虫实战录15 小时前
【码上爬】 题十二:如来神掌 困难, JSVMP加密,使用代理补环境
前端·javascript·node.js
threelab15 小时前
Three.js 数学函数着色器 | 三维可视化 / AI 提示词
javascript·人工智能·着色器
ZC跨境爬虫16 小时前
跟着 MDN 学CSS day_3:(为一个传记页面添加样式)
前端·javascript·css·ui·音视频·html5
夜雪闻竹16 小时前
sql.js WASM 实战:浏览器里跑 SQLite
javascript·sql·wasm
爱喝铁观音的谷力景辉16 小时前
在Cesium中实现带箭头方向路线样式的技术详解
javascript·cesium
Qhappy16 小时前
AI逆向实战:从零还原某航空App的AES加密
javascript·后端