element-plus源码解读6——v-repeat-click长按重复点击功能指令的实现

38 阅读7分钟
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源码中的实际应用

image.png

使用示例(可直接运行)

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