接手过基于jQuery的后台系统的人,大概率都处理过导航菜单的子菜单展开问题。最常见的一个需求是:同级菜单里一次只允许一个子菜单展开,展开新的之前先收起旧的。另一个需求则是点击页面空白区域,所有展开的子菜单自动关闭。这两个功能写起来并不复杂,但很多人在项目里会各写各的,导致同一个菜单容器上绑了多套互相冲突的事件逻辑。本文先把单开模式的实现梳理清楚,再补上点击外部关闭的完整方案,最后给出一个整合后的通用插件雏形。

单开模式的两种实现思路
假设你的菜单结构是常见的ul嵌套ul,外层菜单项带有类名.has-submenu,内层子菜单是一个紧跟其后的.submenu。最初的写法往往是在每个父菜单上绑定click事件,然后$(this).find('.submenu').slideToggle()。这样每个子菜单都能独立开合,但完全做不到单开,用户连续点三个菜单,三个子菜单全部展开,侧边栏滚得看不到底。
要实现单开,核心就是在展开当前子菜单之前,先把它的兄弟菜单下的子菜单全部收起。用jQuery的DOM遍历可以很直接地写出来:在click处理函数里,先找到当前菜单项的所有兄弟元素,再在这些兄弟元素里找到.submenu并调用slideUp(),然后再切换当前子菜单的状态。代码大致如下:
$('.has-submenu').on('click', function(e) {
e.stopPropagation();
var $submenu = $(this).children('.submenu');
// 先收起所有兄弟菜单的子菜单
$(this).siblings('.has-submenu').children('.submenu').slideUp(200);
// 再切换当前子菜单
$submenu.slideToggle(200);
});
这段代码能跑,但有一个小问题:兄弟元素的选择依赖.has-submenu类名,如果菜单层级较多,或者兄弟元素并不都带有这个类,就会漏掉。更稳妥的做法是用$(this).parent().children('.submenu')来定位同一父容器下的所有子菜单,不过这也要求所有子菜单都是父容器的直接子元素。对于标准的三层嵌套结构来说,通常可以接受。还有一种思路是完全基于状态切换:给展开的父菜单加一个.open类,在click事件里先把父容器中所有带.open的菜单项移除类并收起子菜单,再给当前项加上类并展开。这样逻辑更清晰,也方便通过CSS控制箭头旋转等视觉状态。
$('.has-submenu').on('click', function(e) {
e.stopPropagation();
var $this = $(this);
var wasOpen = $this.hasClass('open');
// 先收起同层所有已打开的菜单
$this.parent().find('.has-submenu.open').each(function() {
$(this).removeClass('open').children('.submenu').slideUp(200);
});
// 如果当前菜单原本是关闭的,则展开它
if (!wasOpen) {
$this.addClass('open').children('.submenu').slideDown(200);
}
});
这种基于类切换的方式在二次点击同一个菜单时也能正确收起,不会出现“点了自己却先被兄弟收掉又强制展开”的奇怪行为。当然,如果子菜单本身是用CSS的display:none配合slideDown来控制动画,那收起时用slideUp是配套的;如果改用fadeToggle或者纯类切换加CSS过渡,代码也要做相应调整。
点击外部关闭子菜单的完整处理
单开模式解决的是菜单内部互斥的问题,但用户展开某个子菜单后,一般会希望点击页面其他位置时这个菜单自动收起。如果忽略了这一点,用户只能再去点一次菜单项来收起,操作路径变长。处理点击外部关闭的标准做法是在document上绑定一个click事件,判断触发点击的元素是否在菜单容器内部。如果不在,就把所有展开的子菜单收起来。
这里有一个容易踩的坑:菜单项自身的click事件里通常会调用e.stopPropagation()来阻止事件冒泡到document,否则点击菜单项本身也会触发document上的关闭逻辑,导致菜单刚展开就被立即关闭。所以上面的代码里都写了e.stopPropagation()。而document层的click处理函数里就不需要stopPropagation了,直接判断目标元素位置即可。
$(document).on('click', function(e) {
var $target = $(e.target);
// 如果点击目标不在导航容器内,收起所有子菜单
if (!$target.closest('#main-nav').length) {
$('#main-nav .has-submenu.open').each(function() {
$(this).removeClass('open').children('.submenu').slideUp(200);
});
}
});
使用$(e.target).closest('#main-nav')来判断是否在容器内,兼容点击的是子菜单内部的链接、图标或者空白区域。如果点击事件目标本身就是导航容器内部的元素,closest会返回非空,不会执行关闭。这种写法在jQuery中非常常见,但它要求导航容器有一个稳定的ID或者类名,比如#main-nav。如果页面里有多个独立的菜单组件,就需要分别处理,或者把容器选择器做成变量。
有些人喜欢在菜单项点击时不使用stopPropagation,而是在document的click里先判断目标是不是菜单项的触发按钮本身,是就不关闭。这种思路也可以,但代码会变得啰嗦,因为要区分“点击了菜单项标题”和“点击了子菜单里的链接”两种不同行为。相比之下,在菜单项的click里阻止冒泡是最简洁的做法。不过要注意,如果菜单项里还有其他的交互元素比如折叠图标单独绑定事件,那些事件也得根据自己的需要决定是否stopPropagation,否则可能和document层的关闭逻辑产生意料之外的联动。
整合成一个可复用的菜单管理器
把单开模式和点击外部关闭写在一起,大概就是下面这个结构。初始化时对整个导航容器做一次事件委托,内部菜单项和document各绑定一个click,这样即使后续通过AJAX动态加载了新的菜单项,委托机制也能自动生效,不需要重新绑定。
(function($) {
$.fn.singleAccordionMenu = function(options) {
var settings = $.extend({
submenuSelector: '.submenu',
parentSelector: '.has-submenu',
openClass: 'open',
animationSpeed: 200
}, options);
return this.each(function() {
var $nav = $(this);
// 菜单项点击:单开切换
$nav.on('click', settings.parentSelector, function(e) {
e.stopPropagation();
var $item = $(this);
var wasOpen = $item.hasClass(settings.openClass);
// 收起同级所有已展开的子菜单
$item.parent().find(settings.parentSelector + '.' + settings.openClass).each(function() {
$(this).removeClass(settings.openClass)
.children(settings.submenuSelector).slideUp(settings.animationSpeed);
});
if (!wasOpen) {
$item.addClass(settings.openClass)
.children(settings.submenuSelector).slideDown(settings.animationSpeed);
}
});
// 点击导航外部收起所有子菜单
$(document).on('click', function(e) {
if (!$(e.target).closest($nav).length) {
$nav.find(settings.parentSelector + '.' + settings.openClass).each(function() {
$(this).removeClass(settings.openClass)
.children(settings.submenuSelector).slideUp(settings.animationSpeed);
});
}
});
});
};
})(jQuery);
调用方式非常简单:$('#main-nav').singleAccordionMenu();。如果需要修改子菜单的选择器或者动画速度,传入配置对象即可。这个插件的命名叫singleAccordionMenu,因为它的行为和手风琴组件很像——同一时间只展开一个内容面板。如果你使用的手风琴插件恰好也能做菜单,逻辑是相通的,但这个轻量实现没有多余的样式依赖,更适合导航场景。
还有一个值得注意的细节:document上绑定的click事件在插件被多次初始化时会重复叠加,导致同一个点击触发多次关闭逻辑。虽然重复关闭不会有视觉差异,但会浪费性能,也可能因为多次执行slideUp而产生动画队列堆积。解决方式是在插件初始化前解绑之前绑定的事件,或者使用一个命名空间来区分,比如$(document).off('click.menuClose').on('click.menuClose', ...)。上面的示例代码为了保持简洁没有处理这个问题,实际项目里建议加上命名空间。
做导航菜单还有一个常见需求是鼠标悬停展开,而不是点击展开。如果要把上面这套改成hover模式,只需要把click事件换成mouseenter和mouseleave的组合,并且外部关闭逻辑改为判断鼠标是否移出导航容器。但没有必要为了hover去改document的点击监听,因为hover场景下用户离开菜单区域菜单就应该收起。如果你做的系统同时需要移动端点击和桌面端悬停,可以用matchMedia或者判断触摸设备来做分支,本文就不展开了。
jQuery导航菜单子菜单切换点击外部关闭修改时间:2026-10-03 05:35:41