Vue3+Element-Plus实现后台管理系统侧边菜单折叠与展开功能详解
1. 项目概述与核心价值最近在重构一个后台管理系统菜单栏的折叠与展开功能是每个开发者都绕不开的“标配”。乍一看这功能简单得像是“点击按钮切换宽度”但真做起来你会发现从状态管理、动画过渡到布局自适应处处是细节。用 Vue3 的 Composition API 配合 Element-Plus 的菜单组件来实现不仅能体验到响应式编程的爽快更能深入理解前端交互设计的精髓。这篇文章我就来拆解这个“Vue3Element-Plus 实现左侧菜单折叠与展开”功能不止于实现更会分享我在实际项目中趟过的坑和总结的最佳实践目标是让你看完就能在自己的项目里复现一个既流畅又健壮的菜单系统。这个功能的核心价值在于提升后台管理系统的空间利用率和操作效率。当我们需要专注处理右侧主内容区的复杂表单或图表时一个可折叠的侧边菜单能提供更宽阔的视野而当我们需要在不同模块间频繁切换时展开的菜单又能提供清晰的导航。实现它你将涉及 Vue3 的响应式状态 (ref,computed)、组件通信、CSS 过渡动画以及如何与 Element-Plus 的el-menu组件深度集成。无论你是刚接触 Vue3 的新手还是想优化现有项目的老手这里都有你需要的干货。2. 技术选型与项目环境搭建2.1 为什么是 Vue3 Element-Plus在开始写代码之前我们先聊聊技术选型。Vue3 的 Composition API 是这次实现的核心优势。相比于 Vue2 的 Options APIComposition API 让我们能更灵活地组织和复用逻辑。对于菜单折叠状态这个“状态”我们可以用一个ref或reactive来管理并通过computed派生出与之相关的样式、图标等逻辑集中且清晰。Element-Plus 作为当前 Vue3 生态中最成熟、使用最广泛的 UI 组件库之一其el-menu组件为我们提供了坚实的基础。它内置了垂直菜单模式、激活状态管理、路由集成等能力我们只需要在其之上添加折叠控制逻辑就能事半功倍。避免了从零开始编写菜单的 HTML 结构、CSS 样式和交互逻辑极大地提升了开发效率。注意确保你项目中的 Element-Plus 版本在 2.0.0 以上以获得对 Vue3 最稳定的支持。可以通过npm list element-plus或yarn list element-plus查看当前版本。2.2 初始化一个干净的 Vue3 项目如果你还没有项目让我们从零开始。这里推荐使用 Vite 作为构建工具它的启动速度和热更新体验远超 Webpack。# 使用 npm 7, 需要额外的双横线 npm create vuelatest my-admin-project -- --template vue-ts # 进入项目目录 cd my-admin-project # 安装依赖 npm install # 安装 Element-Plus 及其图标库 npm install element-plus element-plus/icons-vue项目创建后我们需要在入口文件通常是main.ts或main.js中全局引入 Element-Plus。// main.ts import { createApp } from vue import App from ./App.vue import ElementPlus from element-plus import element-plus/dist/index.css import * as ElementPlusIconsVue from element-plus/icons-vue const app createApp(App) // 全局注册 Element Plus app.use(ElementPlus) // 全局注册所有图标可选但推荐方便使用 for (const [key, component] of Object.entries(ElementPlusIconsVue)) { app.component(key, component) } app.mount(#app)至此一个支持 Vue3、TypeScript 和 Element-Plus 的基础开发环境就准备好了。3. 核心状态管理与布局设计3.1 定义全局折叠状态菜单的折叠状态是一个典型的全局状态它可能被侧边栏组件、顶部栏的折叠按钮、甚至面包屑组件所访问和修改。在中小型项目中使用 Vuex 或 Pinia 可能显得“杀鸡用牛刀”。我们可以利用 Vue3 的provide和inject来实现一个轻量级的全局状态管理。首先在App.vue或一个专门的状态文件中创建这个状态。!-- App.vue -- script setup langts import { ref, provide } from vue // 1. 定义响应式折叠状态默认是展开(false) const isCollapse ref(false) // 2. 提供一个修改状态的方法保持逻辑内聚 const toggleCollapse () { isCollapse.value !isCollapse.value } // 3. 将状态和方法提供给所有子组件 provide(isCollapse, isCollapse) provide(toggleCollapse, toggleCollapse) /script template div idapp !-- 布局组件会在这里注入并使用状态 -- router-view / /div /template这种方式的好处是简单直接状态流清晰。在需要使用的子组件中通过inject来获取。script setup langts import { inject } from vue const isCollapse injectRefboolean(isCollapse) const toggleCollapse inject() void(toggleCollapse) /script3.2 构建基础页面布局一个典型的管理后台布局包含侧边栏Aside、顶部栏Header和主内容区Main。我们使用 Element-Plus 的el-container系列组件可以快速搭建。!-- layouts/BasicLayout.vue -- template el-container classlayout-container !-- 侧边栏 -- el-aside :widthasideWidth classlayout-aside SideBar :collapseisCollapse / /el-aside el-container !-- 顶部栏 -- el-header classlayout-header HeaderBar toggle-collapsetoggleCollapse / /el-header !-- 主内容区 -- el-main classlayout-main router-view / /el-main /el-container /el-container /template script setup langts import { computed } from vue import SideBar from ./SideBar.vue import HeaderBar from ./HeaderBar.vue const props defineProps{ isCollapse: boolean }() const emit defineEmits{ toggle-collapse: [] }() const toggleCollapse () { emit(toggle-collapse) } // 动态计算侧边栏宽度折叠时64px展开时200px const asideWidth computed(() (props.isCollapse ? 64px : 200px)) /script style scoped .layout-container { height: 100vh; } .layout-aside { background-color: #304156; transition: width 0.3s ease-in-out; /* 添加宽度过渡动画 */ overflow: hidden; } .layout-header { background-color: #fff; border-bottom: 1px solid #e6e6e6; display: flex; align-items: center; } .layout-main { background-color: #f0f2f5; padding: 20px; } /style这里有几个关键点动态宽度使用computed属性asideWidth根据isCollapse状态返回不同的 CSS 宽度值。这是实现折叠效果的核心。CSS 过渡在.layout-aside的样式中添加transition: width 0.3s ease-in-out;。这会让宽度的变化有一个平滑的动画效果而不是生硬地跳变。组件通信HeaderBar组件中的折叠按钮点击后通过emit触发toggle-collapse事件通知父布局组件BasicLayout更新状态。4. 侧边栏菜单组件的深度实现4.1 配置 Element-Plus 的 el-menuSideBar.vue组件是功能的核心。我们需要正确配置el-menu以响应折叠状态。!-- components/layout/SideBar.vue -- template el-menu :default-activeactiveMenu :collapsecollapse :collapse-transitionfalse background-color#304156 text-color#bfcbd9 active-text-color#409EFF classside-bar-menu selecthandleSelect sidebar-item v-forroute in permission_routes :keyroute.path :itemroute :base-pathroute.path :is-collapsecollapse / /el-menu /template script setup langts import { computed } from vue import { useRoute } from vue-router import SidebarItem from ./SidebarItem.vue // 假设我们从权限模块获取路由列表 import { usePermissionStore } from /store/permission const route useRoute() const permissionStore usePermissionStore() const permission_routes permissionStore.routes const props defineProps{ collapse: boolean }() // 计算当前激活的菜单用于高亮 const activeMenu computed(() { const { meta, path } route // 如果路由元信息中设置了activeMenu则以其为准用于激活父菜单 if (meta.activeMenu) { return meta.activeMenu as string } return path }) const handleSelect (index: string) { // 可以在这里处理菜单选择后的逻辑例如埋点 console.log(菜单被选中:, index) } /script style scoped .side-bar-menu { border-right: none; /* 去除默认边框 */ height: 100%; } /* 重要解决折叠时子菜单文本溢出的问题 */ .side-bar-menu:not(.el-menu--collapse) { width: 200px; } /style关键属性解析:collapsecollapse这是控制菜单折叠的核心属性。当为true时el-menu会切换到迷你模式只显示图标。:collapse-transitionfalse禁用 Element-Plus 自带的折叠动画。因为我们通过外层容器的宽度过渡来实现整体动画这样可以避免两层动画冲突导致的不流畅。background-color,text-color用于定制菜单的主题色保持与设计一致。:default-active绑定到activeMenu计算属性确保页面刷新或路由跳转后对应的菜单项能正确高亮。4.2 递归渲染菜单项 (SidebarItem)后台菜单通常是多层嵌套的。我们需要一个递归组件SidebarItem.vue来处理这种树形结构。!-- components/layout/SidebarItem.vue -- template !-- 没有子路由或者只有一个需要始终显示的隐藏子路由时渲染 el-menu-item -- template v-ifhasOneShowingChild(item.children, item) (!onlyOneChild.children || onlyOneChild.noShowingChildren) el-menu-item :indexresolvePath(onlyOneChild.path) clickclickLink(onlyOneChild) el-icon v-ifonlyOneChild.meta?.icon component :isonlyOneChild.meta.icon / /el-icon template #title span{{ onlyOneChild.meta?.title }}/span /template /el-menu-item /template !-- 有多个子路由时渲染 el-sub-menu -- el-sub-menu v-else :indexresolvePath(item.path) template #title el-icon v-ifitem.meta?.icon component :isitem.meta.icon / /el-icon span v-if!isCollapse{{ item.meta?.title }}/span /template sidebar-item v-forchild in item.children :keychild.path :itemchild :base-pathresolvePath(child.path) :is-collapseisCollapse / /el-sub-menu /template script setup langts import { computed } from vue import { useRouter } from vue-router import path from path-browserify // 需要安装 npm install path-browserify const props defineProps{ item: any // 路由项 basePath: string // 基础路径 isCollapse: boolean // 是否折叠 }() const router useRouter() // 判断是否只有一个需要显示的子菜单处理 hidden: true 的路由 const hasOneShowingChild (children: any[] [], parent: any) { if (!children) { children [] } const showingChildren children.filter((item) { if (item.meta item.meta.hidden) { return false } else { // 临时赋值用于后续渲染 onlyOneChild item return true } }) // 如果只有一个子菜单则直接显示这个子菜单不显示父级菜单 if (showingChildren.length 1) { return true } // 如果没有子菜单需要显示则将父级菜单本身作为唯一子菜单 if (showingChildren.length 0) { onlyOneChild { ...parent, path: , noShowingChildren: true } return true } return false } let onlyOneChild: any null // 用于存储唯一的子菜单项 // 解析完整路径 const resolvePath (routePath: string) { if (isExternal(routePath)) { return routePath } if (isExternal(props.basePath)) { return props.basePath } return path.resolve(props.basePath, routePath) } // 判断是否为外部链接 const isExternal (path: string) { return /^(https?:|mailto:|tel:)/.test(path) } // 处理菜单点击路由跳转或打开外部链接 const clickLink (menuItem: any) { if (isExternal(menuItem.path)) { window.open(menuItem.path, _blank) } else { router.push(resolvePath(menuItem.path)) } } /script这个递归组件是菜单系统的灵魂。它处理了两种情况的渲染逻辑最终菜单项 (el-menu-item)当路由没有子节点或只有一个需要显示的子节点时直接渲染为一个可点击的菜单项。父级菜单 (el-sub-menu)当路由有多个子节点时渲染为一个可展开/折叠的父级菜单并递归调用自身来渲染其子节点。实操心得在递归组件中路径 (path) 的解析是关键。务必使用path.resolve或类似方法拼接基础路径和相对路径否则路由跳转会失败。path-browserify库提供了 Node.jspath模块在浏览器环境下的功能。4.3 顶部栏折叠按钮实现顶部栏的按钮是触发折叠动作的入口。它的实现相对简单。!-- components/layout/HeaderBar.vue -- template div classheader-container !-- 折叠按钮 -- div classcollapse-btn clicktoggleCollapse el-icon v-ifisCollapseExpand //el-icon el-icon v-elseFold //el-icon /div !-- 其他顶部栏内容如面包屑、用户信息等 -- div classheader-right Breadcrumb / UserDropdown / /div /div /template script setup langts import { Expand, Fold } from element-plus/icons-vue import Breadcrumb from ./Breadcrumb.vue import UserDropdown from ./UserDropdown.vue const props defineProps{ isCollapse: boolean }() const emit defineEmits{ toggle-collapse: [] }() const toggleCollapse () { emit(toggle-collapse) } /script style scoped .header-container { display: flex; align-items: center; justify-content: space-between; width: 100%; height: 100%; } .collapse-btn { font-size: 20px; cursor: pointer; padding: 0 10px; display: flex; align-items: center; } .collapse-btn:hover { background-color: #f5f7fa; } .header-right { display: flex; align-items: center; } /style这里使用了 Element-Plus 的图标组件Expand /和Fold /根据isCollapse状态切换图标视觉反馈非常直观。5. 高级功能与性能优化5.1 持久化折叠状态用户折叠或展开菜单后刷新页面这个状态应该被记住。我们可以利用浏览器的localStorage或sessionStorage来实现状态的持久化。首先我们改造一下状态管理使其支持持久化。我们可以创建一个可组合函数useCollapse。// composables/useCollapse.ts import { ref, onMounted } from vue export default function useCollapse() { // 尝试从 localStorage 读取保存的状态默认为展开(false) const storedState localStorage.getItem(sidebar-collapse) const isCollapse ref(storedState ? JSON.parse(storedState) : false) const toggleCollapse () { isCollapse.value !isCollapse.value // 状态变化时同步到 localStorage localStorage.setItem(sidebar-collapse, JSON.stringify(isCollapse.value)) } // 可选在组件挂载时从 localStorage 初始化状态 onMounted(() { // 如果之前有存储则使用存储的值 const stored localStorage.getItem(sidebar-collapse) if (stored ! null) { isCollapse.value JSON.parse(stored) } }) return { isCollapse, toggleCollapse } }然后在App.vue中使用这个组合式函数并通过provide共享出去。!-- App.vue -- script setup langts import { provide } from vue import useCollapse from /composables/useCollapse const { isCollapse, toggleCollapse } useCollapse() provide(isCollapse, isCollapse) provide(toggleCollapse, toggleCollapse) /script这样用户的折叠偏好就会被保存在浏览器本地即使关闭标签页或浏览器下次访问时依然保持原样。5.2 响应式适配在小屏幕上自动折叠在移动设备或小屏幕笔记本上侧边栏通常需要自动折叠以节省空间。我们可以利用 CSS 媒体查询和 Vue 的响应式数据来实现。首先创建一个可组合函数来检测屏幕宽度。// composables/useBreakpoints.ts import { ref, onMounted, onUnmounted } from vue export default function useBreakpoints() { const isMobile ref(window.innerWidth 768) // 例如小于768px视为移动端 const updateIsMobile () { isMobile.value window.innerWidth 768 } onMounted(() { window.addEventListener(resize, updateIsMobile) }) onUnmounted(() { window.removeEventListener(resize, updateIsMobile) }) return { isMobile } }然后修改我们的useCollapse逻辑使其在小屏幕时自动折叠并允许用户手动切换。// composables/useCollapse.ts (增强版) import { ref, onMounted, watch } from vue import useBreakpoints from ./useBreakpoints export default function useCollapse() { const { isMobile } useBreakpoints() const storedState localStorage.getItem(sidebar-collapse) // 初始化时如果是小屏幕强制折叠否则读取存储状态 const initialCollapse isMobile.value ? true : (storedState ? JSON.parse(storedState) : false) const isCollapse ref(initialCollapse) const toggleCollapse () { isCollapse.value !isCollapse.value // 只有非移动端的状态才持久化因为移动端默认就是折叠的 if (!isMobile.value) { localStorage.setItem(sidebar-collapse, JSON.stringify(isCollapse.value)) } } // 监听屏幕尺寸变化 watch(isMobile, (newVal) { if (newVal) { // 切换到小屏幕自动折叠 isCollapse.value true } else { // 切换到大屏幕恢复之前存储的状态 const stored localStorage.getItem(sidebar-collapse) isCollapse.value stored ? JSON.parse(stored) : false } }) return { isCollapse, toggleCollapse, isMobile // 也可以提供出去供其他组件使用 } }5.3 菜单折叠时的用户体验优化当菜单折叠后只剩下图标用户可能不清楚每个图标的含义。Element-Plus 的el-menu在折叠模式下鼠标悬停在菜单上时会显示一个title提示框。但我们可以做得更好。1. 自定义更美观的 Tooltip默认的 Tooltip 样式可能和你的项目主题不搭。我们可以通过全局样式覆盖或者使用el-tooltip组件包裹菜单项来实现更精细的控制但这需要修改递归组件SidebarItem的逻辑较为复杂。一个更简单的方法是修改全局的 ElTooltip 样式。/* 在全局样式文件中例如 index.css */ .el-popper.is-dark { background-color: #2d3a4b !important; border: 1px solid #2d3a4b !important; } .el-popper.is-dark .el-popper__arrow::before { background-color: #2d3a4b !important; border: 1px solid #2d3a4b !important; }2. 折叠状态下隐藏子菜单文本在SidebarItem.vue中我们已经通过span v-if!isCollapse{{ item.meta?.title }}/span实现了在折叠时隐藏父级菜单的文本。对于el-menu-item的标题Element-Plus 会自动处理。3. 折叠动画的平滑性确保.layout-aside的 CSS 过渡属性transition包含了width。如果侧边栏内部有复杂的组件过快的折叠可能会导致内容渲染异常。可以适当增加过渡时间例如transition: width 0.35s ease-in-out;。6. 常见问题排查与实战技巧在实际开发中你可能会遇到以下问题。这里是我总结的排查清单和解决方案。问题现象可能原因解决方案菜单折叠/展开时内容区域抖动或闪动。1. 侧边栏宽度变化时主内容区宽度重新计算导致布局重排。2. 过渡动画属性设置不当。1. 为.layout-main等可能受影响的区域也添加transition或使用flex: 1等弹性布局减少计算。2. 检查transition属性是否只应用于width避免影响padding、margin。折叠后鼠标悬停提示 (Tooltip) 不出现或位置不对。1.el-menu的collapse属性未正确绑定或为false。2. 自定义样式覆盖了 Tooltip 的容器。1. 检查:collapseisCollapse绑定是否正确确保isCollapse是布尔值。2. 检查是否有overflow: hidden样式截断了 Tooltip尝试调整z-index。路由跳转后菜单激活状态不高亮。1.el-menu的default-active绑定值不是完整的路由路径。2. 路由是嵌套路由激活路径匹配逻辑有误。1. 在SideBar.vue中使用useRoute()获取当前路由的fullPath或path作为activeMenu。2. 对于嵌套路由考虑使用路由元信息meta.activeMenu来指定需要高亮的父级菜单路径。菜单折叠时子菜单的弹出层位置异常跑到屏幕外。这是 Element-Plus 组件在折叠模式下的一个已知问题弹出层的计算基准点可能出错。1. 升级 Element-Plus 到最新版本官方可能已修复。2. 临时方案为el-sub-menu添加popper-class自定义弹出层样式强制调整位置。例如.custom-popper { left: 64px !important; }(64px是折叠宽度)。浏览器控制台出现关于provide/inject的警告。在子组件中inject了未在祖先组件中provide的 key。1. 检查provide和inject的 key 字符串是否完全一致大小写敏感。2. 确保提供状态的组件如App.vue确实在调用inject的组件的祖先链上。刷新页面后折叠状态恢复到了默认值没有记住。1.localStorage的读写逻辑有误。2. 状态初始化顺序问题在localStorage读取前就使用了默认值。1. 检查localStorage.getItem和setItem的 key 是否正确值是否为字符串。2. 确保在ref初始化时或组件onMounted生命周期中读取存储的值。参考我们useCollapse的组合函数写法。独家避坑技巧图标动态引入优化在SidebarItem.vue中我们通过component :isonlyOneChild.meta.icon /动态渲染图标。如果图标很多全部全局注册可能导致初始包体积变大。可以考虑按需引入使用defineAsyncComponent进行懒加载但这会稍微增加菜单切换时的延迟。对于后台管理系统图标数量通常可控全局注册是更简单高效的选择。折叠状态与路由守卫如果你的某些页面布局依赖于菜单是否折叠例如一些图表需要根据可用宽度重绘可以在路由守卫中监听状态变化。更优雅的做法是将isCollapse状态注入到 Vue Router 的全局后置守卫中或者在图表组件内部使用watch监听window.innerWidth或isCollapse的变化。性能考量递归组件SidebarItem在菜单项非常多时比如超过100项可能会影响渲染性能。一个优化点是确保每个菜单项的key是唯一且稳定的我们使用了path。此外对于超大型菜单可以考虑使用虚拟滚动但这需要更复杂的实现Element-Plus 的菜单组件原生不支持。通常后台管理系统的菜单不会达到这个量级。测试要点手动测试时务必检查① 折叠/展开动画是否平滑② 折叠后鼠标悬停提示是否准确③ 折叠状态下点击菜单路由跳转是否正常④ 刷新页面后状态是否保持⑤ 在不同屏幕宽度下使用浏览器开发者工具模拟自动折叠和展开逻辑是否生效。实现一个健壮的菜单折叠功能远不止绑定一个collapse属性那么简单。它涉及到状态管理、组件通信、动画协调、持久化、响应式设计等多个方面。通过本文的拆解希望你能不仅掌握实现步骤更能理解每一步背后的设计意图和潜在问题。在实际项目中根据你的具体需求比如是否需要支持手风琴模式、是否需要与标签页联动等可以在这个基础上进行灵活扩展。