💥 背景:谁在狂点按钮?
在项目开发中,有一个永恒不变的经典剧情:
测试点按钮:点点点点点点点
前端喊停:别点了别点了!
后端叹气:又是重复提交,数据炸了……
产品拍桌:这个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。