手把手实现一个灵活可复用的右键菜单组件

爱学习的玉霞 交互 阅读 2,493
赞 14 收藏
二维码
手机扫码查看
反馈

为啥要折腾右键菜单?

最近在搞一个富文本编辑器,用户说“能不能加个右键菜单,像 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 => 
    &lt;div class=&quot;menu-item&quot; data-action=&quot;${item.action}&quot;&gt;${item.label}&lt;/div&gt;
  ).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 的实现里就有完整支持,可以参考。

最后说句实在话

如果你只是做个内部工具,图快,那就用第三方库,哪怕有点小毛病也能忍。但如果是长期维护的产品,尤其是需要深度定制交互的(比如菜单里嵌输入框、带图标、动态禁用选项),我强烈建议自己封装。代码不多,但省下的调试时间远超预期。我现在的项目里,这个轻量菜单组件已经稳定跑了半年多,改需求时加个新选项也就两分钟的事。

以上是我踩坑后的总结,希望对你有帮助。有更优的实现方式欢迎评论区交流——特别是关于无障碍那块,我还在找更优雅的解法。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论