手把手实现一个灵活可复用的右键菜单组件
为啥要折腾右键菜单?
最近在搞一个富文本编辑器,用户说“能不能加个右键菜单,像 Word 那样点一下就能复制格式、插入图片”。我一想,这不就是个 ContextMenu 嘛,简单。结果一动手,发现水还挺深——原生方案太弱,第三方库又五花八门,有的轻量但难定制,有的功能全但体积大得离谱。折腾了几个项目后,我决定把几种主流方案拉出来遛一遛,说说我的真实体验。
谁更灵活?谁更省事?
我试过三种主流做法:原生 DOM + 事件监听、用现成的 React 组件库(比如 react-contextmenu)、自己手写一个轻量级的通用组件。下面挨个聊聊。
先说原生方案。其实就两步:监听 contextmenu 事件,阻止默认行为,然后动态生成一个 div 当菜单。代码长这样:
document.addEventListener('contextmenu', (e) => {
e.preventDefault();
const menu = document.createElement('div');
menu.style.position = 'absolute';
menu.style.left = e.pageX + 'px';
menu.style.top = e.pageY + 'px';
menu.style.background = '#fff';
menu.style.border = '1px solid #ccc';
menu.style.zIndex = '9999';
menu.innerHTML =
<div onclick="handleCopy()">复制</div>
<div onclick="handlePaste()">粘贴</div>
;
document.body.appendChild(menu);
// 点击外部关闭
const close = () => {
document.body.removeChild(menu);
document.removeEventListener('click', close);
};
setTimeout(() => document.addEventListener('click', close), 0);
});
优点?零依赖,一行 CDN 都不用引。但缺点也明显:样式丑得没法看,位置计算容易出错(尤其滚动容器里),而且每个页面都要重写一遍。我在一个表格项目里用过,结果用户反馈“菜单跑屏幕外去了”,查了半天才发现没处理 viewport 边界。这种方案,除非是临时 demo,否则我真的不推荐。
第三方库:香但有坑
React 项目里很多人会直接上 react-contextmenu。它确实省事,声明式写法,支持嵌套、分组、快捷键提示:
import { ContextMenu, MenuItem, ContextMenuTrigger } from 'react-contextmenu';
function App() {
return (
<div>
<ContextMenuTrigger id="my-menu">
<div>右键我</div>
</ContextMenuTrigger>
<ContextMenu id="my-menu">
<MenuItem onClick={handleCopy}>复制</MenuItem>
<MenuItem onClick={handlePaste}>粘贴</MenuItem>
</ContextMenu>
</div>
);
}
看起来很美好,对吧?但问题来了:这个库最后一次更新是 2020 年,TypeScript 支持差,而且强制要求你给每个触发区域加 id,在动态列表里特别麻烦(比如表格每行都要唯一 ID)。更糟的是,它内部用 ReactDOM.createPortal 挂到 body,但 z-index 冲突频发,和某些 UI 框架(比如 Ant Design 的 Modal)叠在一起时菜单直接被盖住。我踩过这个坑,改了好久 CSS 才搞定。
后来试了 @radix-ui/react-context-menu,它是 headless 的,只管逻辑不管样式,灵活性高很多。但代价是你要自己写所有样式,连 hover 效果都得手动加。对于赶工期的项目,这反而成了负担。
我的选型逻辑:手写一个轻量版
现在我基本都选择自己写一个通用右键菜单组件。听起来很累?其实核心代码就几十行,而且一次封装,到处复用。关键是我能完全控制行为和样式,不用看第三方库的脸色。
我的思路是:用一个全局单例组件,通过函数调用方式弹出菜单。比如:
// contextMenu.js
let menuInstance = null;
export function showContextMenu(items, x, y) {
if (menuInstance) {
document.body.removeChild(menuInstance);
}
const menu = document.createElement('div');
menu.className = 'custom-context-menu'; // 用 CSS 类统一管理样式
menu.innerHTML = items.map(item =>
<div class="menu-item" data-action="${item.action}">${item.label}</div>
).join('');
// 边界检测:别让菜单跑出屏幕
const maxX = window.innerWidth - 150;
const maxY = window.innerHeight - 30 * items.length;
menu.style.left = Math.min(x, maxX) + 'px';
menu.style.top = Math.min(y, maxY) + 'px';
document.body.appendChild(menu);
menuInstance = menu;
// 点击菜单项
menu.addEventListener('click', (e) => {
const action = e.target.dataset.action;
if (action) {
const item = items.find(i => i.action === action);
item?.onClick?.();
hideContextMenu();
}
});
// 点击外部关闭
setTimeout(() => {
document.addEventListener('click', hideContextMenu, { once: true });
document.addEventListener('contextmenu', hideContextMenu, { once: true });
}, 0);
}
function hideContextMenu() {
if (menuInstance) {
document.body.removeChild(menuInstance);
menuInstance = null;
}
}
然后在业务代码里这么用:
element.addEventListener('contextmenu', (e) => {
e.preventDefault();
showContextMenu([
{ label: '复制', action: 'copy', onClick: () => console.log('copied') },
{ label: '粘贴', action: 'paste', onClick: () => console.log('pasted') }
], e.clientX, e.clientY);
});
配套的 CSS 也很简单:
.custom-context-menu {
position: fixed;
background: white;
border: 1px solid #ddd;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
min-width: 120px;
z-index: 10000;
}
.menu-item {
padding: 6px 12px;
cursor: pointer;
}
.menu-item:hover {
background: #f5f5f5;
}
这套方案我用了三个项目,几乎没出过问题。边界检测解决了菜单跑出屏幕的问题,单例模式避免重复创建,CSS 类名统一管理样式,后续换主题也方便。虽然多写了几十行代码,但换来的是完全的掌控感——这点对我这种控制狂来说太重要了。
踩坑提醒:这三点一定注意
- 滚动容器里的位置计算:如果你的右键区域在一个
overflow: auto的 div 里,e.clientX/Y是相对于 viewport 的,但菜单可能需要相对于容器定位。这时候得用getBoundingClientRect()转换坐标,别直接用 pageX/Y。 - 移动端兼容性:手机上根本没有右键!所以记得加个判断:
if ('ontouchstart' in window) return;,或者干脆在移动端隐藏相关功能。 - 键盘导航支持:无障碍要求菜单能用方向键和回车操作。我偷懒没加,但如果你做的是企业级产品,这步不能省。Radix UI 的实现里就有完整支持,可以参考。
最后说句实在话
如果你只是做个内部工具,图快,那就用第三方库,哪怕有点小毛病也能忍。但如果是长期维护的产品,尤其是需要深度定制交互的(比如菜单里嵌输入框、带图标、动态禁用选项),我强烈建议自己封装。代码不多,但省下的调试时间远超预期。我现在的项目里,这个轻量菜单组件已经稳定跑了半年多,改需求时加个新选项也就两分钟的事。
以上是我踩坑后的总结,希望对你有帮助。有更优的实现方式欢迎评论区交流——特别是关于无障碍那块,我还在找更优雅的解法。

暂无评论