Vue3 + TS 实现长按指令 v-longPress:优雅解决移动端/PC端长按交互需求

🔥 Vue3 + TS 实现长按指令 v-longPress:优雅解决移动端/PC端长按交互需求

在前端开发中,长按(Long Press)是高频交互场景(如移动端删除操作、PC端右键菜单、批量操作触发等)。原生HTML没有直接的长按事件,通常需要通过 mousedown/touchstart 结合定时器实现。本文将教你基于 Vue3 + TypeScript 实现一个功能完善、体验友好、跨端兼容v-longPress 自定义指令,支持自定义长按时长、触发回调、取消触发等特性,开箱即用。

🎯 指令核心特性

  • ✅ 跨端兼容:同时支持PC端(鼠标长按)和移动端(触摸长按)
  • ✅ 自定义配置:支持自定义长按时长、触发回调、取消回调
  • ✅ 防误触优化:按下后移动超过阈值自动取消长按
  • ✅ 完整 TypeScript 类型定义,开发提示友好
  • ✅ 支持指令参数动态更新,适配动态业务场景
  • ✅ 自动清理定时器/事件监听,无内存泄漏
  • ✅ 支持阻止默认行为/冒泡,适配复杂交互场景

📁 完整代码实现(v-longPress.ts)

typescript 复制代码
// directives/v-longPress.ts
import type { ObjectDirective, DirectiveBinding, App } from 'vue'

/**
 * 长按指令配置接口
 */
export interface LongPressOptions {
  /** 长按触发时长(ms),默认500ms */
  duration?: number
  /** 长按触发的回调函数 */
  handler: (e: MouseEvent | TouchEvent) => void
  /** 长按取消的回调函数(可选) */
  cancelHandler?: (e: MouseEvent | TouchEvent) => void
  /** 移动阈值(px),超过则取消长按,默认5px */
  moveThreshold?: number
  /** 是否阻止默认行为,默认true */
  preventDefault?: boolean
  /** 是否阻止事件冒泡,默认false */
  stopPropagation?: boolean
}

/**
 * 扩展元素属性,存储长按相关状态
 */
interface LongPressElement extends HTMLElement {
  _longPress?: {
    options: LongPressOptions
    timer: number | null          // 长按定时器
    startX: number                // 按下时X坐标
    startY: number                // 按下时Y坐标
    isPressing: boolean           // 是否正在长按中
    // 事件处理函数(绑定this,便于移除监听)
    mouseDownHandler: (e: MouseEvent) => void
    touchStartHandler: (e: TouchEvent) => void
    mouseUpHandler: (e: MouseEvent) => void
    touchEndHandler: (e: TouchEvent) => void
    mouseMoveHandler: (e: MouseEvent) => void
    touchMoveHandler: (e: TouchEvent) => void
    mouseLeaveHandler: (e: MouseEvent) => void
  }
}

/**
 * 默认配置
 */
const DEFAULT_OPTIONS: Omit<LongPressOptions, 'handler'> = {
  duration: 500,
  moveThreshold: 5,
  preventDefault: true,
  stopPropagation: false
}

/**
 * 计算两点之间的距离
 * @param x1 起始X坐标
 * @param y1 起始Y坐标
 * @param x2 结束X坐标
 * @param y2 结束Y坐标
 * @returns 距离(px)
 */
const calculateDistance = (x1: number, y1: number, x2: number, y2: number): number => {
  return Math.sqrt(Math.pow(x2 - x1, 2) + Math.pow(y2 - y1, 2))
}

/**
 * 清除长按相关状态和定时器
 * @param el 目标元素
 * @param e 触发事件(可选,用于取消回调)
 */
const clearLongPressState = (el: LongPressElement, e?: MouseEvent | TouchEvent) => {
  const longPressData = el._longPress
  if (!longPressData) return

  // 清除定时器
  if (longPressData.timer) {
    clearTimeout(longPressData.timer)
    longPressData.timer = null
  }

  // 触发取消回调
  if (longPressData.isPressing && longPressData.options.cancelHandler && e) {
    longPressData.options.cancelHandler(e)
  }

  // 重置状态
  longPressData.isPressing = false
}

/**
 * 绑定事件监听
 * @param el 目标元素
 */
const bindEvents = (el: LongPressElement) => {
  const longPressData = el._longPress
  if (!longPressData) return

  // PC端事件
  el.addEventListener('mousedown', longPressData.mouseDownHandler)
  el.addEventListener('mouseup', longPressData.mouseUpHandler)
  el.addEventListener('mousemove', longPressData.mouseMoveHandler)
  el.addEventListener('mouseleave', longPressData.mouseLeaveHandler)

  // 移动端事件
  el.addEventListener('touchstart', longPressData.touchStartHandler)
  el.addEventListener('touchend', longPressData.touchEndHandler)
  el.addEventListener('touchmove', longPressData.touchMoveHandler)
}

/**
 * 解绑事件监听
 * @param el 目标元素
 */
const unbindEvents = (el: LongPressElement) => {
  const longPressData = el._longPress
  if (!longPressData) return

  // PC端事件
  el.removeEventListener('mousedown', longPressData.mouseDownHandler)
  el.removeEventListener('mouseup', longPressData.mouseUpHandler)
  el.removeEventListener('mousemove', longPressData.mouseMoveHandler)
  el.removeEventListener('mouseleave', longPressData.mouseLeaveHandler)

  // 移动端事件
  el.removeEventListener('touchstart', longPressData.touchStartHandler)
  el.removeEventListener('touchend', longPressData.touchEndHandler)
  el.removeEventListener('touchmove', longPressData.touchMoveHandler)
}

/**
 * 初始化长按事件处理函数
 * @param el 目标元素
 */
const initHandlers = (el: LongPressElement) => {
  const longPressData = el._longPress
  if (!longPressData) return
  const { options } = longPressData

  // PC端鼠标按下
  longPressData.mouseDownHandler = (e: MouseEvent) => {
    // 阻止右键菜单(仅左键触发)
    if (e.button !== 0) return

    // 阻止默认行为/冒泡
    if (options.preventDefault) e.preventDefault()
    if (options.stopPropagation) e.stopPropagation()

    // 记录起始坐标
    longPressData.startX = e.clientX
    longPressData.startY = e.clientY
    longPressData.isPressing = true

    // 设置长按定时器
    longPressData.timer = window.setTimeout(() => {
      if (longPressData.isPressing) {
        options.handler(e)
        longPressData.isPressing = false
      }
    }, options.duration!) as unknown as number
  }

  // 移动端触摸开始
  longPressData.touchStartHandler = (e: TouchEvent) => {
    // 阻止默认行为/冒泡
    if (options.preventDefault) e.preventDefault()
    if (options.stopPropagation) e.stopPropagation()

    // 记录起始坐标(取第一个触摸点)
    const touch = e.touches[0]
    longPressData.startX = touch.clientX
    longPressData.startY = touch.clientY
    longPressData.isPressing = true

    // 设置长按定时器
    longPressData.timer = window.setTimeout(() => {
      if (longPressData.isPressing) {
        options.handler(e)
        longPressData.isPressing = false
      }
    }, options.duration!) as unknown as number
  }

  // PC端鼠标抬起
  longPressData.mouseUpHandler = (e: MouseEvent) => {
    clearLongPressState(el, e)
  }

  // 移动端触摸结束
  longPressData.touchEndHandler = (e: TouchEvent) => {
    clearLongPressState(el, e)
  }

  // PC端鼠标移动
  longPressData.mouseMoveHandler = (e: MouseEvent) => {
    if (!longPressData.isPressing) return

    // 计算移动距离,超过阈值取消长按
    const distance = calculateDistance(
      longPressData.startX,
      longPressData.startY,
      e.clientX,
      e.clientY
    )

    if (distance > options.moveThreshold!) {
      clearLongPressState(el, e)
    }
  }

  // 移动端触摸移动
  longPressData.touchMoveHandler = (e: TouchEvent) => {
    if (!longPressData.isPressing) return

    // 计算移动距离,超过阈值取消长按
    const touch = e.touches[0]
    const distance = calculateDistance(
      longPressData.startX,
      longPressData.startY,
      touch.clientX,
      touch.clientY
    )

    if (distance > options.moveThreshold!) {
      clearLongPressState(el, e)
    }
  }

  // PC端鼠标离开元素
  longPressData.mouseLeaveHandler = (e: MouseEvent) => {
    clearLongPressState(el, e)
  }
}

/**
 * 清理所有长按相关资源
 * @param el 目标元素
 */
const cleanup = (el: LongPressElement) => {
  // 清除状态和定时器
  clearLongPressState(el)
  
  // 解绑事件
  unbindEvents(el)
  
  // 删除扩展属性
  delete el._longPress
}

/**
 * v-longPress 自定义指令实现
 */
export const longPressDirective: ObjectDirective<LongPressElement, LongPressOptions | (() => void)> = {
  /**
   * 指令挂载时初始化
   */
  mounted(el: LongPressElement, binding: DirectiveBinding<LongPressOptions | (() => void)>) {
    // 1. 解析指令参数
    let options: LongPressOptions = {
      ...DEFAULT_OPTIONS,
      handler: () => {}
    }

    if (typeof binding.value === 'function') {
      // 直接传函数:作为长按触发回调,使用默认配置
      options.handler = binding.value
    } else if (typeof binding.value === 'object' && binding.value !== null) {
      // 传对象:合并配置
      options = {
        ...DEFAULT_OPTIONS,
        ...binding.value
      }
    }

    // 校验必填项
    if (typeof options.handler !== 'function') {
      console.warn('[v-longPress] 必须指定有效的长按回调函数')
      return
    }

    // 2. 初始化长按状态
    el._longPress = {
      options,
      timer: null,
      startX: 0,
      startY: 0,
      isPressing: false,
      mouseDownHandler: () => {},
      touchStartHandler: () => {},
      mouseUpHandler: () => {},
      touchEndHandler: () => {},
      mouseMoveHandler: () => {},
      touchMoveHandler: () => {},
      mouseLeaveHandler: () => {}
    }

    // 3. 初始化事件处理函数
    initHandlers(el)

    // 4. 绑定事件监听
    bindEvents(el)

    // 5. 添加长按样式提示
    el.style.cursor = 'pointer'
  },

  /**
   * 指令更新时处理参数变化
   */
  updated(el: LongPressElement, binding: DirectiveBinding<LongPressOptions | (() => void)>) {
    // 先清理旧配置
    cleanup(el)
    
    // 重新初始化
    this.mounted(el, binding)
  },

  /**
   * 指令卸载时清理资源
   */
  unmounted(el: LongPressElement) {
    cleanup(el)
  }
}

/**
 * 全局注册长按指令
 * @param app Vue应用实例
 * @param directiveName 指令名称,默认longPress
 */
export const setupLongPressDirective = (app: App, directiveName: string = 'longPress') => {
  app.directive(directiveName, longPressDirective)
}

// TypeScript 类型扩展
declare module 'vue' {
  export interface ComponentCustomDirectives {
    longPress: typeof longPressDirective
  }
}

🚀 快速上手

1. 全局注册指令(main.ts)

在 Vue3 入口文件中注册指令,全局可用:

typescript 复制代码
// main.ts
import { createApp } from 'vue'
import App from './App.vue'
import { setupLongPressDirective } from './directives/v-longPress'

const app = createApp(App)

// 注册长按指令(默认名称v-longPress)
setupLongPressDirective(app)

app.mount('#app')

2. 基础使用(直接传回调)

最简单的用法:直接传递长按触发的回调函数,使用默认配置(500ms长按时长):

vue 复制代码
<template>
  <!-- PC端鼠标长按/移动端触摸长按触发 -->
  <button v-longPress="handleLongPress">
    长按500ms触发
  </button>
</template>

<script setup lang="ts">
// 长按触发回调
const handleLongPress = (e: MouseEvent | TouchEvent) => {
  console.log('长按触发', e)
  alert('长按成功!')
}
</script>

3. 高级使用(自定义配置)

通过对象参数配置完整的长按规则,支持自定义时长、移动阈值、取消回调等:

vue 复制代码
<template>
  <div class="card">
    <div 
      class="delete-btn"
      v-longPress="{
        duration: 800,          // 长按800ms触发
        moveThreshold: 10,      // 移动超过10px取消
        handler: handleDelete,  // 长按触发回调
        cancelHandler: handleCancel, // 长按取消回调
        preventDefault: true,   // 阻止默认行为
        stopPropagation: true   // 阻止事件冒泡
      }"
    >
      长按删除(800ms)
    </div>
  </div>
</template>

<script setup lang="ts">
// 长按删除回调
const handleDelete = () => {
  console.log('执行删除操作')
  if (confirm('确定要删除这条数据吗?')) {
    // 实际业务逻辑:调用删除接口
    alert('删除成功!')
  }
}

// 长按取消回调
const handleCancel = () => {
  console.log('长按取消')
  // 可添加取消提示,如:
  // ElMessage.info('已取消删除')
}
</script>

<style scoped>
.card {
  padding: 20px;
  border: 1px solid #e5e7eb;
  border-radius: 8px;
  width: 300px;
  margin: 20px;
}
.delete-btn {
  color: #ff4d4f;
  cursor: pointer;
  padding: 8px 16px;
  border: 1px solid #ff4d4f;
  border-radius: 4px;
  display: inline-block;
}
.delete-btn:hover {
  background: #fff2f2;
}
</style>

4. 动态配置长按指令

适配动态变化的长按配置(如根据不同状态调整长按时长):

vue 复制代码
<template>
  <div>
    <div>
      <label>长按时长(ms):</label>
      <input 
        type="number" 
        v-model.number="pressDuration"
        min="100"
        max="2000"
        step="100"
      />
    </div>
    
    <button 
      v-longPress="{
        duration: pressDuration,
        handler: handleDynamicLongPress,
        cancelHandler: handleDynamicCancel
      }"
    >
      动态配置长按按钮
    </button>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'

// 动态长按时长
const pressDuration = ref(500)

// 动态长按回调
const handleDynamicLongPress = () => {
  alert(`长按${pressDuration.value}ms触发成功!`)
}

// 动态取消回调
const handleDynamicCancel = () => {
  console.log(`长按${pressDuration.value}ms取消`)
}
</script>

5. 结合UI库实现长按菜单

实际业务中常结合下拉菜单实现长按操作(以Element Plus为例):

vue 复制代码
<template>
  <div>
    <el-button 
      ref="btnRef"
      v-longPress="{
        handler: openContextMenu,
        duration: 600
      }"
    >
      长按打开操作菜单
    </el-button>
    
    <!-- 自定义上下文菜单 -->
    <el-dropdown 
      ref="dropdownRef"
      :visible="menuVisible"
      @visible-change="menuVisible = false"
    >
      <el-dropdown-menu>
        <el-dropdown-item @click="handleEdit">编辑</el-dropdown-item>
        <el-dropdown-item @click="handleCopy">复制</el-dropdown-item>
        <el-dropdown-item @click="handleDelete" divided>删除</el-dropdown-item>
      </el-dropdown-menu>
    </el-dropdown>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { ElMessage } from 'element-plus'

const btnRef = ref<HTMLButtonElement>(null)
const dropdownRef = ref()
const menuVisible = ref(false)

// 打开上下文菜单
const openContextMenu = () => {
  if (btnRef.value && dropdownRef.value) {
    const rect = btnRef.value.getBoundingClientRect()
    // 设置菜单位置
    dropdownRef.value.$el.style.position = 'absolute'
    dropdownRef.value.$el.style.top = `${rect.bottom + 10}px`
    dropdownRef.value.$el.style.left = `${rect.left}px`
    menuVisible.value = true
  }
}

// 编辑操作
const handleEdit = () => {
  ElMessage.info('执行编辑操作')
  menuVisible.value = false
}

// 复制操作
const handleCopy = () => {
  ElMessage.success('复制成功')
  menuVisible.value = false
}

// 删除操作
const handleDelete = () => {
  ElMessage.warning('执行删除操作')
  menuVisible.value = false
}
</script>

🔧 核心知识点解析

1. 长按实现原理

长按的核心逻辑是**"按下计时 + 抬起/移动取消"**:

  1. 按下(mousedown/touchstart):记录起始坐标,启动定时器,达到指定时长触发回调。
  2. 抬起/离开(mouseup/touchend/mouseleave):清除定时器,取消长按。
  3. 移动(mousemove/touchmove):计算移动距离,超过阈值自动取消长按,防止误触。

2. 跨端兼容处理

  • PC端 :监听 mousedown/mouseup/mousemove/mouseleave 事件。
  • 移动端 :监听 touchstart/touchend/touchmove 事件。
  • 统一的坐标计算逻辑,兼容鼠标和触摸事件的坐标获取方式。

3. 防误触优化

通过 moveThreshold 移动阈值实现防误触:

  • 默认阈值为5px,按下后移动超过该距离自动取消长按。
  • 计算两点间距离使用勾股定理:distance=(x2−x1)2+(y2−y1)2distance = \sqrt{(x2-x1)^2 + (y2-y1)^2}distance=(x2−x1)2+(y2−y1)2 。

4. 内存泄漏防护

  • unmounted 钩子中清理定时器、解绑所有事件监听。
  • updated 钩子中先清理旧配置,再初始化新配置。
  • 使用元素扩展属性存储状态,卸载时删除属性释放内存。

5. TypeScript 类型优化

  • 定义 LongPressOptions 接口,明确配置项类型。
  • 扩展 HTMLElement 类型,添加长按状态属性。
  • 支持两种参数类型(函数/对象),类型推导自动适配。

📋 配置项说明

配置项 类型 默认值 说明
duration number 500 长按触发时长,单位ms
handler (e: MouseEvent | TouchEvent) => void - 长按触发的回调函数(必填)
cancelHandler (e: MouseEvent | TouchEvent) => void - 长按取消的回调函数(可选)
moveThreshold number 5 移动阈值,超过则取消长按,单位px
preventDefault boolean true 是否阻止默认行为(如移动端长按弹出菜单)
stopPropagation boolean false 是否阻止事件冒泡

🎯 常见使用场景

场景1:移动端列表项长按操作

vue 复制代码
<template>
  <div class="list">
    <div 
      class="list-item"
      v-for="item in list"
      :key="item.id"
      v-longPress="{
        duration: 600,
        handler: () => showActionSheet(item),
        cancelHandler: () => hideActionSheet()
      }"
    >
      {{ item.name }}
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'

const list = ref([
  { id: 1, name: '微信' },
  { id: 2, name: '支付宝' },
  { id: 3, name: '抖音' }
])

// 显示操作面板
const showActionSheet = (item: any) => {
  console.log('长按触发', item)
  // 实际开发中可调用移动端ActionSheet组件
  alert(`长按${item.name},显示操作菜单`)
}

// 隐藏操作面板
const hideActionSheet = () => {
  console.log('长按取消')
}
</script>

<style scoped>
.list {
  width: 300px;
  border: 1px solid #e5e7eb;
  border-radius: 8px;
}
.list-item {
  padding: 12px 16px;
  border-bottom: 1px solid #f0f0f0;
  cursor: pointer;
}
.list-item:last-child {
  border-bottom: none;
}
.list-item:hover {
  background: #f5f5f5;
}
</style>

场景2:PC端长按批量选择

vue 复制代码
<template>
  <div class="table-container">
    <el-table 
      :data="tableData"
      @row-click="handleRowClick"
    >
      <el-table-column 
        label="名称"
        prop="name"
      >
        <template #default="scope">
          <div 
            v-longPress="{
              handler: () => toggleSelect(scope.row),
              duration: 400
            }"
          >
            {{ scope.row.name }}
          </div>
        </template>
      </el-table-column>
      <el-table-column label="操作" width="100">
        <template #default="scope">
          <el-button size="small" @click="handleEdit(scope.row)">编辑</el-button>
        </template>
      </el-table-column>
    </el-table>
    
    <div class="selected-count" v-if="selectedRows.length">
      已选择 {{ selectedRows.length }} 条数据
      <el-button size="small" @click="selectedRows = []">清空选择</el-button>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'

// 模拟表格数据
const tableData = ref([
  { id: 1, name: '数据1' },
  { id: 2, name: '数据2' },
  { id: 3, name: '数据3' }
])

// 选中的行
const selectedRows = ref<any[]>([])

// 长按切换选择状态
const toggleSelect = (row: any) => {
  const index = selectedRows.value.findIndex(item => item.id === row.id)
  if (index > -1) {
    selectedRows.value.splice(index, 1)
  } else {
    selectedRows.value.push(row)
  }
}

// 点击行(取消批量选择)
const handleRowClick = (row: any) => {
  selectedRows.value = []
}

// 编辑操作
const handleEdit = (row: any) => {
  console.log('编辑', row)
}
</script>

<style scoped>
.table-container {
  width: 500px;
  margin: 20px;
}
.selected-count {
  margin-top: 10px;
  padding: 8px;
  background: #f5f5f5;
  border-radius: 4px;
}
</style>

🚨 注意事项

  1. 移动端默认行为 :长按会触发浏览器默认菜单(如复制、保存图片),需设置 preventDefault: true 阻止。
  2. 触摸事件穿透 :移动端需注意事件穿透问题,可结合 pointer-events: none 处理。
  3. 性能优化:避免在长按回调中执行复杂计算,建议使用防抖/节流。
  4. 兼容性
    • PC端:所有现代浏览器均支持。
    • 移动端:iOS Safari/Android Chrome 均支持,低版本安卓需测试。
  5. 右键菜单冲突 :PC端长按左键触发,避免与右键菜单冲突,可通过 e.button !== 0 过滤右键。

📌 总结

本文实现的 v-longPress 指令具备以下核心优势:

  1. 跨端兼容:同时支持PC端和移动端,一套代码适配多端。
  2. 体验优秀:内置防误触机制,支持自定义移动阈值,避免误触发。
  3. 配置灵活:支持自定义长按时长、回调函数、事件行为,适配各种业务场景。
  4. 类型安全:基于TypeScript开发,类型提示完善,减少开发错误。
  5. 性能优异:自动清理定时器和事件监听,无内存泄漏问题。

这个指令可以直接集成到你的Vue3项目中,解决各种长按交互需求。如果需要进一步扩展,可以在此基础上增加:

  • 支持长按进度条显示
  • 支持多段长按(短按/中按/长按触发不同操作)
  • 支持长按拖拽
  • 支持自定义长按样式(如按压反馈)

希望这篇文章对你有帮助,欢迎点赞、收藏、评论交流!

复制代码
相关推荐
147API2 小时前
改名后的24小时:npm 包抢注如何劫持开源项目供应链
前端·npm·node.js
ziqi5222 小时前
第二十二天笔记
前端·chrome·笔记
鹤归时起雾.2 小时前
react一阶段学习
前端·学习·react.js
乐~~~2 小时前
评估等级页面
javascript·vue.js
微祎_2 小时前
Flutter for OpenHarmony:构建一个专业级 Flutter 番茄钟,深入解析状态机、定时器管理与专注力工具设计
开发语言·javascript·flutter
2301_780669862 小时前
HTML-CSS-常见标签和样式(标题的排版、标题的样式、选择器、正文的排版、正文的样式、整体布局、盒子模型)
前端·css·html·javaweb
薯片锅巴2 小时前
锅巴的JavaScript进阶修炼日记2:面向对象编程/原型及原型链
开发语言·javascript·ecmascript
mseaspring2 小时前
一款高颜值SSH终端工具!基于Electron+Vue3开发,开源免费还好用
运维·前端·javascript·electron·ssh
appearappear2 小时前
wkhtmltopdf把 html 原生转成成 pdf
前端·pdf·html