从零实现一个高性能可扩展的前端Menu菜单组件
Menu菜单方案,我到底该选哪个?
最近在重构一个老后台系统,菜单组件要重写。之前用的是纯手写的 DOM 操作,改个样式能改到怀疑人生。这次我决定认真对比下主流方案:原生 JS 手写、React + 自定义 Hook、以及直接上 Ant Design 的 Menu 组件。折腾了两天,踩了几个坑,也摸清了各自的脾气。今天就来说说我的真实体验——不讲大道理,只聊实际开发中谁更省事、谁更灵活、谁容易半夜让你爬起来修 bug。
手写原生 JS:自由但累成狗
我一开始其实是想自己写的。毕竟菜单逻辑看起来简单:点一下展开,再点收起,hover 时高亮,支持多级嵌套……但写到第三层嵌套的时候我就后悔了。状态管理乱成一锅粥,键盘导航(Tab / Arrow)完全没考虑,无障碍(a11y)更是灾难。而且一旦需求加个“动态加载子菜单”或者“记住上次展开项”,代码立马膨胀。
不过,如果你项目极简,比如就一级菜单,或者对 bundle size 极度敏感(比如嵌入式 H5),手写确实可控。核心代码其实就这些:
<nav class="menu">
<ul>
<li class="menu-item has-children">
<a href="#">产品</a>
<ul class="submenu">
<li><a href="#">设计工具</a></li>
<li><a href="#">开发套件</a></li>
</ul>
</li>
</ul>
</nav>
document.querySelectorAll('.menu-item.has-children > a').forEach(link => {
link.addEventListener('click', (e) => {
e.preventDefault();
const parent = e.target.parentElement;
parent.classList.toggle('open');
});
});
但注意:这个方案没有处理点击外部关闭、没有 focus 管理、没有动画过渡。你得自己补,而每补一个功能,bug 就多一个。我上次上线后用户反馈“手机上点不开”,查了半天发现是 touch 事件没监听……所以除非你时间多得发慌,否则别轻易手写。
React + 自定义 Hook:灵活又可控,我的心头好
现在我大部分项目都用 React,所以自然想到用自定义 Hook 来封装菜单逻辑。好处是状态集中管理、复用性强,还能结合 Context 做全局菜单控制。最关键的是,你可以精确控制每一帧的渲染行为,性能优化空间大。
我写了一个简化版的 useMenu,核心思路是用 useState 存当前展开的 key,配合递归渲染:
function useMenu(initialOpenKeys = []) {
const [openKeys, setOpenKeys] = useState(new Set(initialOpenKeys));
const toggle = (key) => {
const newSet = new Set(openKeys);
if (newSet.has(key)) {
newSet.delete(key);
} else {
newSet.add(key);
}
setOpenKeys(newSet);
};
return { openKeys, toggle };
}
// 使用
function MenuItem({ item, level = 0 }) {
const { openKeys, toggle } = useContext(MenuContext);
const isOpen = openKeys.has(item.key);
return (
<li>
<div onClick={() => toggle(item.key)}>
{item.title}
</div>
{item.children && isOpen && (
<ul>
{item.children.map(child => (
<MenuItem key={child.key} item={child} level={level + 1} />
))}
</ul>
)}
</li>
);
}
这套方案我亲测有效,尤其适合需要和业务深度耦合的场景。比如菜单项要根据权限动态显示,或者点击后触发异步加载子菜单(这时候我在 toggle 里加个 async/await 就行)。而且因为是你自己写的,出问题一眼就能定位。
但缺点也很明显:前期投入时间多。你要处理键盘导航、focus 顺序、aria 属性,还得写测试。不过一旦搭好架子,后续维护成本很低。我现在的后台系统全靠这个,改样式、加功能都没翻过车。
Ant Design Menu:开箱即用,但别被“简单”骗了
很多人第一反应是直接用 Ant Design 的 Menu 组件。确实,几行代码就能跑起来:
import { Menu } from 'antd';
const items = [
{
key: '1',
label: '导航一',
children: [
{ key: '1-1', label: '选项1' },
{ key: '1-2', label: '选项2' },
],
},
];
<Menu mode="inline" items={items} />
看起来很美,对吧?但等你真用起来,坑就来了。比如你想自定义菜单项的渲染结构(比如加个图标、加个 badge),官方文档说可以用 label 传 ReactNode,但你会发现 hover 样式可能错乱,或者子菜单宽度计算异常。更烦的是,它的内部状态是黑盒,你想监听“某个菜单展开完成”这种事件,得 hack 它的内部方法,非常不稳。
还有一次,我需要菜单在移动端变成抽屉式,结果发现 mode 切换时动画会闪一下。查 GitHub issue 发现这是个老问题,官方建议“自己控制 mode”。行吧,那我又得在外层包一层状态判断,反而比手写还啰嗦。
所以我的结论是:如果你的菜单就是标准后台样式,不折腾 UI,也不需要特殊交互,AntD 能省你两天时间。但只要有一点定制需求,它就会反过来拖你后腿。而且 bundle size 会增加 50KB+,对性能敏感的项目得掂量下。
我的选型逻辑:看团队、看需求、看 deadline
说实话,没有“最好”的方案,只有“最适合当下”的方案。我自己的选型逻辑是这样的:
- 如果是个人项目或 MVP 验证,直接上 Ant Design,先跑起来再说;
- 如果是中大型后台系统,且团队有 React 经验,我强烈推荐自定义 Hook 方案——前期多花半天,后期少熬十次夜;
- 如果是超轻量级页面(比如营销页侧边栏),手写原生 JS 反而是最干净的选择,但务必加上基础的 a11y 支持。
另外,千万别忽视无障碍。我之前吃过亏,上线后被 QA 提了 accessibility 问题,说屏幕阅读器读不出菜单层级。后来在自定义 Hook 里加了 role="menu"、aria-expanded 这些属性才过关。AntD 虽然内置了 a11y,但一旦你自定义了结构,这些属性可能就丢了。
最后的小提醒
无论你选哪种方案,记得处理好这几个细节:
- 点击菜单外区域自动收起(可以用
useClickOutsideHook); - 移动端 touch 事件兼容(别只监听 click);
- 菜单高度变化时的平滑过渡(CSS
max-height动画比height: auto更可靠); - 动态数据更新时的状态同步(比如用户权限变更后菜单项变化)。
以上是我踩坑后的总结,希望对你有帮助。如果你有更好的实现方式,或者在 AntD 里解决了我提到的那些问题,欢迎评论区交流——毕竟前端这行,永远有更优雅的解法等着我们去发现。

暂无评论