从零实现一个灵活可扩展的Menu菜单组件

司徒钰浩 组件 阅读 2,131
赞 23 收藏
二维码
手机扫码查看
反馈

项目初期的技术选型

这次的项目是一个内部管理系统,涉及到大量的数据展示和操作。因为菜单结构比较复杂,有三级嵌套,还要求支持动态加载。开始的时候我想用现成的组件库,像Ant Design或者Element Plus都挺香的,但产品那边突然加了个需求:菜单项需要根据用户权限动态显示,并且还要支持自定义右键菜单。

从零实现一个灵活可扩展的Menu菜单组件

这下就有点麻烦了,现成的组件虽然功能强大,但在这种高度定制化的需求面前反而束手束脚。折腾了一天各种配置,最后发现不如自己写一个来得快。于是决定基于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菜单组件的完整讲解,有更优的实现方式欢迎评论区交流。后续还会继续分享这类实战经验,希望对你有帮助。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论