从零实现一个灵活可扩展的Menu菜单组件
项目初期的技术选型
这次的项目是一个内部管理系统,涉及到大量的数据展示和操作。因为菜单结构比较复杂,有三级嵌套,还要求支持动态加载。开始的时候我想用现成的组件库,像Ant Design或者Element Plus都挺香的,但产品那边突然加了个需求:菜单项需要根据用户权限动态显示,并且还要支持自定义右键菜单。
这下就有点麻烦了,现成的组件虽然功能强大,但在这种高度定制化的需求面前反而束手束脚。折腾了一天各种配置,最后发现不如自己写一个来得快。于是决定基于Vue3从零开始造轮子,反正核心逻辑也不复杂。
最大的坑:性能问题
刚开始写的时候没想太多,直接用了递归渲染的方式处理多级菜单。代码看起来挺优雅的:
function renderMenu(items) {
return items.map(item => {
if (item.children) {
return (
<SubMenu key={item.key} title={item.title}>
{renderMenu(item.children)}
</SubMenu>
)
}
return <MenuItem key={item.key}>{item.title}</MenuItem>
})
}
本地跑起来没啥问题,数据量小的时候确实流畅得很。结果一上线就翻车了——有个客户的菜单项超过500个,页面直接卡死了。打开开发者工具一看,渲染时间飙到了3秒多。
后来才发现问题出在两个地方:一是递归渲染本身就有性能开销;二是每次状态变化都会触发整个菜单树的重新渲染。
优化之路:虚拟列表+懒加载
解决性能问题花了我整整三天。最先想到的是虚拟列表,只渲染可视区域的内容。不过菜单这种树形结构比普通列表复杂得多,普通的虚拟列表方案根本用不上。折腾了半天,最后用了个折中的办法:
把一级菜单正常渲染,二级和三级菜单采用懒加载的方式。只有当用户展开某个菜单项时,才去加载它的子菜单。这样既保证了初始加载速度,又不会一次性渲染太多DOM节点。
const Menu = ({ items }) => {
const [expandedKeys, setExpandedKeys] = useState([])
const handleExpand = (key) => {
if (expandedKeys.includes(key)) {
setExpandedKeys(expandedKeys.filter(k => k !== key))
} else {
setExpandedKeys([...expandedKeys, key])
}
}
return (
<ul>
{items.map(item => (
<li key={item.key}>
<span onClick={() => handleExpand(item.key)}>
{item.title}
</span>
{expandedKeys.includes(item.key) && item.children && (
<Menu items={item.children} />
)}
</li>
))}
</ul>
)
}
这里有个小技巧:通过维护expandedKeys数组来控制哪些菜单是展开状态,避免不必要的重渲染。实测下来,就算菜单项上千,页面也能保持流畅。
意外收获:右键菜单的实现
说到右键菜单,一开始我是拒绝的,觉得这个功能太鸡肋。但架不住产品经理坚持说这是刚需,只好硬着头皮做。本来以为很麻烦,没想到Vue3的组合式API让这个功能变得异常简单。
核心思路是在document上监听contextmenu事件,然后根据点击位置动态创建菜单实例:
import { createApp } from 'vue'
import ContextMenu from './ContextMenu.vue'
let contextMenuInstance = null
export function showContextMenu(event, menuItems) {
if (contextMenuInstance) {
document.body.removeChild(contextMenuInstance.$el)
}
event.preventDefault()
const app = createApp(ContextMenu, { menuItems })
const el = document.createElement('div')
document.body.appendChild(el)
contextMenuInstance = app.mount(el)
contextMenuInstance.$el.style.position = 'absolute'
contextMenuInstance.$el.style.left = ${event.clientX}px
contextMenuInstance.$el.style.top = ${event.clientY}px
const handleClickOutside = () => {
document.body.removeChild(contextMenuInstance.$el)
document.removeEventListener('click', handleClickOutside)
contextMenuInstance = null
}
document.addEventListener('click', handleClickOutside)
}
使用起来也很方便:
document.addEventListener('contextmenu', (e) => {
showContextMenu(e, [
{ label: '编辑', action: () => console.log('edit') },
{ label: '删除', action: () => console.log('delete') }
])
})
最终的解决方案
经过一系列优化,菜单组件终于算是能用了。整体架构分成三个部分:
- 基础菜单渲染:负责一级菜单的展示
- 懒加载机制:按需加载子菜单
- 右键菜单:独立的浮动层组件
权限控制这部分也值得一提。最开始我是把权限判断写在每个菜单项里,后来发现这样太难维护。改成了统一的权限过滤器:
function filterMenus(menus, permissions) {
return menus.filter(menu => {
if (!permissions.includes(menu.permission)) return false
if (menu.children) {
menu.children = filterMenus(menu.children, permissions)
}
return true
})
}
// 使用示例
const userPermissions = ['view', 'edit']
const filteredMenus = filterMenus(rawMenus, userPermissions)
回顾与反思
这个菜单组件虽然功能上满足了需求,但还是有些遗憾的地方。比如懒加载的动画效果还不够平滑,有时候会出现闪一下的情况;还有就是右键菜单在某些特殊场景下(比如iframe里)会有定位问题。
不过总体来说,这次的实战让我对Vue3的响应式系统有了更深的理解,特别是在处理复杂状态更新的时候。那些性能优化的套路也是实打实踩出来的经验。
以上是我个人对这个Menu菜单组件的完整讲解,有更优的实现方式欢迎评论区交流。后续还会继续分享这类实战经验,希望对你有帮助。

暂无评论