import { isFunction } from '@element-plus/utils'
import type { ObjectDirective } from 'vue'
export const REPEAT_INTERVAL = 100
export const REPEAT_DELAY = 600
const SCOPE = '_RepeatClick'
interface RepeatClickEl extends HTMLElement {
[SCOPE]: null | {
start?: (evt: MouseEvent) => void
clear?: () => void
}
}
export interface RepeatClickOptions {
interval?: number
delay?: number
handler: (...args: unknown[]) => unknown
}
/**
* 长按重复点击功能指令
* 用户可以通过长按按钮实现快速连续操作
* beforeMount和unmounted是vue自定义指令的生命周期钩子函数
*/
export const vRepeatClick: ObjectDirective<
RepeatClickEl,
RepeatClickOptions | RepeatClickOptions['handler']
> = {
/**
* beforeMount:在元素被挂载到DOM之前执行
* @param el 绑定指令的DOM元素
* @param binding 是一个对象,包含指令的绑定信息
*
* <template>
* <span v-repeat-click="decrease">按钮</span>
* </template>
* 这个示例中el就是span元素
*/
beforeMount(el, binding) {
const value = binding.value
console.log('binding.value', value)
// isFunction:判断value是否是一个函数
// 如果value是一个函数,解构空对象{},使用默认值
// 如果value不是一个函数,解构value对象,获取interval和delay
const { interval = REPEAT_INTERVAL, delay = REPEAT_DELAY } = isFunction(
value
)
? {}
: value
console.log('interval', interval)
console.log('delay', delay)
// 存储setInterval返回的定时器ID
let intervalId: ReturnType<typeof setInterval> | undefined
// 存储setTimeout返回的定时器ID
let delayId: ReturnType<typeof setTimeout> | undefined
// 处理函数,如果value是一个函数,则执行value函数,否则执行value.handler函数
const handler = () => (isFunction(value) ? value() : value.handler())
// clear 函数用于清除所有定时器,防止内存泄漏和重复执行
const clear = () => {
if (delayId) {
// 清除延迟定时器 强调延迟
clearTimeout(delayId)
delayId = undefined
}
if (intervalId) {
// 清除重复定时器 强调重复
clearInterval(intervalId)
intervalId = undefined
}
}
const start = (evt: MouseEvent) => {
// 如果鼠标按钮不是左键,则不进行任何操作
if (evt.button !== 0) return
// 清除之前的定时器 防止快速多次点击导致多个定时器同时运行
clear()
// 立即执行一次处理函数
handler()
/**
* 在document上添加事件监听器
* mouseup:监听鼠标松开事件
* clear:事件处理函数 清除延时定时器和重复定时器
* { once: true }:表示只执行一次后自动移除监听器
* addEventListener 只是注册一个监听器
* 这行代码执行后,立即继续执行下一行,不会等待 mouseup 事件发生
* */
document.addEventListener('mouseup', clear, { once: true })
delayId = setTimeout(() => {
intervalId = setInterval(() => {
handler()
}, interval)
}, delay)
}
/**
* 将start和clear函数存储到DOM元素上
* 这样做是为了在元素被销毁时,能够清除定时器,防止内存泄漏
*
* 存储的内容:
* el[SCOPE] = {
* start: (evt) => { ... }, // 按下鼠标时执行的函数
* clear: () => { ... } // 清除定时器的函数
* }
*
* el[SCOPE] = null
* el[SCOPE]是DOM元素的一个JavaScript对象属性(property),而不是HTML属性(attribute)
*/
el[SCOPE] = { start, clear }
el.addEventListener('mousedown', start)
},
unmounted(el) {
if (!el[SCOPE]) return
const { start, clear } = el[SCOPE]
if (start) {
el.removeEventListener('mousedown', start)
}
if (clear) {
clear()
document.removeEventListener('mouseup', clear)
}
el[SCOPE] = null
},
}
时间轴:
0ms - 用户按下鼠标
- start() 开始执行
- clear() 执行 ✅
- handler() 执行 ✅
- addEventListener 注册监听器 ✅
- setTimeout 创建延迟定时器 ✅
- 代码执行完毕
600ms - setTimeout 的回调执行
- setInterval 创建重复定时器
- handler() 开始每 100ms 执行
700ms - handler() 执行 ✅
800ms - handler() 执行 ✅
900ms - handler() 执行 ✅
1000ms - 用户松开鼠标
- document 的 mouseup 事件触发
- clear() 执行 ✅
- 所有定时器被清除
- handler() 停止执行
在element-plus的el-input-number源码中的实际应用
使用示例(可直接运行)
<template>
<div class="demo-container">
<h1>v-repeat-click 指令示例</h1>
<p class="intro">
v-repeat-click 是一个自定义指令,用于实现长按重复点击功能。
<br />
按下按钮后,会立即执行一次,然后延迟 600ms 后开始每 100ms
重复执行,松开鼠标后停止。
</p>
<!-- 示例 1:基本用法 - 直接传函数 -->
<div class="demo-section">
<h3>示例 1:基本用法(直接传函数)</h3>
<p class="desc">
使用默认配置:延迟 600ms,间隔 100ms
<br />
<code>v-repeat-click="increment"</code>
</p>
<div class="button-group">
<el-button v-repeat-click="increment1" type="primary" size="large">
长按我增加 (+1)
</el-button>
<el-button v-repeat-click="decrement1" type="danger" size="large">
长按我减少 (-1)
</el-button>
</div>
<div class="result">
<p>
当前值: <strong>{{ count1 }}</strong>
</p>
<p>
执行次数: <strong>{{ executeCount1 }}</strong>
</p>
<p class="status" :class="{ active: isActive1 }">
状态: {{ isActive1 ? '⏸️ 正在执行...' : '⏹️ 已停止' }}
</p>
</div>
</div>
<!-- 示例 2:配置对象用法 -->
<div class="demo-section">
<h3>示例 2:配置对象用法(自定义延迟和间隔)</h3>
<p class="desc">
自定义配置:延迟 300ms,间隔 50ms(更快)
<br />
<code
>v-repeat-click="{ handler: increment, interval: 50, delay: 300
}"</code
>
</p>
<div class="button-group">
<el-button
v-repeat-click="{ handler: increment2, interval: 50, delay: 300 }"
type="success"
size="large"
>
快速增加 (+1)
</el-button>
<el-button
v-repeat-click="{ handler: decrement2, interval: 50, delay: 300 }"
type="warning"
size="large"
>
快速减少 (-1)
</el-button>
</div>
<div class="result">
<p>
当前值: <strong>{{ count2 }}</strong>
</p>
<p>
执行次数: <strong>{{ executeCount2 }}</strong>
</p>
<p class="status" :class="{ active: isActive2 }">
状态: {{ isActive2 ? '⏸️ 正在执行...' : '⏹️ 已停止' }}
</p>
</div>
</div>
<!-- 示例 3:慢速配置 -->
<div class="demo-section">
<h3>示例 3:慢速配置(延迟更长,间隔更长)</h3>
<p class="desc">
慢速配置:延迟 1000ms,间隔 200ms(更慢)
<br />
<code
>v-repeat-click="{ handler: increment, interval: 200, delay: 1000
}"</code
>
</p>
<div class="button-group">
<el-button
v-repeat-click="{ handler: increment3, interval: 200, delay: 1000 }"
type="info"
size="large"
>
慢速增加 (+1)
</el-button>
<el-button
v-repeat-click="{ handler: decrement3, interval: 200, delay: 1000 }"
type="default"
size="large"
>
慢速减少 (-1)
</el-button>
</div>
<div class="result">
<p>
当前值: <strong>{{ count3 }}</strong>
</p>
<p>
执行次数: <strong>{{ executeCount3 }}</strong>
</p>
<p class="status" :class="{ active: isActive3 }">
状态: {{ isActive3 ? '⏸️ 正在执行...' : '⏹️ 已停止' }}
</p>
</div>
</div>
<!-- 示例 4:实际应用 - 数字输入框 -->
<div class="demo-section">
<h3>示例 4:实际应用 - el-input-number 组件</h3>
<p class="desc">这就是 v-repeat-click 在 Element Plus 中的实际应用场景</p>
<el-input-number v-model="inputNumber" :min="0" :max="100" />
<p style="margin-top: 10px">
当前值: <strong>{{ inputNumber }}</strong>
</p>
<p class="tip">💡 提示:长按增减按钮,可以看到和上面示例一样的效果</p>
</div>
<!-- 执行日志 -->
<div class="demo-section">
<h3>执行日志</h3>
<div class="log-container">
<div v-if="logs.length === 0" class="empty-log">
暂无日志,请尝试长按按钮
</div>
<div v-for="(log, index) in logs" :key="index" class="log-item">
<span class="log-time">{{ log.time }}</span>
<span class="log-message">{{ log.message }}</span>
</div>
</div>
<el-button @click="clearLogs" size="small" style="margin-top: 10px">
清空日志
</el-button>
</div>
</div>
</template>
<script lang="ts" setup>
import { getCurrentInstance, ref } from 'vue'
import { vRepeatClick } from '@element-plus/directives'
// 注册指令(在 play 环境中需要手动注册)
const instance = getCurrentInstance()
if (instance) {
instance.appContext.app.directive('repeat-click', vRepeatClick)
}
// 示例 1 的数据
const count1 = ref(0)
const executeCount1 = ref(0)
const isActive1 = ref(false)
const increment1 = () => {
count1.value++
executeCount1.value++
isActive1.value = true
addLog('示例1', '增加', count1.value)
// 模拟异步操作,延迟后重置状态
setTimeout(() => {
isActive1.value = false
}, 150)
}
const decrement1 = () => {
count1.value--
executeCount1.value++
isActive1.value = true
addLog('示例1', '减少', count1.value)
setTimeout(() => {
isActive1.value = false
}, 150)
}
// 示例 2 的数据
const count2 = ref(0)
const executeCount2 = ref(0)
const isActive2 = ref(false)
const increment2 = () => {
count2.value++
executeCount2.value++
isActive2.value = true
addLog('示例2', '快速增加', count2.value)
setTimeout(() => {
isActive2.value = false
}, 100)
}
const decrement2 = () => {
count2.value--
executeCount2.value++
isActive2.value = true
addLog('示例2', '快速减少', count2.value)
setTimeout(() => {
isActive2.value = false
}, 100)
}
// 示例 3 的数据
const count3 = ref(0)
const executeCount3 = ref(0)
const isActive3 = ref(false)
const increment3 = () => {
count3.value++
executeCount3.value++
isActive3.value = true
addLog('示例3', '慢速增加', count3.value)
setTimeout(() => {
isActive3.value = false
}, 250)
}
const decrement3 = () => {
count3.value--
executeCount3.value++
isActive3.value = true
addLog('示例3', '慢速减少', count3.value)
setTimeout(() => {
isActive3.value = false
}, 250)
}
// 示例 4 的数据
const inputNumber = ref(0)
// 日志功能
interface Log {
time: string
message: string
}
const logs = ref<Log[]>([])
const addLog = (example: string, action: string, value: number) => {
const now = new Date()
const time = `${now.getHours().toString().padStart(2, '0')}:${now
.getMinutes()
.toString()
.padStart(2, '0')}:${now.getSeconds().toString().padStart(2, '0')}.${now
.getMilliseconds()
.toString()
.padStart(3, '0')}`
logs.value.unshift({
time,
message: `[${example}] ${action} → 当前值: ${value}`,
})
// 只保留最近 50 条日志
if (logs.value.length > 50) {
logs.value = logs.value.slice(0, 50)
}
}
const clearLogs = () => {
logs.value = []
}
</script>
<style scoped>
.demo-container {
padding: 20px;
max-width: 1200px;
margin: 0 auto;
}
h1 {
color: #303133;
margin-bottom: 10px;
}
.intro {
color: #606266;
margin-bottom: 30px;
padding: 15px;
background: #f0f9ff;
border-left: 4px solid #409eff;
border-radius: 4px;
}
.demo-section {
margin-bottom: 40px;
padding: 20px;
border: 1px solid #dcdfe6;
border-radius: 4px;
background: #f5f7fa;
}
.demo-section h3 {
margin-top: 0;
color: #303133;
font-size: 18px;
}
.desc {
color: #606266;
margin: 10px 0 15px 0;
font-size: 14px;
line-height: 1.6;
}
.desc code {
background: #e4e7ed;
padding: 2px 6px;
border-radius: 3px;
font-family: 'Courier New', monospace;
font-size: 13px;
color: #e6a23c;
}
.button-group {
display: flex;
gap: 15px;
margin: 20px 0;
}
.result {
margin-top: 20px;
padding: 15px;
background: #fff;
border-radius: 4px;
border: 1px solid #e4e7ed;
}
.result p {
margin: 8px 0;
color: #606266;
font-size: 14px;
}
.result strong {
color: #303133;
font-size: 18px;
}
.status {
color: #909399;
font-weight: 500;
}
.status.active {
color: #67c23a;
animation: pulse 1s infinite;
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.6;
}
}
.tip {
color: #909399;
font-size: 13px;
margin-top: 10px;
font-style: italic;
}
.log-container {
max-height: 300px;
overflow-y: auto;
background: #fff;
border: 1px solid #e4e7ed;
border-radius: 4px;
padding: 10px;
}
.empty-log {
text-align: center;
color: #909399;
padding: 20px;
}
.log-item {
padding: 8px 12px;
margin-bottom: 5px;
border-radius: 4px;
background: #f5f7fa;
display: flex;
gap: 15px;
font-size: 13px;
}
.log-time {
color: #909399;
font-family: 'Courier New', monospace;
min-width: 120px;
}
.log-message {
color: #606266;
flex: 1;
}
</style>