手把手实现一个高性能的自定义右键菜单

Zz怡涵 交互 阅读 850
赞 14 收藏
二维码
手机扫码查看
反馈

又双叒叕被右键菜单搞崩了

项目里要做个文件管理器,用户在列表上右键要弹出操作菜单:重命名、删除、复制这些。听起来挺简单对吧?结果光是这个右键事件,我折腾了一整天。

手把手实现一个高性能的自定义右键菜单

最开始我直接用 contextmenu 事件绑定,代码写得飞起:

document.querySelector('#file-list').addEventListener('contextmenu', (e) => {
  e.preventDefault();
  showContextMenu(e.clientX, e.clientY);
});

本地跑得好好的,一上线测试就出问题——偶尔点右键会触发浏览器默认菜单,而且有时候根本没反应。更离谱的是,在某些笔记本触控板上长按居然也会触发,用户体验直接翻车。

排查过程:从怀疑人生到恍然大悟

一开始我以为是事件冒泡的问题,加了各种 stopPropagation 和 preventDefault,甚至把父级元素的右键都禁了,还是不稳定。后来我在 Chrome 控制台打日志,发现一个诡异现象:有些 contextmenu 事件的 e.button 值是 0,也就是左键……这不对劲啊。

查 MDN 才知道,button 的值代表的是按下哪个键触发的,右键应该是 2。但有些设备(比如 Mac 触控板双指点击)或者辅助工具可能会模拟成左键点击然后弹出菜单,这时候 button 就是 0。所以不能只靠 button 判断是不是右键。

我还试过监听 mousedown + 判断右键,然后自己 delay 300ms 模拟 contextmenu,结果延迟太明显,交互像卡顿了一样,果断放弃。

折腾了半天发现,关键不是怎么“检测右键”,而是怎么确保 preventDefault 能稳定生效。因为如果页面有其他脚本异步绑定了事件,或者组件库自己加了监听,你的 preventDefault 可能就被覆盖了。

最终方案:全局拦截 + 白名单放行

后来我想了个土办法:不在具体元素上绑,而是在 document 层级统一处理,配合自定义属性控制是否启用右键菜单。

核心思路是:

  • document 监听 contextmenu,统一阻止默认行为
  • 通过 dataset 或 class 判断当前目标是否允许自定义菜单
  • 如果是,则触发自定义逻辑;否则不做任何事

这样能避免多个组件重复绑定导致的冲突,也更容易调试。

最终代码长这样:

// 右键菜单控制器
class ContextMenuHandler {
  constructor() {
    this.init();
  }

  init() {
    document.addEventListener('contextmenu', this.handleContextMenu.bind(this));
  }

  handleContextMenu(e) {
    const target = e.target;
    const hasCustomMenu = target.closest('[data-context-menu]');

    if (!hasCustomMenu) {
      return; // 没有自定义菜单需求,直接返回
    }

    e.preventDefault(); // 只有需要的时候才阻止

    const menuType = hasCustomMenu.dataset.contextMenu;
    const dataId = hasCustomMenu.dataset.id;

    this.showMenu(menuType, {
      x: e.clientX,
      y: e.clientY,
      dataId
    });
  }

  showMenu(type, options) {
    // 这里可以动态渲染不同类型的菜单
    console.log(显示 ${type} 类型菜单,位置:${options.x}, ${options.y});
    
    // 实际项目中可能是 Vue/React 渲染的浮层
    // 或者用原生 DOM 创建
    this.renderMenu(type, options);
  }

  renderMenu(type, { x, y, dataId }) {
    let menuItems = [];

    switch (type) {
      case 'file':
        menuItems = [
          { label: '打开', action: 'open' },
          { label: '重命名', action: 'rename' },
          { label: '删除', action: 'delete' }
        ];
        break;
      case 'folder':
        menuItems = [
          { label: '新建文件', action: 'create-file' },
          { label: '粘贴', action: 'paste' },
          { label: '刷新', action: 'refresh' }
        ];
        break;
      default:
        menuItems = [{ label: '无操作', disabled: true }];
    }

    // 创建菜单 DOM
    const menu = document.createElement('div');
    menu.className = 'custom-context-menu';
    menu.style.left = ${x}px;
    menu.style.top = ${y}px;
    menu.dataset.type = type;
    menu.dataset.id = dataId;

    menu.innerHTML = 
      <ul>
        ${menuItems.map(item => 
          item.disabled 
            ? <li class="disabled">${item.label}</li>
            : <li data-action="${item.action}">${item.label}</li>
        ).join('')}
      </ul>
    ;

    document.body.appendChild(menu);

    // 绑定点击事件
    menu.querySelectorAll('li:not(.disabled)').forEach(li => {
      li.addEventListener('click', (e) => {
        this.onMenuItemClick(type, e.currentTarget.dataset.action, dataId);
        this.destroyMenu();
      });
    });

    // 点击外部关闭
    this.handleClickOutside = (e) => {
      if (!menu.contains(e.target)) {
        this.destroyMenu();
      }
    };
    document.addEventListener('click', this.handleClickOutside);
  }

  onMenuItemClick(type, action, dataId) {
    console.log(执行操作:${type}.${action}, ID: ${dataId});
    
    // 实际项目中发请求或调用方法
    // fetch('/api/action', { method: 'POST', body: JSON.stringify({ type, action, dataId }) })
  }

  destroyMenu() {
    const menu = document.querySelector('.custom-context-menu');
    if (menu) {
      document.body.removeChild(menu);
    }
    document.removeEventListener('click', this.handleClickOutside);
  }
}

// 启动
new ContextMenuHandler();

对应的样式也很简单,我就用了点基础 CSS:

.custom-context-menu {
  position: absolute;
  background: white;
  border: 1px solid #ddd;
  border-radius: 4px;
  box-shadow: 0 2px 8px rgba(0,0,0,0.15);
  padding: 8px 0;
  min-width: 120px;
  z-index: 9999;
  font-size: 14px;
  line-height: 1.5;
}

.custom-context-menu ul {
  list-style: none;
  margin: 0;
  padding: 0;
}

.custom-context-menu li {
  padding: 6px 16px;
  cursor: pointer;
  transition: background 0.2s;
}

.custom-context-menu li:hover {
  background-color: #f5f5f5;
}

.custom-context-menu li.disabled {
  color: #ccc;
  cursor: not-allowed;
}

使用时只要给元素加上 data-context-menu 属性就行:

<div id="file-list">
  <div class="file-item" data-context-menu="file" data-id="123">文件1.txt</div>
  <div class="folder-item" data-context-menu="folder" data-id="456">文件夹A</div>
</div>

这里我踩了个坑:多次点击生成多个菜单

最开始没做 destroyMenu 的时候,连续右键几次就会出现好几个菜单叠在一起。后来加了销毁逻辑,但忘了移除外部点击监听,导致内存泄漏。改完之后虽然还是会偶尔有点闪动(可能是因为 appendChild 和 reflow),但整体已经可用。

还有一个小问题是:菜单出现在屏幕边缘时会被裁掉。本来想上 position: fixed + 动态计算位置避开边界,但考虑到项目进度,暂时先用 overflow: visible 硬撑了。反正一般也不会在极窄区域右键。

为啥不用第三方库?

其实之前项目里用过 bootstrap-contextmenujquery-ui-contextmenu,但这次是纯前端重构,不想引入 jQuery。而且这类功能其实逻辑不复杂,自己实现反而更灵活,比如我们可以根据 data-type 动态加载不同菜单项,还能结合权限控制显示隐藏。

顺带提一嘴,如果你用 Vue,可以用 v-contextmenu 这种指令封装;React 的话建议写个 custom hook。但底层原理都一样:拦截 contextmenu 事件,阻止默认,手动渲染。

移动端兼容性?别想了

移动端没有“右键”这个概念,一般是长按触发。如果你想做跨端一致体验,得额外监听 touchstart + setTimeout 模拟长按。但我这个项目只面向桌面端,所以直接忽略了 mobile 场景。要是后续要支持,可能得加个判断:

if ('ontouchstart' in window) {
  // 移动端走长按逻辑
} else {
  // PC端走右键逻辑
}

不过这就又是另一个故事了。

总结一下

以上是我踩坑后的总结。右键菜单看似简单,但真要做到稳定、可维护、不冲突,还是有不少细节要注意。我现在这套方案已经在生产环境跑了几天,除了边缘情况下的轻微闪动外,基本没问题。

这个方案不是最优的,但足够简单,容易理解和维护。毕竟我们不是在造轮子,而是在赶项目上线。

如果你有更好的方案,比如用 MutationObserver 自动注入菜单属性,或者用 Shadow DOM 隔离样式,欢迎评论区交流。我也想知道有没有更优雅的解法。

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

暂无评论