Code前端首页关于Code前端联系我们

Vue3项目里做右键菜单是直接搜插件好,还是自己手写封装合适?有哪些避坑点?

terry 1小时前 阅读数 29 #Vue

这个问题其实是很多Vue3前端开发者刚接触右键菜单需求时的共性纠结——不管是后台管理系统的操作菜单、编辑器里的快捷右键,还是电商项目里的商品缩略图右键操作,好像随便搜npm仓库能跳出一堆现成的Vue3右键菜单插件,但要么样式改起来巨麻烦,要么功能阉割得厉害,要么文档写得像天书;但自己从零写的话,又怕重复造轮子,还要处理定位偏移、层级覆盖、点击空白关闭这些细节坑。

先给个明确的结论:不是非此即彼的选择,要根据你的项目具体需求和团队能力来定,接下来我会从「什么时候直接用插件」「什么时候必须自己手写封装」「手写/用插件都要避的通用坑」这三个部分展开,最后还会给一个极简版的、支持基础自定义的Vue3组合式API封装案例,方便大家上手。

先说说什么时候直接用现成的Vue3右键菜单插件更划算

如果你的项目没有特别强的品牌定制需求,或者只是要做一两个页面临时加的、功能非常基础的右键菜单,那用插件绝对是最快、最省精力的选择,基础功能一般指什么?就是支持右键点击触发、有子菜单、点击菜单项执行对应回调、点击空白或按Esc关闭这些,很多成熟的插件都能完美覆盖。

那怎么挑靠谱的插件呢?这里有几个小技巧,比你直接搜“Vue3右键菜单”然后选下载量最高的第一个还要准——因为有时候下载量高可能是老版本Vue2插件蹭了关键词,或者是某个博主随便封装的半成品火了一波但没人维护了,第一个是看npm包的发布时间和更新频率,最好是近三个月有更新的,说明开发者还在跟进Vue3的生态(比如最近的Vue3.4有一些小的响应式优化和自定义指令的小改动,更新及时的插件兼容性会更好);第二个是看GitHub仓库的Issue和Star数之比,如果Star数很高但Issue堆了几百个没人处理,那大概率也是个“死插件”;第三个是先看Demo再拉代码到本地试,不要只看文档里的截图和API说明——有些插件Demo里的效果很好,但你自己改个主题色、改个菜单项的间距就会发现它的CSS用了!important或者嵌套得很深,根本没法改;第四个是尽量选TypeScript支持完善的,如果你用TypeScript开发,选带完整类型定义的插件能省很多排查类型错误的时间。

再聊聊什么时候必须自己手写封装Vue3右键菜单组件库

什么时候手写封装更有价值?主要有三种情况:第一种是你的项目有非常强的视觉统一性要求,比如企业级后台管理系统的操作栏、右键菜单、弹窗都是统一的蓝色系、圆角、阴影,用现成的插件改样式要覆盖几百行CSS,还不如自己写;第二种是你的右键菜单需要一些插件没有的复杂功能,比如支持拖拽菜单项调整顺序、支持菜单项的图标自定义(用自己项目的IconFont或者SVG组件,不是插件自带的那几个固定图标)、支持右键菜单的多场景复用(比如在表格的行上右键是“编辑、删除、导出”,在表格的列头上右键是“隐藏列、冻结列、排序设置”,在编辑器的文本上右键是“复制、粘贴、查找替换”,还要能动态根据当前点击的元素传参);第三种是你的团队对性能有极致要求,很多现成的插件为了兼容性会加很多冗余的代码,比如同时支持Vue2和Vue3,或者加了很多你根本用不到的动画、拖拽功能,自己手写的话可以完全按需加载,代码量小,性能也更好。

其实手写封装Vue3右键菜单并没有你想象中的那么难,核心逻辑无非就是这几步:第一步是监听右键点击事件(contextmenu),阻止默认的浏览器右键菜单弹出;第二步是获取右键点击的位置(clientX和clientY),然后根据窗口的大小调整右键菜单的位置,防止它超出可视区域;第三步是监听点击空白区域(mousedown或者click)和按Esc键(keydown)的事件,关闭右键菜单;第四步是封装成通用的组件,支持自定义菜单项、图标、回调函数、样式等。

不管是手写还是用插件,都要注意这些避坑点

避坑点一:右键菜单的位置偏移问题一定要处理

很多人刚开始做右键菜单的时候,直接把clientX和clientY赋值给菜单的top和left,结果菜单会超出可视区域——比如你在屏幕最右边右键,菜单就会跑到屏幕外面去,根本看不见;在屏幕最下面右键,菜单的下半部分就会被遮掉,那怎么处理呢?其实很简单,就是在设置菜单的位置之前,先获取当前窗口的可视区域大小(window.innerWidth和window.innerHeight),再获取右键菜单的自身大小(可以用ref获取DOM元素,然后用offsetWidth和offsetHeight,或者用getBoundingClientRect()方法,后者更准确,因为它会考虑CSS的transform和滚动条的影响),然后判断一下:如果clientX + menuWidth > window.innerWidth,就把left设置成clientX - menuWidth;如果clientY + menuHeight > window.innerHeight,就把top设置成clientY - menuHeight;如果同时满足两个条件,就同时调整top和left,还要注意如果右键点击的元素在一个有滚动条的容器里,那还要加上容器的scrollTop和scrollLeft,不然菜单的位置会错。

避坑点二:右键菜单的层级覆盖问题要注意

这个问题也是非常常见的——比如你的项目里有弹窗、有下拉菜单、有遮罩层,结果右键菜单弹出来之后被这些元素遮住了,根本点不到,那怎么避免呢?首先是把右键菜单的挂载位置放在body的最下面,而不是放在某个组件的内部——因为如果放在组件内部,组件的父元素如果设置了overflow: hidden或者z-index比较小,那右键菜单肯定会被遮住;其次是给右键菜单设置一个足够大的z-index,比如设置成99999,但不要设置成比项目里的全局遮罩层还大的数字,不然全局遮罩层弹出来的时候,右键菜单还在上面,不符合用户的操作习惯;最后是如果项目里用了Element Plus、Ant Design Vue这些UI组件库,最好查一下它们的全局遮罩层的z-index是多少,比如Element Plus的全局遮罩层z-index是2000,那你的右键菜单设置成1999或者2001都可以——如果设置成1999,那弹窗弹出来的时候右键菜单会被遮住,符合逻辑;如果设置成2001,那弹窗弹出来的时候右键菜单还在上面,需要自己处理。

避坑点三:动态菜单项和参数传递的问题要做好

不管是手写还是用插件,动态菜单项和参数传递都是一个高频需求——比如你在表格的不同行上右键,菜单项可能是一样的,但回调函数里需要获取当前行的id、name这些数据;再比如你在编辑器里选中文本的时候右键,会多出“复制选中内容”“剪切选中内容”这些菜单项,没选中文本的时候这些菜单项是隐藏的,那怎么处理动态菜单项呢?手写的话,可以用v-for循环渲染菜单项,然后用v-if或者v-show控制菜单项的显示和隐藏;用插件的话,一般会支持一个dynamic或者items的属性,你可以传一个computed属性过去,根据当前的状态动态返回菜单项数组,那怎么传递参数呢?手写的话,可以在监听contextmenu事件的时候,把需要传递的参数存到ref或者reactive里,然后在菜单项的点击回调函数里调用;用插件的话,一般会支持在触发右键菜单的元素上绑定一个data-*属性,或者在右键菜单的组件上绑定一个props属性,然后在回调函数里获取。

避坑点四:多个右键菜单共存的问题要解决

很多项目里可能会有多个右键菜单——比如刚才说的表格行右键、表格列头右键、编辑器文本右键,那怎么区分这些右键菜单,并且只显示当前点击元素对应的右键菜单呢?手写的话,可以给每个触发右键菜单的元素加一个唯一的标识(比如data-menu-type="table-row"),然后在监听contextmenu事件的时候,获取这个标识,再显示对应的右键菜单;用插件的话,一般会支持一个target或者selector的属性,你可以给不同的右键菜单绑定不同的选择器,还要注意多个右键菜单之间的互斥性——比如显示了表格行右键菜单之后,点击表格列头,要先关闭表格行右键菜单,再显示表格列头右键菜单,不能两个同时显示。

避坑点五:移动端的兼容性问题要考虑

虽然右键菜单主要是用在PC端的,但现在很多项目都是PC端和移动端共用一套代码,所以最好也考虑一下移动端的兼容性,那移动端怎么触发右键菜单呢?一般是长按元素——所以你需要在监听contextmenu事件的同时,也监听touchstart和touchend事件,计算一下长按的时间,如果超过了500ms或者1000ms,就触发自定义的右键菜单,同时阻止默认的移动端长按菜单弹出(比如iOS上的放大镜和复制粘贴菜单,Android上的复制粘贴菜单),还要注意移动端的屏幕比较小,所以右键菜单的大小要自适应,或者用底部弹窗的形式代替,体验会更好。

给大家分享一个极简版的、支持基础自定义的Vue3组合式API封装案例

既然说了手写封装不难,那我就给大家分享一个我自己在项目里常用的极简版Vue3右键菜单组合式API封装案例,代码量很小,只有不到200行,支持自定义菜单项、图标、回调函数、位置偏移处理、点击空白关闭、按Esc关闭,大家可以直接复制到自己的项目里用,然后根据自己的需求修改。

我们新建一个useContextMenu.ts的文件,用来封装右键菜单的核心逻辑:

import { ref, onMounted, onUnmounted, type Ref } from 'vue'
interface MenuItem {
  label: string
  icon?: string | (() => JSX.Element)
  disabled?: boolean
  divided?: boolean
  onClick?: (data?: any) => void
  children?: MenuItem[]
}
export function useContextMenu() {
  // 右键菜单的显示状态
  const visible = ref(false)
  // 右键菜单的位置
  const position = ref({ top: 0, left: 0 })
  // 右键菜单的菜单项
  const menuItems = ref<MenuItem[]>([])
  // 当前点击元素传递的参数
  const menuData = ref<any>(null)
  // 右键菜单的DOM元素
  const menuRef: Ref<HTMLElement | null> = ref(null)
  // 右键点击的目标元素
  let targetElement: HTMLElement | null = null
  // 显示右键菜单
  const show = (e: MouseEvent | TouchEvent, items: MenuItem[], data?: any) => {
    // 阻止默认的浏览器右键菜单或移动端长按菜单
    e.preventDefault()
    // 阻止事件冒泡,避免触发父元素的右键菜单
    e.stopPropagation()
    // 获取点击位置
    let clientX: number, clientY: number
    if ('touches' in e) {
      // 移动端长按
      clientX = e.touches[0].clientX
      clientY = e.touches[0].clientY
    } else {
      // PC端右键
      clientX = e.clientX
      clientY = e.clientY
    }
    // 保存目标元素、菜单项和参数
    targetElement = e.target as HTMLElement
    menuItems.value = items
    menuData.value = data
    // 先显示菜单,再获取菜单的大小(因为如果菜单隐藏的话,offsetWidth和offsetHeight都是0)
    visible.value = true
    // 这里用requestAnimationFrame是为了确保DOM已经更新,菜单已经显示出来了
    requestAnimationFrame(() => {
      if (!menuRef.value) return
      const menuWidth = menuRef.value.offsetWidth
      const menuHeight = menuRef.value.offsetHeight
      const windowWidth = window.innerWidth
      const windowHeight = window.innerHeight
      // 调整位置,防止超出可视区域
      let left = clientX
      let top = clientY
      if (left + menuWidth > windowWidth) {
        left = clientX - menuWidth
      }
      if (top + menuHeight > windowHeight) {
        top = clientY - menuHeight
      }
      position.value = { top, left }
    })
  }
  // 关闭右键菜单
  const hide = () => {
    visible.value = false
    targetElement = null
    menuData.value = null
  }
  // 点击菜单项
  const handleMenuItemClick = (item: MenuItem) => {
    if (item.disabled) return
    if (item.onClick) {
      item.onClick(menuData.value)
    }
    hide()
  }
  // 监听点击空白区域
  const handleClickOutside = (e: MouseEvent | TouchEvent) => {
    if (!visible.value) return
    if (!menuRef.value) return
    if (menuRef.value.contains(e.target as Node)) return
    if (targetElement?.contains(e.target as Node)) return
    hide()
  }
  // 监听按Esc键
  const handleEscKeydown = (e: KeyboardEvent) => {
    if (!visible.value) return
    if (e.key === 'Escape') {
      hide()
    }
  }
  // 监听滚动事件
  const handleScroll = () => {
    if (!visible.value) return
    hide()
  }
  // 组件挂载时添加事件监听
  onMounted(() => {
    document.addEventListener('mousedown', handleClickOutside)
    document.addEventListener('touchstart', handleClickOutside)
    document.addEventListener('keydown', handleEscKeydown)
    window.addEventListener('scroll', handleScroll, true)
  })
  // 组件卸载时移除事件监听
  onUnmounted(() => {
    document.removeEventListener('mousedown', handleClickOutside)
    document.removeEventListener('touchstart', handleClickOutside)
    document.removeEventListener('keydown', handleEscKeydown)
    window.removeEventListener('scroll', handleScroll, true)
  })
  return {
    visible,
    position,
    menuItems,
    menuRef,
    show,
    hide,
    handleMenuItemClick
  }
}

我们新建一个ContextMenu.vue的文件,用来封装右键菜单的UI组件:

<template>
  <div
    v-if="visible"
    ref="menuRef"
    class="context-menu"
    :style="{ top: `${position.top}px`, left: `${position.left}px` }"
  >
    <div v-for="(item, index) in menuItems" :key="index" class="context-menu-item-wrapper">
      <!-- 分割线 -->
      <div v-if="item.divided" class="context-menu-divider"></div>
      <!-- 菜单项 -->
      <div
        class="context-menu-item"
        :class="{ 'context-menu-item--disabled': item.disabled }"
        @click="handleMenuItemClick(item)"
      >
        <!-- 图标 -->
        <span v-if="item.icon" class="context-menu-item-icon">
          <component v-if="typeof item.icon === 'function'" :is="item.icon" />
          <i v-else :class="item.icon"></i>
        </span>
        <!-- 文字 -->
        <span class="context-menu-item-label">{{ item.label }}</span>
        <!-- 子菜单箭头(这里暂时不实现子菜单,有需要的可以自己加) -->
        <span v-if="item.children" class="context-menu-item-arrow">▶</span>
      </div>
    </div>
  </div>
</template>
<script setup lang="ts">
import { useContextMenu } from './useContextMenu'
const props = defineProps<{
  // 这里暂时不需要props,所有的逻辑都通过useContextMenu暴露的方法和状态控制
}>()
// 直接暴露useContextMenu的所有状态和方法,方便父组件调用
const contextMenu = useContextMenu()
defineExpose(contextMenu)
</script>
<style scoped>
.context-menu {
  position: fixed;
  z-index: 9999;
  background-color: #fff;
  border-radius: 4px;
  box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
  padding: 4px 0;
  min-width: 160px;
  user-select: none;
}
.context-menu-item-wrapper {
  width: 100%;
}
.context-menu-divider {
  height: 1px;
  background-color: #eee;
  margin: 4px 0;
}
.context-menu-item {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 8px 16px;
  cursor: pointer;
  font-size: 14px;
  color: #333;
  transition: background-color 0.2s;
}
.context-menu-item:hover {
  background-color: #f5f7fa;
}
.context-menu-item--disabled {
  cursor: not-allowed;
  color: #999;
}
.context-menu-item--disabled:hover {
  background-color: transparent;
}
.context-menu-item-icon {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 16px;
  height: 16px;
  font-size: 14px;
}
.context-menu-item-label {
  flex: 1;
}
.context-menu-item-arrow {
  font-size: 12px;
  color: #666;
}
</style>

我们在父组件里使用这个ContextMenu组件:

<template>
  <div class="parent-container">
    <!-- 右键点击这个div触发菜单 -->
    <div
      class="click-area"
      @contextmenu="handleRightClick"
      @touchstart="handleTouchStart"
      @touchend="handleTouchEnd"
    >
      右键点击我(PC端)或者长按我(移动端)试试
    </div>
    <!-- 右键菜单组件 -->
    <ContextMenu ref="contextMenuRef" />
  </div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import ContextMenu from './ContextMenu.vue'
import type { MenuItem } from './useContextMenu'
// 右键菜单组件的引用
const contextMenuRef = ref<InstanceType<typeof ContextMenu> | null>(null)
// 移动端长按的定时器
let touchTimer: number | null = null
// 菜单项数组
const menuItems: MenuItem[] = [
  {
    label: '复制',
    icon: 'el-icon-document-copy',
    onClick: (data) => {
      console.log('复制', data)
    }
  },
  {
    label: '粘贴',
    icon: 'el-icon-document-checked',
    onClick: (data) => {
      console.log('粘贴', data)
    }
  },
  {
    divided: true
  },
  {
    label: '编辑',
    icon: 'el-icon-edit',
    onClick: (data) => {
      console.log('编辑', data)
    }
  },
  {
    label: '删除',
    icon: 'el-icon-delete',
    disabled: true,
    onClick: (data) => {
      console.log('删除', data)
    }
  }
]
// PC端右键点击事件
const handleRightClick = (e: MouseEvent) => {
  contextMenuRef.value?.show(e, menuItems, { id: 1, name: '测试数据' })
}
// 移动端长按开始事件
const handleTouchStart = (e: TouchEvent) => {
  touchTimer = window.setTimeout(() => {
    contextMenuRef.value?.show(e, menuItems, { id: 1, name: '测试数据' })
  }, 500)
}
// 移动端长按结束事件
const handleTouchEnd = () => {
  if (touchTimer) {
    clearTimeout(touchTimer)
    touchTimer = null
  }
}
</script>
<style scoped>
.parent-container {
  width: 100%;
  height: 100vh;
  display: flex;
  align-items: center;
  justify-content: center;
  background-color: #f5f7fa;
}
.click-area {
  width: 400px;
  height: 200px;
  display: flex;
  align-items: center;
  justify-content: center;
  background-color: #fff;
  border-radius: 4px;
  box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
  font-size: 16px;
  color: #333;
  cursor: pointer;
}
</style>

这个案例里暂时没有实现子菜单的功能,因为子菜单的逻辑会稍微复杂一点,需要处理子菜单的显示和隐藏、子菜单的位置偏移、子菜单的层级覆盖等问题,但如果你需要的话,可以在这个基础上自己加——比如在菜单项上监听mouseenter事件显示子菜单,监听mouseleave事件隐藏子菜单,子菜单的挂载位置可以放在父菜单项的右边或者下边,然后同样调整位置防止超出可视区域。

总结一下

Vue3项目里做右键菜单,不是非要自己手写或者非要用现成的插件,要根据你的项目具体需求和团队能力来定:如果是临时加的、功能非常基础的右键菜单,直接用现成的插件更划算;如果是有强定制需求、复杂功能、极致性能要求的项目,自己手写封装成组件库更有价值,不管是手写还是用插件,都要注意位置偏移、层级覆盖、动态菜单项和参数传递、多个右键菜单共存、移动端兼容性这些避坑点,我给大家分享了一个极简版的组合式API封装案例,大家可以直接复制到自己的项目里用,然后根据自己的需求修改。

版权声明

本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。

热门