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;
}, []);
};
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。没有银弹,只有最合适的方案。