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

内联属性为什么靠不住
很多人上来就写 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; }
定位为什么总错位
九成错位都出在坐标和定位方式没配对。
|
菜单的定位方式 |
该用哪组坐标 |
|---|---|
|
|
|
|
|
|
本文用的是 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",屏幕上才读得出来。
拦了原生菜单就得给出等价能力——复制、粘贴、打开链接这些,别让用户少了条路。全站禁用右键这种事,能不做就不做。

