注册
web

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 检测:自动识别异步操作并在完成后恢复状态

工作流程


用户点击按钮

节流检查(1.5秒内只执行一次)

禁用按钮 + 添加 Loading 图标

执行绑定的函数

检测返回值是否为 Promise

Promise 完成后(或同步函数执行完)

移除 Loading + 恢复按钮状态

五、使用方法


1. 注册指令


// main.ts
import clickOnce from '@/directives/clickOnce'

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

2. 在组件中使用


<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>

六、优势对比


传统方式


<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 指令


<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 调用按钮
  • 支付确认按钮

不适合使用



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

九、指令源码


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


作者:码途进化论
来源:juejin.cn/post/7589839767816355878

0 个评论

要回复文章请先登录注册