HTML 的 oncontextmenu 事件怎么拦截并完美替换为自定义右键

发布于
1

自定义右键只有一条正路:addEventListener('contextmenu', handler),并在回调第一行同步调用 event.preventDefault()。这活儿的关键从来不是”能不能拦”,而是”拦得及不及时”。

HTML oncontextmenu 拦截原生右键,实现自定义右键菜单,含完整实现逻辑与示例代码。

内联属性为什么靠不住

很多人上来就写 oncontextmenu="return false",看着一行搞定,实际三个硬伤:

  • 内联绑定,运行期间没法动态开关;

  • 不支持事件委托,列表里新增的节点绑不上;

  • 在 Vue / React 里直接失效——JSX 的 onContextMenu={() => false} 拦不住默认行为,菜单照弹。

顺带避个坑:别图省事监听 document 再用 e.target.matches() 过滤目标。子元素容易匹配漏,<input>、<textarea>、contenteditable 又容易被误伤,粘贴和拼写检查会一起废掉。把监听挂在具体区域上,用 closest('[data-context-menu]') 圈定作用域更稳。

一份能直接用的实现

// 菜单容器 id,全局唯一,方便反复创建/销毁
const MENU_ID = '__ctx_menu__';
const closeMenu = () => document.getElementById(MENU_ID)?.remove();

function openMenu(x, y, items) {
  closeMenu();                          // 先清旧的,否则菜单会层层堆叠
  const menu = document.createElement('div');
  menu.id = MENU_ID;
  menu.className = 'ctx-menu';
  menu.tabIndex = -1;                       // 可聚焦,键盘用户才操作得了
  menu.innerHTML = items
    .map(i => `<div class="ctx-item" data-act="${i.act}">${i.label}</div>`)
    .join('');
  document.body.appendChild(menu);         // 必须挂 body

  const w = menu.offsetWidth, h = menu.offsetHeight;
  menu.style.left = Math.min(x, innerWidth  - w - 8) + 'px';  // 贴边翻转
  menu.style.top  = Math.min(y, innerHeight - h - 8) + 'px';
  menu.focus();
}

document.addEventListener('contextmenu', e => {
  const zone = e.target.closest('[data-context-menu]');
  if (!zone) return;                    // 非目标区域,放行原生菜单
  if (e.target.closest('input, textarea, [contenteditable]')) return;
  e.preventDefault();                   // 第一行,同步,一毫秒都不能晚

  openMenu(e.clientX, e.clientY, [
    { label: '复制',   act: 'copy' },
    { label: '重命名', act: 'rename' },
    { label: '删除',   act: 'delete' }
  ]);
});

// 菜单项点击
document.addEventListener('click', e => {
  const item = e.target.closest('.ctx-item');
  if (!item) return;
  console.log('执行:', item.dataset.act);
  closeMenu();
});

// 四种关闭路径:点外部、Esc、失焦、滚动
document.addEventListener('mousedown', e => {
  if (!e.target.closest('.ctx-menu')) closeMenu();
});
document.addEventListener('keydown', e => e.key === 'Escape' && closeMenu());
addEventListener('blur', closeMenu);
addEventListener('scroll', closeMenu, true);   // 捕获阶段,内部滚动容器也覆盖
.ctx-menu {
  position: fixed;      /* 不是 absolute */
  z-index: 9999;
  min-width: 140px;
  background: #fff;
  border: 1px solid #e0e0e0;
  border-radius: 6px;
  box-shadow: 0 4px 16px rgba(0,0,0,.12);
  padding: 4px;
}
.ctx-item { padding: 7px 12px; cursor: pointer; font-size: 13px; }
.ctx-item:hover { background: #f2f2f2; }

定位为什么总错位

九成错位都出在坐标和定位方式没配对。

菜单的定位方式

该用哪组坐标

position: fixed

e.clientX / e.clientY,直接赋值

position: absolute

e.pageX / e.pageY,即 client + window.scrollX/Y

本文用的是 fixed + clientX/clientY,这套最省心:视口坐标对视口定位,不用管页面滚到哪。有人在这基础上再加 window.scrollX,反而多滚了一次。

还有个更隐蔽的原因:菜单的父容器只要带 transform、filter、perspective,position: fixed 就会退化成相对该容器定位,页面一滚菜单就飘。所以菜单一律 appendChild 到 document.body。另外别忘了边界判断,右键点在下边缘时菜单要往上翻,上面代码里的 Math.min 就是干这个的。

关不掉的菜单

最常见的翻车现场:监听 document.mousedown 关闭菜单,结果点菜单项自己也算”点外部”,菜单刚弹出就没了。判断条件必须把菜单本身和触发元素排掉:

if (!e.target.closest('.ctx-menu') && !e.target.matches('.trigger')) {
  closeMenu();
}

另外三件事别漏:菜单用完 remove() 而不只是 display:none,不然 DOM 越堆越多;Esc 键必须支持;页面滚动时用捕获阶段监听,菜单才不会粘在原地跟着内容跑偏。

剩下的坑

  • preventDefault() 塞进 setTimeout 或 Promise.then,晚 1 毫秒原生菜单就闪一下。

  • 别用 e.button === 2 判断右键。contextmenu 本来就是右键(移动端的长按),加了反而可能漏。

  • iframe 和 Shadow DOM 不穿透,得分别在 contentDocument 和 shadowRoot 上单独绑。

  • 多个回调都监听了 contextmenu,只在其中一个里写 preventDefault(),其余照弹不误。

  • iOS Safari 对 contextmenu 支持很弱,Android Chrome 长按可能和系统菜单叠加,移动端必须真机测。

  • 无障碍:容器加 role="menu"、子项 role="menuitem",屏幕上才读得出来。

拦了原生菜单就得给出等价能力——复制、粘贴、打开链接这些,别让用户少了条路。全站禁用右键这种事,能不做就不做。

0 / 600
0 讨论
热门最新
总结
暂无总结
嗨,下午好!
所有的成功,都源自一个勇敢的开始

暂无福利券