Vue3 防重复点击指令 - clickOnce

Vue3 防重复点击指令 - clickOnce

一、问题背景

在实际的 Web 应用开发中,我们经常会遇到以下问题:

  1. 用户快速多次点击提交按钮:导致重复提交表单,产生多条相同数据
  2. 异步请求未完成时再次点击:可能导致数据不一致或服务器压力增大
  3. 用户体验不佳:没有明确的加载状态反馈,用户不知道操作是否正在进行

这些问题在以下场景中尤为常见:

  • 表单提交(注册、登录、创建订单等)
  • 数据保存操作
  • 文件上传
  • 支付操作
  • API 调用

二、解决方案

clickOnce 指令通过以下机制解决上述问题:

1. 节流机制

使用 @vueuse/coreuseThrottleFn,在 1.5 秒内只允许执行一次点击操作。

2. 按钮禁用

点击后立即禁用按钮,防止用户再次点击。

3. 视觉反馈

自动添加 Element Plus 的 Loading 图标,让用户明确知道操作正在进行中。

4. 智能恢复

  • 如果绑定的函数返回 Promise(异步操作),则在 Promise 完成后自动恢复按钮状态
  • 如果是同步操作,则立即恢复

三、核心特性

自动防重复点击 :1.5秒节流时间

自动 Loading 状态 :无需手动管理 loading 变量

支持异步操作 :自动检测 Promise 并在完成后恢复

优雅的清理机制 :组件卸载时自动清理事件监听

类型安全:完整的 TypeScript 支持

四、技术实现

关键技术点

  1. Vue 3 自定义指令 :使用 Directive 类型定义
  2. VueUse 节流useThrottleFn 提供稳定的节流功能
  3. 动态组件渲染 :使用 createVNoderender 动态创建 Loading 图标
  4. Promise 检测:自动识别异步操作并在完成后恢复状态

工作流程

javascript 复制代码
用户点击按钮
    ↓
节流检查(1.5秒内只执行一次)
    ↓
禁用按钮 + 添加 Loading 图标
    ↓
执行绑定的函数
    ↓
检测返回值是否为 Promise
    ↓
Promise 完成后(或同步函数执行完)
    ↓
移除 Loading + 恢复按钮状态

五、使用方法

1. 注册指令

typescript 复制代码
// main.ts
import clickOnce from '@/directives/clickOnce'

app.directive('click-once', clickOnce)

2. 在组件中使用

vue 复制代码
<template>
  <!-- 异步操作示例 -->
  <el-button 
    type="primary" 
    v-click-once="handleSubmit">
    提交表单
  </el-button>

  <!-- 带参数的异步操作 -->
  <el-button 
    type="success" 
    v-click-once="() => handleSave(formData)">
    保存数据
  </el-button>
</template>

<script setup lang="ts">
const handleSubmit = async () => {
  // 模拟 API 调用
  await api.submitForm(formData)
  ElMessage.success('提交成功')
}

const handleSave = async (data: any) => {
  await api.saveData(data)
  ElMessage.success('保存成功')
}
</script>

六、优势对比

传统方式

vue 复制代码
<template>
  <el-button 
    type="primary" 
    :loading="loading"
    :disabled="loading"
    @click="handleSubmit">
    提交
  </el-button>
</template>

<script setup lang="ts">
const loading = ref(false)

const handleSubmit = async () => {
  if (loading.value) return
  
  loading.value = true
  try {
    await api.submit()
  } finally {
    loading.value = false
  }
}
</script>

问题

  • 需要手动管理 loading 状态
  • 每个按钮都要写重复代码
  • 容易遗漏 finally 清理逻辑

使用 clickOnce 指令

vue 复制代码
<template>
  <el-button 
    type="primary" 
    v-click-once="handleSubmit">
    提交
  </el-button>
</template>

<script setup lang="ts">
const handleSubmit = async () => {
  await api.submit()
}
</script>

优势

  • 代码简洁,无需管理状态
  • 自动处理 loading 和禁用
  • 统一的用户体验

七、注意事项

  1. 仅用于异步操作:该指令主要为异步操作设计,同步操作会立即恢复
  2. 绑定函数必须返回 Promise:对于异步操作,确保函数返回 Promise
  3. 节流时间固定 :当前节流时间为 1.5 秒,可根据需求调整 THROTTLE_TIME 常量
  4. 依赖 Element Plus:使用了 Element Plus 的 Loading 图标和样式

八、适用场景

适合使用

  • 表单提交按钮
  • 数据保存按钮
  • 文件上传按钮
  • API 调用按钮
  • 支付确认按钮

不适合使用

  • 普通导航按钮
  • 切换/开关按钮
  • 需要快速连续点击的场景(如计数器)

九、指令源码

typescript 复制代码
import type { Directive } from 'vue'
import { createVNode, render } from 'vue'
import { useThrottleFn } from '@vueuse/core'
import { Loading } from '@element-plus/icons-vue'

const THROTTLE_TIME = 1500

const clickOnce: Directive<HTMLButtonElement, () => Promise<unknown> | void> = {
  mounted(el, binding) {
    const handleClick = useThrottleFn(
      () => {
        // 如果元素已禁用,直接返回(双重保险)
        if (el.disabled) return

        // 禁用按钮
        el.disabled = true
        // 添加 loading 状态
        el.classList.add('is-loading')

        // 创建 loading 图标容器
        const loadingIconContainer = document.createElement('i')
        loadingIconContainer.className = 'el-icon is-loading'

        // 使用 Vue 的 createVNode 和 render 来渲染 Loading 组件
        const vnode = createVNode(Loading)
        render(vnode, loadingIconContainer)

        // 将 loading 图标插入到按钮开头
        el.insertBefore(loadingIconContainer, el.firstChild)

        // 将 loading 图标存储到元素上,以便后续移除
        ;(el as any)._loadingIcon = loadingIconContainer
        ;(el as any)._loadingVNode = vnode

        // 执行绑定的函数(应返回 Promise 或普通函数)
        const result = binding.value?.()

        const removeLoading = () => {
          el.disabled = false
          // 移除 loading 状态
          el.classList.remove('is-loading')
          const icon = (el as any)._loadingIcon
          if (icon && icon.parentNode === el) {
            // 卸载 Vue 组件
            render(null, icon)
            el.removeChild(icon)
            delete (el as any)._loadingIcon
            delete (el as any)._loadingVNode
          }
        }

        // 如果返回的是 Promise,则在完成时恢复;否则立即恢复
        if (result instanceof Promise) {
          result.finally(removeLoading)
        } else {
          // 非异步操作,立即恢复(或根据需求决定是否恢复)
          // 通常建议只用于异步操作,所以这里也可以不处理,或给出警告
          removeLoading()
        }
      },
      THROTTLE_TIME,
    )

    // 将 throttled 函数存储到元素上,以便在 unmount 时移除
    ;(el as any)._throttledClick = handleClick
    el.addEventListener('click', handleClick)
  },

  beforeUnmount(el) {
    const handleClick = (el as any)._throttledClick
    if (handleClick) {
      el.removeEventListener('click', handleClick)
      // 取消可能还在等待的 throttle
      handleClick.cancel?.()
      delete (el as any)._throttledClick
    }
  },
}

export default clickOnce

十、总结

clickOnce 指令通过封装防重复点击逻辑,提供了一个开箱即用的解决方案,让开发者可以专注于业务逻辑,而不用担心重复点击的问题。它结合了节流、状态管理和视觉反馈,为用户提供了更好的交互体验。

相关推荐
岳哥i3 小时前
vue鼠标单机复制文本
javascript
jacGJ3 小时前
记录学习--文件读写
java·前端·学习
毕设源码-赖学姐4 小时前
【开题答辩全过程】以 基于WEB的实验室开放式管理系统的设计与实现为例,包含答辩的问题和答案
前端
幻云20104 小时前
Python深度学习:从筑基到登仙
前端·javascript·vue.js·人工智能·python
我即将远走丶或许也能高飞6 小时前
vuex 和 pinia 的学习使用
开发语言·前端·javascript
钟离墨笺6 小时前
Go语言--2go基础-->基本数据类型
开发语言·前端·后端·golang
爱吃泡芙的小白白6 小时前
Vue 3 核心原理与实战:从响应式到企业级应用
前端·javascript·vue.js
卓怡学长7 小时前
m115乐购游戏商城系统
java·前端·数据库·spring boot·spring·游戏
码上成长7 小时前
JavaScript 数组合并性能优化:扩展运算符 vs concat vs 循环 push
开发语言·javascript·ecmascript
老陈聊架构7 小时前
『AI辅助Skill』掌握三大AI设计Skill:前端独立完成产品设计全流程
前端·人工智能·claude·skill