级联菜单看起来只是“点开、点别处收起”的小交互,真正写起来却很容易误伤内部点击,或者只照顾鼠标却漏掉键盘操作。要把这类菜单做稳,核心不是堆更多监听器,而是先理清事件从哪里冒泡、什么时候该拦、什么时候该让它继续传递,再补上焦点移出时的收起判断。
先明确自动收起的判断边界
在多级下拉导航这类级联菜单里,最常见的需求是:
- 点击菜单内部任意层级,菜单继续保持打开状态,方便用户继续操作;
- 点击菜单外部任意区域,当前展开的子菜单自动收起;
- 用键盘导航时,焦点真正离开菜单后,也要能自然收起。
因此,自动收起逻辑最好不要分散在每个菜单项上,而是统一围绕菜单容器来判断。无论用户点的是文字、图标,还是某一级子项,最终都可以借助事件冒泡,把这次操作归类为“菜单内”还是“菜单外”。
假设你的菜单由一个外层容器包裹:
<nav class="dropdown-menu" id="main-menu">
...
</nav>
后面的点击判断、焦点判断,都会围绕这个容器展开。
推荐方案:监听 document 点击并配合 contains()
实际项目里,更稳妥的写法通常不是只监听菜单容器,而是直接监听 document 的 click 事件,再用 menuElement.contains(e.target) 判断点击目标是否仍在菜单范围内。

这样做的原因很直接:菜单内部任何元素被点击后,事件都会继续冒泡到 document。你不需要给每一级菜单重复绑定“是否关闭”的逻辑,只要在最外层统一判定一次即可。
const menuElement = document.getElementById('main-menu');
document.addEventListener('click', (e) => {
if (!menuElement.contains(e.target)) {
closeAllSubmenus();
}
});
这段逻辑的判断方式可以拆成两步理解:
- 如果
e.target是菜单本身,或者它的任意后代元素,contains()会返回true,说明用户还在菜单里操作,不应该收起; - 如果返回
false,说明点击发生在菜单容器之外,这时再调用closeAllSubmenus()才合理。
它的优势在于结构清晰,而且随着菜单层级增多也不会失控。一级、二级、三级菜单项的点击,最终都能走到同一套外部判断逻辑里,不需要为每一层补单独的关闭分支。
什么时候需要 stopPropagation(),什么时候其实不必加
很多人一看到 document 监听,就会习惯性地在菜单内部加 e.stopPropagation()。这并不总是必须,但在某些交互里确实有价值。

例如,点击某个带子菜单的项目时,你希望它只负责展开下一级菜单,而不要让这次点击继续向上冒泡,触发额外的全局点击逻辑:
const item = document.querySelector('.has-sub');
item.addEventListener('click', (e) => {
e.stopPropagation();
openSubmenu();
});
这种写法适合下面两类情况:
- 内部点击除了展开、跳转之外,还可能和全局点击监听发生冲突;
- 你希望把某些菜单内部行为完全限定在局部,不再交给上层监听器处理。
但也要注意,前面那种 document + contains() 的实现本身已经能识别“是不是点在菜单里”。如果你的逻辑足够简单,内部点击即使冒泡到 document,也会被 contains() 正确过滤掉,并不会误关菜单。
所以更准确的结论是:e.stopPropagation() 是可选增强,不是这类需求的前提条件。只有在内部交互和上层逻辑确实互相干扰时,再加它会更合适。
别只处理鼠标:用 focusout 补齐键盘操作
如果级联菜单支持键盘导航,只监听鼠标点击还不够。用户按 Tab 在菜单项之间移动焦点时,菜单也应该表现一致:焦点还在菜单里时保持展开,焦点离开整个菜单区域后自动收起。

这时可以在菜单容器上补一个 focusout 监听,并结合 e.relatedTarget 做判断:
menuElement.addEventListener('focusout', (e) => {
if (!menuElement.contains(e.relatedTarget)) {
closeAllSubmenus();
}
});
这里的关键点在于:
focusout适合监听“焦点从当前元素或其子元素移出”的场景;e.relatedTarget表示焦点即将移动到的新元素;- 如果新焦点不在
menuElement内部,就说明用户已经离开整个菜单,可以执行收起。
这样一来,鼠标点击页面其他区域会触发关闭,键盘把焦点移出菜单也会触发关闭,两套操作路径最终得到一致体验。对于可访问性要求更高的导航组件,这是很有必要的一步。
一套更稳的实现思路可以这样落地
把这类级联菜单的自动收起逻辑收束一下,可以得到一套比较实用的原则:
- 收起判断统一放在外层,优先使用
document.addEventListener('click', ...); - 通过
menuElement.contains(e.target)区分菜单内点击和菜单外点击; - 对展开子菜单这类内部交互,只有在确实会与全局监听冲突时,再补
e.stopPropagation(); - 为键盘导航补上
focusout,并结合e.relatedTarget与contains()判断焦点是否真正离开菜单。
归根到底,事件冒泡在这里不是用来“阻止一切”的,而是用来集中处理逻辑。只要把内部操作过滤清楚,把外部点击和焦点移出这两条关闭路径补完整,级联菜单的自动收起就会稳定得多。













