自定义指令:禁止button连续点击调用接口

227 阅读2分钟

1、封装自定义指令

// utils/debounce.js
import { ref, isRef, unref, nextTick } from 'vue';

export const debounce = {
  mounted(el, binding) {
    // 获取指令参数和修饰符
    const { value, arg, modifiers } = binding;
    
    // 配置选项
    const options = {
      delay: modifiers.limit ? 500 : 3000,    // 防抖延迟时间(默认3秒)
      showLoading: !modifiers.noloading,     // 是否显示加载状态
      event: arg || 'click',                 // 事件类型(默认click)
      immediate: !!modifiers.immediate,      // 是否立即执行第一次点击
      resetOnError: !!modifiers.reset        // 错误时是否重置防抖状态
    };
    
    // 响应式加载状态
    const isLoading = ref(false);
    
    // 查找ElementPlus Button内部的button元素
    const findButtonEl = () => {
      if (el.tagName.toLowerCase() === 'button') return el;
      return el.querySelector('button.el-button') || el;
    };
    
    // 更新加载状态
    const updateLoadingState = (loading) => {
      isLoading.value = loading;
      const buttonEl = findButtonEl();
      
      if (!buttonEl || !options.showLoading) return;
      
      if (loading) {
        buttonEl.classList.add('is-loading');
        // 添加加载图标(如果没有)
        if (!buttonEl.querySelector('.el-icon-loading')) {
          const icon = document.createElement('i');
          icon.className = 'el-icon-loading';
          buttonEl.prepend(icon);
          
          // 调整原有内容的位置
          if (buttonEl.querySelector('.el-button__content')) {
            buttonEl.querySelector('.el-button__content').classList.add('ml-1');
          }
        }
      } else {
        buttonEl.classList.remove('is-loading');
        const icon = buttonEl.querySelector('.el-icon-loading');
        if (icon) icon.remove();
        
        if (buttonEl.querySelector('.el-button__content')) {
          buttonEl.querySelector('.el-button__content').classList.remove('ml-1');
        }
      }
    };
    
    // 处理不同类型的绑定值
    let handler;
    let lastCallTime = 0;
    
    if (typeof value === 'function') {
      // 情况1:直接绑定函数(无参数)
      handler = () => {
        if (isLoading.value) return;
        
        const now = Date.now();
        const allowCall = options.immediate || (now - lastCallTime > options.delay);
        
        if (allowCall) {
          lastCallTime = now;
          updateLoadingState(true);
          
          return Promise.resolve(value())
            .catch(error => {
              if (options.resetOnError) {
                // 错误时重置防抖状态
                lastCallTime = 0;
              }
              throw error;
            })
            .finally(() => {
              // 使用setTimeout确保延迟后才重置加载状态
              setTimeout(() => updateLoadingState(false), options.delay);
            });
        }
      };
    } else if (typeof value === 'object' && value !== null) {
      // 情况2:通过对象传递函数和参数
      const { handler: fn, params = [] } = value;
      
      if (typeof fn !== 'function') {
        console.error('v-debounce对象格式错误:handler必须是函数');
        return;
      }
      
      handler = () => {
        if (isLoading.value) return;
        
        const now = Date.now();
        const allowCall = options.immediate || (now - lastCallTime > options.delay);
        
        if (allowCall) {
          lastCallTime = now;
          updateLoadingState(true);
          
          // 解析参数(支持ref和普通值)
          const resolvedParams = params.map(param => 
            isRef(param) ? unref(param) : param
          );
          
          return Promise.resolve(fn(...resolvedParams))
            .catch(error => {
              if (options.resetOnError) {
                lastCallTime = 0;
              }
              throw error;
            })
            .finally(() => {
              setTimeout(() => updateLoadingState(false), options.delay);
            });
        }
      };
    } else {
      console.error('v-debounce指令需要绑定函数或函数配置对象');
      return;
    }
    
    // 绑定事件监听器
    el.addEventListener(options.event, handler);
    
    // 保存引用以便在unmounted时移除
    el.__debounceHandler = handler;
    el.__debounceEvent = options.event;
  },
  
  unmounted(el) {
    // 清理事件监听器
    if (el.__debounceHandler) {
      el.removeEventListener(el.__debounceEvent, el.__debounceHandler);
      delete el.__debounceHandler;
      delete el.__debounceEvent;
    }
  }
};

2、全局注册指令

// main.js
import { createApp } from 'vue';
import ElementPlus from 'element-plus';
import 'element-plus/dist/index.css';
import App from './App.vue';
import { debounce } from './utils/debounce';

const app = createApp(App);

app.use(ElementPlus);
app.directive('debounce', debounce); // 全局注册防抖指令

app.mount('#app');

3、两种调用方式示例

方式 1:直接调用函数(无参数)

<el-button v-debounce="submitForm" type="primary">
  提交
</el-button>

const submitForm = () => {
  console.log('提交表单');
  // 执行API请求
  return api.submit().then(() => {
    console.log('提交成功');
  });
};

方式 2:带参数调用函数

<el-button v-debounce="{ handler: submitWithForm, params: [formRef] }" type="primary">
  带参提交
</el-button>

const formRef = ref(null);

const submitWithForm = (form) => {
  return new Promise((resolve) => {
    form.validate((valid) => {
      if (valid) {
        // 执行API请求
        api.submit(form.model).then(resolve);
      }
    });
  });
};

4、高级用法和修饰符

自定义防抖时间

<!-- 使用.limit修饰符设置500ms短间隔 -->
<el-button v-debounce.limit="submitQuickly" type="primary">
  快速提交
</el-button>

禁用加载状态

<!-- 使用.noloading修饰符禁用加载状态 -->
<el-button v-debounce.noloading="submitSilently" type="primary">
  静默提交
</el-button>

立即执行第一次点击

<!-- 使用.immediate修饰符立即执行第一次点击 -->
<el-button v-debounce.immediate="submitNow" type="primary">
  立即提交
</el-button>

错误时重置防抖状态

<!-- 使用.reset修饰符在错误时重置防抖状态 -->
<el-button v-debounce.reset="submitWithRetry" type="primary">
  可重试提交
</el-button>