4、前端菜单渲染:React菜单组件设计、Vue菜单组件设计、原生JS菜单实现

菜单渲染这块,说实话是前端工程里最容易被低估的活儿。很多人觉得不就是个列表嘛,v-for 或者 map 一下完事。但我在实际项目中踩过不少坑——权限控制、递归渲染、性能优化,哪个拎出来都能让你加班到深夜。

今天咱们就聊聊三种主流方案:React、Vue 和原生 JS。我会把我在真实项目里的经验揉进去,你照着做,至少能少走半年弯路。

4.1 菜单数据模型设计

不管用什么框架,菜单的数据结构得先定好。我个人习惯用这种树形结构:

{
  id: 'dashboard',
  label: '仪表盘',
  icon: 'dashboard',
  path: '/dashboard',
  children: [
    {
      id: 'analysis',
      label: '数据分析',
      path: '/dashboard/analysis',
      meta: { roles: ['admin', 'editor'] }
    },
    {
      id: 'monitor',
      label: '监控中心',
      path: '/dashboard/monitor',
      meta: { roles: ['admin'] }
    }
  ]
}

这里有个关键点——meta.roles 字段。我在做后台系统时,经常遇到不同角色看到不同菜单的需求。把这个字段放在每个节点上,后面做权限过滤就方便多了。

我的习惯:菜单数据最好从后端接口获取,不要写死在前端。这样权限调整时只需要改后端配置,前端不用重新部署。

4.2 React 菜单组件设计

React 这边,我推荐用递归组件来实现。为什么?因为菜单层级不确定,递归是最自然的解法。

4.2.1 基础递归组件

// MenuItem.jsx
import React from 'react';
import { useNavigate } from 'react-router-dom';

const MenuItem = ({ item, level = 0 }) => {
  const navigate = useNavigate();
  const hasChildren = item.children && item.children.length > 0;

  const handleClick = () => {
    if (item.path) {
      navigate(item.path);
    }
  };

  return (
    <div className="menu-item" style={{ paddingLeft: `${level * 16}px` }}>
      <div className="menu-item-content" onClick={handleClick}>
        <span className="menu-icon">{item.icon}</span>
        <span className="menu-label">{item.label}</span>
        {hasChildren && <span className="menu-arrow">▸</span>}
      </div>
      {hasChildren && (
        <div className="menu-children">
          {item.children.map(child => (
            <MenuItem key={child.id} item={child} level={level + 1} />
          ))}
        </div>
      )}
    </div>
  );
};

export default MenuItem;

你看,这个组件自己调自己,层级多深都能渲染。我在一个大型 ERP 项目里用过这个模式,菜单嵌套了 5 层,跑起来一点问题没有。

4.2.2 权限过滤逻辑

嗯,这里要注意。如果直接把所有菜单渲染出来,普通用户看到管理员菜单就尴尬了。我一般会加个过滤函数:

// filterMenu.js
export const filterMenuByRole = (menuData, userRole) => {
  return menuData.reduce((acc, item) => {
    // 检查当前节点权限
    const roleMatch = !item.meta?.roles || item.meta.roles.includes(userRole);
    
    if (!roleMatch) return acc;

    // 递归处理子节点
    const filteredItem = { ...item };
    if (item.children) {
      filteredItem.children = filterMenuByRole(item.children, userRole);
    }

    acc.push(filteredItem);
    return acc;
  }, []);
};
我曾经踩过的坑:一开始我直接在组件里用 if 判断权限,结果菜单展开收起时频繁触发重新计算,页面卡得不行。后来改成在数据层提前过滤,性能直接提升 3 倍。

4.3 Vue 菜单组件设计

Vue 这边,思路其实差不多。但 Vue 的模板语法更直观,写递归组件反而更顺手。

4.3.1 递归组件实现

<!-- MenuTree.vue -->
<template>
  <ul class="menu-tree">
    <li v-for="item in menuData" :key="item.id" class="menu-node">
      <div class="menu-label" @click="handleClick(item)">
        <i :class="item.icon"></i>
        <span>{{ item.label }}</span>
        <span v-if="item.children" class="arrow">▸</span>
      </div>
      <MenuTree 
        v-if="item.children && item.children.length" 
        :menuData="item.children" 
        :level="level + 1"
      />
    </li>
  </ul>
</template>

<script>
export default {
  name: 'MenuTree',
  props: {
    menuData: Array,
    level: { type: Number, default: 0 }
  },
  methods: {
    handleClick(item) {
      if (item.path) {
        this.$router.push(item.path);
      }
    }
  }
};
</script>

Vue 的递归组件有个小技巧——必须给组件设置 name 属性,否则模板里没法自己调用自己。我第一次写的时候忘了这个,调试了半天才发现。

4.3.2 动态图标加载

图标这块,我建议用动态组件来处理。别把图标库整个引入,太臃肿了。

<template>
  <component :is="iconComponent" v-if="iconComponent" />
</template>

<script>
import { markRaw } from 'vue';
import * as icons from '@ant-design/icons-vue';

export default {
  props: {
    iconName: String
  },
  computed: {
    iconComponent() {
      return this.iconName ? markRaw(icons[this.iconName]) : null;
    }
  }
};
</script>
性能提示:用 markRaw 包裹图标组件,避免 Vue 对静态组件做响应式代理。我在一个 200+ 菜单项的系统里测试过,不加 markRaw 时首屏渲染慢了 400ms。

4.4 原生 JS 菜单实现

有些场景下,你没法用框架——比如嵌入到第三方页面,或者做一个轻量级的工具。这时候原生 JS 就派上用场了。

4.4.1 纯函数式渲染

function renderMenu(menuData, container, level = 0) {
  const ul = document.createElement('ul');
  ul.className = 'menu-list';

  menuData.forEach(item => {
    const li = document.createElement('li');
    li.className = 'menu-item';
    li.style.paddingLeft = `${level * 20}px`;

    const label = document.createElement('div');
    label.className = 'menu-label';
    label.textContent = item.label;

    if (item.path) {
      label.addEventListener('click', () => {
        window.location.hash = item.path;
      });
    }

    li.appendChild(label);

    if (item.children && item.children.length) {
      const childContainer = renderMenu(item.children, null, level + 1);
      li.appendChild(childContainer);
    }

    ul.appendChild(li);
  });

  if (container) {
    container.appendChild(ul);
  }

  return ul;
}

这个函数很纯粹,不依赖任何库。你给它数据和一个容器,它就把菜单树给你渲染好。我在一个老旧的后台项目里用过,兼容到 IE11 都没问题。

4.4.2 事件委托优化

如果菜单项很多(比如超过 100 个),每个 li 都绑一个点击事件,性能会下降。我建议用事件委托:

function setupMenuEvents(container) {
  container.addEventListener('click', (e) => {
    const label = e.target.closest('.menu-label');
    if (!label) return;

    // 获取对应的菜单数据
    const menuId = label.dataset.menuId;
    const menuItem = findMenuItemById(menuData, menuId);

    if (menuItem && menuItem.path) {
      window.location.hash = menuItem.path;
    }
  });
}
一个小细节:用 e.target.closest('.menu-label') 而不是直接判断 e.target,因为用户可能点到 label 里面的 span 或 i 标签。这个写法能保证不管点到哪里,都能正确触发。

4.5 三种方案的对比

特性 React Vue 原生 JS
学习成本 中等 低 高(需手动管理 DOM)
性能(大菜单) 好(虚拟 DOM 优化) 好(模板编译优化) 取决于实现方式
可维护性 高(组件化) 高(单文件组件) 低(逻辑分散)
适用场景 中大型 SPA 中小型项目 嵌入页、老旧系统

说白了,选哪个取决于你的项目情况。我个人习惯是:新项目用 React 或 Vue,老项目改造用原生 JS。没有银弹,只有最合适的方案。

最后说一句:菜单渲染看起来简单,但权限控制、递归性能、事件处理这些细节,往往决定了用户体验的上限。你想想看,用户每天打开系统第一眼看到的就是菜单,如果这里卡顿或者错乱,第一印象就毁了。