加了防抖就能防住重复提交?测试:我不答应!

💥 背景:谁在狂点按钮?

在项目开发中,有一个永恒不变的经典剧情

测试点按钮:点点点点点点点

前端喊停:别点了别点了!

后端叹气:又是重复提交,数据炸了……

产品拍桌:这个BUG你们解决下,不要互相甩锅。

为什么会这样?因为用户不知道点击是否生效,于是重复点击按钮,造成:

  • 表单重复提交

  • 下单接口被连续调用

  • 异步操作重复执行

  • 数据库多条冗余数据……

最初我们的想法是:加个防抖就完了嘛!

<el-button @click="handleClick">点我呀~</el-button>
const handleClick = debounce(() => {
  submitForm()
}, 1000)

这确实能挡住“快速点击”问题。但新的问题又来了👇

😰 Bug来了:防抖抵不过慢接口

测试又来了:

“我点了三下怎么还是提交了三次?”

开发:“你是 5 秒内点的?”(假设接口返回需要 5 秒)

“对啊接口那么慢,我哪知道点成功没…”

此时发现,防抖只控制了点击频率,但并没有绑定“请求状态”

  • 用户在请求未返回期间依旧可以点

  • 如果接口响应慢,用户能点 N 次

  • 每一次点击都走了 debounce → 成功触发请求

这不是防抖的锅,是逻辑设计不合理。

解决思路:控制按钮状态

我们需要的是一个思路:

点击按钮发送请求 → 请求未返回前按钮禁用 → 请求完成后恢复

于是我们有了一个升级方案:

<el-button @click="handleSubmit" :loading="loading">
  提交
</el-button>
const loading = ref(false)

const handleSubmit = async () => {
  loading.value = true
  try {
    await api.submit()
  } finally {
    loading.value = false
  }
}

这样确实解决了问题:loading 状态控制点击权限,请求回来后才能继续操作。

BUT!项目中几十个页面,每个都要写:

let loading = false

岂不是代码满天飞?有没有简洁的方式,于是:

🧙‍♂️ 优雅方式:封装成指令!

可以封装一个 Vue3 自定义指令,实现以下效果:

  • 统一处理按钮的 loading 状态

  • 执行异步任务期间自动禁用按钮

  • 动态添加/移除 loading 图标

  • 自动恢复状态,无需手动维护 loading 变量

🛠️ v-bLoading 指令实现

1、全局注册指令

import { createApp } from 'vue';
import App from './App.vue';
import { bLoading } from './directives/bLoading';
import './styles/index.less' // 引入样式

const app = createApp(App);

bLoading(app); // 注册
app.mount('#app');

2、指令实现

// 引入 Vue 类型
import type { App, DirectiveBinding } from 'vue'

let originalContentMap = new WeakMap<HTMLElement, { icon?: HTMLElement, text?: string }>()

// 插入 loading icon
function insertLoadingIcon(el: HTMLElement) {
  const span = document.createElement('span')
  span.className = 'btn-loading-icon'
  el.insertBefore(span, el.firstChild)
}

// 移除 loading icon
function removeLoadingIcon(el: HTMLElement) {
  const icon = el.querySelector('.btn-loading-icon')
  if (icon) el.removeChild(icon)
}

export function bLoading(app: App) {
  app.directive('bLoading', {
    mounted(el: HTMLElement, binding: DirectiveBinding<(done: () => void) => void>) {
      if (typeof binding.value !== 'function') {
        throw new Error('v-bLoading 的值必须是一个函数')
      }

      // 保存初始按钮内容
      const originalText = el.innerHTML
      originalContentMap.set(el, { text: originalText })

      el.addEventListener('click', async () => {
        if (el.hasAttribute('disabled')) return

        // 禁用按钮防止重复点击
        el.setAttribute('disabled', 'true')

        // 可选:替换按钮文字
        el.innerHTML = '处理中...'

        // 插入 loading 样式
        insertLoadingIcon(el)

        // 执行传入的异步函数
        try {
          await new Promise<void>((resolve) => {
            binding.value(resolve)
          })
        } finally {
          // 恢复按钮
          el.removeAttribute('disabled')
          removeLoadingIcon(el)

          const original = originalContentMap.get(el)
          if (original) el.innerHTML = original.text || ''
        }
      })
    },

    unmounted(el) {
      // 清理缓存
      originalContentMap.delete(el)
    },
  })
}

页面使用方式:只需要 1 行代码!

<template>
  <div>
    <ElButton type="primary" v-bLoading="(done: any) => submitForm(done)">
      提交
    </ElButton>
  </div>
</template>

<script setup lang="ts">
import { ElButton } from 'element-plus';

function submitForm(done: any) {
  setTimeout(() => {
    // 模拟异步任务
    console.log('提交')
    done() // 请求完成,恢复按钮状态
  }, 3000)
}
</script>

🎉 效果展示

  • 按钮点击后自动进入 loading 状态

  • 用户狂点无效(被禁用)

  • 请求返回自动恢复状态

  • 原图标自动恢复,体验丝滑

💬 总结

防抖不是万能的,loading 指令才是真正意义上的“提交防线”。

除此之外,还可以使用 组合式函数(Composition API)思路实现一个通用灵活的封装:useClickLock。

✨ 目标:useClickLock

实现一个组合式函数,用于控制按钮的点击行为,防止重复点击 + 自动处理 loading 状态

<template>
  <ElButton :loading="loading" :disabled="loading" @click="handleClick">
    {{ loading ? '处理中...' : '提交' }}
  </ElButton>
</template>

<script setup lang="ts">
import { useClickLock } from './composables/useClickLock'
import { ElButton } from 'element-plus'

const { loading, lockClick } = useClickLock()

async function handleClick() {
  await lockClick(async () => {
    // 模拟异步请求
    await new Promise(resolve => setTimeout(resolve, 2000))
    console.log('提交成功')
  })
}
</script>

实现:src/composables/useClickLock.ts

import { ref } from 'vue'

/**
 * 防重复点击 + loading 状态封装
 */
export function useClickLock() {
  const loading = ref(false)

  /**
   * 包装异步函数,自动设置 loading 状态并阻止重复点击
   */
  // lockClick 接收一个异步函数作为参数,负责自动处理点击锁。
  async function lockClick(fn: () => Promise<void>) {
    if (loading.value) return
    loading.value = true

    try {
      await fn()
    } finally {
      loading.value = false
    }
  }

  return {
    loading,
    lockClick,
  }
}

效果展示为:

💡方法对比
对比项useClickLock(Hook)v-bLoading(指令)
可读性清晰逻辑,易调试逻辑封装在指令内部,难定位问题
灵活性控制细粒度(loading 状态暴露)只能作用在 DOM 层
参数传递支持多个参数、回调、异常处理只能传一个 value
复用性可组合(比如结合表单锁、toast)可复用,但灵活度有限
适合场景中大型项目/多变逻辑简单快速处理按钮 loading

总结:简单重复操作用指令,复杂交互和逻辑建议用 hook。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值