右键菜单在后台管理、文件管理器、数据表格等场景中几乎无法绕开。浏览器的默认右键菜单虽然可用,但无法和业务数据联动,也不能实现行级权限判断,所以大多数项目都会选择自定义。Vue 3 的事件系统与组合式 API 让这类交互组件可以做得非常轻,核心思路是先阻止默认事件,再根据鼠标坐标与菜单尺寸计算最终位置,最后将菜单渲染到 body 下避免层级裁剪。

实现一个健壮的右键菜单组件,难点并不在于弹出菜单本身,而在于两个容易忽视的细节:菜单位置如何在不同屏幕尺寸和滚动容器中保持正确,以及菜单项权限如何跟随当前用户或当前行数据动态变化。下面把这两块拆开处理,既给出事件层的封装,也给出组件层的权限过滤方案。
先处理定位:contextmenu事件的坐标与边界修正
浏览器在用户点击右键时触发 contextmenu 事件,事件的 clientX 和 clientY 表示鼠标相对视口的坐标。组件第一步就是阻止默认菜单弹出,并记录这两个值。在 Vue 3 中可以借助组合式函数把状态收敛起来,避免每个页面重复写一遍打开、关闭和坐标记录的逻辑。
import { ref, reactive } from 'vue'
export function useContextMenu() {
const visible = ref(false)
const position = reactive({ x: 0, y: 0 })
function open(e) {
e.preventDefault()
visible.value = true
position.x = e.clientX
position.y = e.clientY
}
function close() {
visible.value = false
}
return { visible, position, open, close }
}
上面的 useContextMenu 只负责状态管理,打开方法需要接收原生事件对象。实际使用时在目标区域绑定 @contextmenu="open" 即可。不过直接把 clientX 和 clientY 赋给菜单的 left 和 top 会带来一个问题:当菜单宽度较大,或者鼠标靠近屏幕右侧、底部时,菜单会被视口截断。因此需要获取菜单自身的宽高进行边界修正。
function clampPosition(mouseX, mouseY, menuWidth, menuHeight) {
const gap = 8
const viewportWidth = window.innerWidth
const viewportHeight = window.innerHeight
let x = mouseX + gap
let y = mouseY + gap
if (x + menuWidth > viewportWidth) {
x = mouseX - menuWidth - gap
}
if (y + menuHeight > viewportHeight) {
y = mouseY - menuHeight - gap
}
return {
x: Math.max(gap, Math.min(x, viewportWidth - menuWidth - gap)),
y: Math.max(gap, Math.min(y, viewportHeight - menuHeight - gap))
}
}
边界计算中 gap 设为 8px,让菜单和鼠标保持一点距离,避免光标遮挡菜单阴影。当右侧空间不足时,让菜单出现在鼠标左侧;当底部空间不足时,让菜单出现在鼠标上方。最后再用 Math.min 和 Math.max 兜底,保证极端小窗口下菜单也不会跑到负坐标。菜单宽高可以在菜单显示后的下一帧通过 ref 测量实际 DOM 尺寸,也可以根据菜单项数量估算一个固定值,但实测 DOM 更可靠。
封装组件:Teleport挂载与菜单项渲染
右键菜单如果放在业务容器内部,很容易被父级的 overflow:hidden、transform 或 z-index 影响。Vue 3 的 Teleport 可以将菜单直接挂载到 body,避开这些样式限制。菜单的显示与隐藏使用 v-if 配合 Transition,给用户一个轻量的淡入缩放效果。
<template>
<Teleport to="body">
<Transition name="context-menu">
<div
v-if="visible"
class="context-menu"
:style="{ left: position.x + 'px', top: position.y + 'px' }"
@click.stop
>
<ul class="context-menu__list">
<li
v-for="item in menuItems"
:key="item.key"
:class="{ 'is-disabled': item.disabled }"
@click="handleItemClick(item)"
>
{{ item.label }}
</li>
</ul>
</div>
</Transition>
</Teleport>
</template>
组件对外只需要暴露菜单项数组和打开方法。菜单项结构设计成对象数组,每个对象包含 key、label、disabled、permission 和 handler,这样列表、树、表格场景都能复用同一套模板。父组件只负责把当前行的可用操作组装成数组,不关心菜单内部如何渲染。
import { ref, reactive } from 'vue'
const props = defineProps({
menuItems: {
type: Array,
default: () => []
}
})
const visible = ref(false)
const position = reactive({ x: 0, y: 0 })
function openMenu(e) {
e.preventDefault()
visible.value = true
position.x = e.clientX
position.y = e.clientY
}
function handleItemClick(item) {
if (item.disabled) return
visible.value = false
item.handler && item.handler()
}
handleItemClick 里先判断 disabled 状态,再关闭菜单并执行对应回调。关闭菜单的同时还需要清除当前绑定的行数据,避免下次打开时残留上一次状态。这个清理逻辑可以放在 visible 变为 false 的 watch 中,也可以放在 close 方法里统一处理。菜单项的 disabled 状态可以来自菜单数据本身,也可以来自权限判断的进一步加工,后面会详细说明。
权限控制:从过滤菜单项到自定义指令
权限控制最容易出现的问题是把判断逻辑写死在模板的 v-if 里,导致同一套菜单在不同页面出现不同判断条件。更稳定的做法是给每个菜单项配置 permission 字段,组件内部根据当前用户权限集合统一过滤。这样权限变化时只需要更新用户状态,菜单自动响应,不需要改动模板。
import { computed } from 'vue'
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
const allMenuItems = [
{ key: 'copy', label: '复制', permission: 'file:copy' },
{ key: 'rename', label: '重命名', permission: 'file:rename' },
{ key: 'delete', label: '删除', permission: 'file:delete' }
]
const visibleMenuItems = computed(() => {
return allMenuItems.filter(item => {
if (!item.permission) return true
return userStore.permissions.includes(item.permission)
})
})
如果业务里权限维度不只有用户角色,还可能跟行数据有关,比如文件所有者才能删除、共享文件只能查看。此时菜单项数据应该在打开菜单前由父组件根据当前行数据生成,组件仍然只做 permission 字段的统一过滤。这样权限判断集中在一个地方,后续增加权限点或调整规则不会影响组件结构。
对于需要全局统一挂载右键菜单的场景,可以进一步封装自定义指令 v-contextmenu。指令接收菜单项数组,内部监听 contextmenu 事件,调用同一个 useContextMenu,并配合全局单例菜单组件渲染。下面给出一个轻量示例,用于展示事件监听和清理的基本模式。
const contextMenuDirective = {
mounted(el, binding) {
const openHandler = (event) => {
event.preventDefault()
const menu = binding.value
window.__contextMenu.open(event, menu)
}
el.__contextMenuHandler = openHandler
el.addEventListener('contextmenu', openHandler)
},
unmounted(el) {
el.removeEventListener('contextmenu', el.__contextMenuHandler)
}
}
指令本身不负责模板输出,通常还需要结合一个全局单例菜单组件来渲染菜单。对于大多数业务系统,直接复用组件并传 props 已经足够,不必过度设计。权限控制的重点在于数据层统一过滤,而不是在视图层到处加判断。把权限字段内聚到菜单数据里,组件就能保持纯展示逻辑,后续维护成本会低很多。