公司动态
Vue3+Element-Plus侧边菜单折叠:从状态管理到移动端适配的实战方案
1. 项目概述从“能用”到“好用”的菜单交互进化在后台管理系统的开发中左侧导航菜单的折叠与展开功能看似是一个基础得不能再基础的交互。很多开发者尤其是刚接触Vue3和Element-Plus的朋友可能会觉得这不过就是控制一个el-menu组件的collapse属性再配上一个切换按钮而已。我最初也是这么想的直到在实际项目中我遇到了菜单折叠后图标和文字的对齐问题、折叠状态需要持久化到不同页面、以及折叠动画与路由切换的冲突等一系列“坑”。这才让我意识到一个真正“好用”的折叠功能远不止绑定一个布尔值那么简单。它关乎用户体验的流畅性、状态管理的一致性和代码的可维护性。今天我们就来深入聊聊如何基于Vue3的组合式API和Element-Plus组件库实现一个不仅“能用”而且“好用”、考虑周全的左侧菜单折叠与展开功能。我们将超越官方文档的基础示例深入到状态管理、动画优化、持久化方案以及移动端适配等实战层面。无论你是正在构建一个新的后台管理系统还是希望对现有项目的菜单交互进行优化这篇文章中分享的思路和代码都能为你提供直接的参考和启发。我们将从最核心的状态驱动逻辑开始逐步构建一个健壮、优雅的解决方案。2. 核心设计思路状态驱动与响应式架构在Vue3的响应式世界里实现任何交互功能的第一要义就是厘清状态流。对于菜单折叠这个功能其核心状态非常单一一个布尔值表示当前是展开还是折叠。但是围绕这个核心状态会衍生出一系列关联状态和副作用我们的设计就是要优雅地管理它们。2.1 状态中心Pinia的引入与设计为什么不用简单的ref在组件内管理对于后台管理系统菜单的折叠状态通常是一个全局偏好设置。用户在一个页面折叠了菜单期望在跳转到另一个页面时菜单能保持折叠状态。这就需要我们将这个状态提升到应用级别进行管理。我强烈推荐使用Pinia作为状态管理库。它比Vuex更简洁与Vue3的组合式API结合得更好。我们来创建一个专门的Store来管理布局相关的状态。首先安装Pinia如果尚未安装npm install pinia然后在src/stores目录下创建layout.js// src/stores/layout.js import { defineStore } from pinia import { ref, computed } from vue export const useLayoutStore defineStore(layout, () { // 核心状态菜单是否折叠 const isCollapse ref(false) // 动作切换折叠状态 const toggleCollapse () { isCollapse.value !isCollapse.value } // 动作设置折叠状态 const setCollapse (value) { isCollapse.value value } // 计算属性折叠状态下菜单的宽度用于其他组件计算 const menuWidth computed(() { return isCollapse.value ? 64px : 200px }) // 计算属性主内容区域的左侧边距 const contentMarginLeft computed(() { return isCollapse.value ? 64px : 200px }) return { isCollapse, toggleCollapse, setCollapse, menuWidth, contentMarginLeft } })这个Store的设计有几个关键点单一数据源isCollapse是唯一真理源所有关于菜单宽度的计算都派生自它。明确的变更方式提供了toggleCollapse和setCollapse两个函数来修改状态而不是直接暴露ref的.value进行修改这更符合状态管理的规范也便于未来添加额外的逻辑比如持久化。派生状态menuWidth和contentMarginLeft是计算属性。这意味着当isCollapse变化时所有依赖这些计算属性的地方都会自动更新我们无需手动同步宽度值。2.2 组件结构规划关注点分离一个清晰的组件结构能让代码更易维护。我建议将功能拆分为三个主要组件Layout.vue(布局容器)负责整体的页面框架包含左侧菜单区域和右侧内容区域。它从Pinia Store中读取isCollapse和menuWidth并应用到布局样式上。SidebarMenu.vue(侧边栏菜单)纯粹的展示组件接收一个collapse的prop并负责渲染el-menu。它不关心状态如何变化只负责根据给定的状态进行渲染。Navbar.vue(顶部导航栏)通常包含折叠按钮、用户信息等。它需要调用Store中的toggleCollapse动作来触发状态变更。这种结构遵循了“智能组件”与“木偶组件”的分离原则。Layout和Navbar是智能组件它们知晓状态并执行业务逻辑SidebarMenu是木偶组件只负责接收props并渲染UI这使得SidebarMenu非常纯粹易于测试和复用。实操心得在项目初期就采用这种分离结构能为后续添加诸如“主题色切换”、“菜单搜索”等复杂功能预留出清晰的扩展空间。如果所有逻辑都堆在一个组件里后期维护会非常痛苦。3. 核心细节解析与实操要点有了清晰的设计思路我们开始动手实现。这里面的每一个细节都直接影响到最终用户的使用体验。3.1 布局容器Layout.vue的实现Layout.vue是整个页面的骨架。我们需要使用CSS Flexbox或Grid来创建左右结构并根据Store中的状态动态调整样式。!-- src/layout/Layout.vue -- template div classapp-wrapper !-- 左侧侧边栏 -- div classsidebar-container :style{ width: layoutStore.menuWidth } SidebarMenu :collapselayoutStore.isCollapse / /div !-- 右侧主区域 -- div classmain-container !-- 顶部导航栏 -- Navbar / !-- 页面内容区域 -- div classapp-main router-view / /div /div /div /template script setup import { useLayoutStore } from /stores/layout import SidebarMenu from ./SidebarMenu.vue import Navbar from ./Navbar.vue const layoutStore useLayoutStore() /script style scoped .app-wrapper { display: flex; height: 100vh; width: 100%; overflow: hidden; } .sidebar-container { transition: width 0.3s ease-in-out; background-color: #304156; overflow: hidden; flex-shrink: 0; /* 防止侧边栏被压缩 */ } .main-container { flex: 1; display: flex; flex-direction: column; min-width: 0; /* 解决flex item内容溢出问题 */ } .app-main { flex: 1; padding: 20px; overflow-y: auto; background-color: #f0f2f5; transition: margin-left 0.3s ease-in-out; } /style关键点解析:style绑定侧边栏的宽度直接绑定到Store的menuWidth计算属性。这是一个响应式绑定当isCollapse变化时宽度会自动更新。CSS过渡transition在.sidebar-container上添加了transition: width 0.3s ease-in-out;。这为宽度的变化添加了平滑的动画效果是提升用户体验的关键。动画时长0.3s是一个经过验证的舒适值太快会显得突兀太慢则拖沓。flex-shrink: 0这个属性确保侧边栏在父容器空间不足时不会被挤压变形。min-width: 0在.main-container上设置这个属性是为了解决一个常见的Flexbox布局问题当内容过长时会撑破容器。设置min-width: 0可以允许flex item缩小到低于其内容的最小尺寸。3.2 侧边栏菜单SidebarMenu.vue的深度配置这是与Element-Plus的el-menu组件直接交互的地方。除了基础的collapse属性还有很多细节需要处理。!-- src/layout/components/SidebarMenu.vue -- template el-menu :default-activeactiveMenu :collapsecollapse :unique-openedtrue :collapse-transitionfalse background-color#304156 text-color#bfcbd9 active-text-color#409eff classsidebar-menu selecthandleSelect sidebar-item v-forroute in permissionRoutes :keyroute.path :itemroute :base-pathroute.path :is-collapsecollapse / /el-menu /template script setup import { computed } from vue import { useRoute, useRouter } from vue-router import { usePermissionStore } from /stores/permission import SidebarItem from ./SidebarItem.vue const props defineProps({ collapse: { type: Boolean, required: true } }) const route useRoute() const router useRouter() const permissionStore usePermissionStore() // 根据当前路由计算高亮菜单 const activeMenu computed(() { const { meta, path } route if (meta.activeMenu) { return meta.activeMenu // 支持在路由meta中指定高亮菜单 } return path }) // 获取有权限的路由列表通常从Store中获取 const permissionRoutes computed(() { return permissionStore.routes }) // 菜单选择事件 const handleSelect (index) { router.push(index) } /script style scoped .sidebar-menu { border-right: none; height: 100%; } /* 调整折叠状态下菜单项的样式 */ .sidebar-menu:not(.el-menu--collapse) { width: 200px; } /style关键属性详解:collapsecollapse核心属性控制菜单的折叠状态。:unique-openedtrue确保每次只展开一个一级菜单。这对于保持侧边栏的整洁非常重要尤其是在菜单项较多时。:collapse-transitionfalse这是一个重要的性能优化点。Element-Plus的菜单组件在折叠/展开时默认有一个子菜单的展开动画。在菜单结构复杂或项数很多时这个动画可能会导致明显的卡顿。关闭它设为false能获得更即时的响应。我们已经在Layout容器级别为宽度变化添加了动画这里的子菜单动画可以省去。background-color,text-color这些颜色需要与你的整体主题色相匹配。示例中的颜色是Element-Plus后台风格的典型深色系。关于SidebarItem组件这是一个递归组件用于渲染无限层级的菜单树。它需要处理图标显示、子菜单渲染、以及折叠状态下只显示图标等逻辑。由于篇幅所限其具体实现会在下一节详细展开。注意事项activeMenu的计算逻辑需要仔细处理。在具有“详情页”等场景时你可能希望点击“用户列表”进入详情页后侧边栏的“用户管理”菜单依然保持高亮。这通常需要在路由的meta对象中设置activeMenu属性来指定。3.3 递归菜单项组件SidebarItem.vue的实现这是实现动态多级菜单的核心。它需要判断一个路由项是普通菜单、可展开的父菜单还是一个外部链接。!-- src/layout/components/SidebarItem.vue -- template !-- 没有子路由或者只有一个需要隐藏的子路由比如详情页 -- template v-ifhasOneShowingChild(item.children, item) (!onlyOneChild.children || onlyOneChild.noShowingChildren) el-menu-item :indexresolvePath(onlyOneChild.path) :class{ submenu-title-noDropdown: !isCollapse } svg-icon v-ifonlyOneChild.meta.icon :icon-classonlyOneChild.meta.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 svg-icon v-ifitem.meta.icon :icon-classitem.meta.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 import { computed } from vue import path from path const props defineProps({ item: { type: Object, required: true }, basePath: { type: String, default: }, isCollapse: { type: Boolean, default: false } }) // 判断是否只有一个需要显示的子路由 const hasOneShowingChild (children [], parent) { if (!children) { children [] } const showingChildren children.filter(item { // 根据meta中的hidden属性决定是否显示 if (item.meta item.meta.hidden) { return false } return true }) // 如果只有一个子菜单这个子菜单将直接代替父级显示 if (showingChildren.length 1) { Object.assign(parent, { onlyOneChild: showingChildren[0] }) return true } // 如果没有子菜单需要显示则父级本身作为一个菜单项显示 if (showingChildren.length 0) { Object.assign(parent, { onlyOneChild: { ...parent, path: , noShowingChildren: true } }) return true } return false } const onlyOneChild computed(() { return props.item.onlyOneChild || {} }) // 解析完整路径处理嵌套路由 const resolvePath (routePath) { if (isExternal(routePath)) { return routePath } return path.resolve(props.basePath, routePath) } // 判断是否为外部链接 const isExternal (path) { return /^(https?:|mailto:|tel:)/.test(path) } /script style scoped /* 当菜单折叠时隐藏el-sub-menu的标题文字 */ .el-menu--collapse .el-sub-menu__title span { display: none; } /* 当菜单折叠时隐藏el-sub-menu的图标箭头 */ .el-menu--collapse .el-sub-menu__title .el-sub-menu__icon-arrow { display: none; } /style核心逻辑解析hasOneShowingChild函数这是递归渲染的关键。它判断一个路由项是否应该被渲染为单独的el-menu-item。有两种情况情况一该路由只有一个需要显示的子路由例如“用户管理”下面只有一个“用户列表”而“新增用户”可能被标记为hidden: true。此时直接渲染这个唯一的子路由作为菜单项提升用户体验。情况二该路由没有任何需要显示的子路由。此时将路由自身渲染为一个菜单项。其他情况有多个子路由需要显示则渲染为el-sub-menu。路径解析使用Node.js的path.resolve方法需安装path-browserify或在构建工具中配置polyfill来正确处理嵌套路由的路径拼接确保路由跳转准确。折叠状态下的样式处理通过CSS选择器.el-menu--collapse我们可以在菜单折叠时隐藏子菜单标题的文字和展开箭头只保留图标这使得折叠状态下的UI非常紧凑和美观。踩过的坑hasOneShowingChild函数中对parent对象的修改Object.assign是一种副作用但在Vue的渲染逻辑中这通常发生在计算阶段且parent即itemprop是来自上层组件的响应式对象这种模式在Element-Plus的官方示例中也有使用。关键在于理解它是在渲染前确定组件结构而不是在渲染过程中修改状态。4. 状态持久化与高级交互优化一个功能做到基本可用并不难难的是考虑边界情况和长期体验。下面我们来解决状态持久化和一些高级交互问题。4.1 折叠状态的持久化方案用户折叠了菜单刷新页面后应该保持折叠状态。我们可以利用浏览器的localStorage或sessionStorage来实现。我们在Pinia Store中增加持久化逻辑// src/stores/layout.js (更新部分) import { defineStore } from pinia import { ref, computed, onMounted } from vue export const useLayoutStore defineStore(layout, () { const isCollapse ref(false) // 初始化时从本地存储读取 const initFromStorage () { const saved localStorage.getItem(app-sidebar-collapse) if (saved ! null) { isCollapse.value JSON.parse(saved) } } // 状态变化时保存到本地存储 const saveToStorage () { localStorage.setItem(app-sidebar-collapse, JSON.stringify(isCollapse.value)) } // 切换动作现在包含持久化 const toggleCollapse () { isCollapse.value !isCollapse.value saveToStorage() } const setCollapse (value) { isCollapse.value value saveToStorage() } // 在Store被挂载时初始化 onMounted(() { initFromStorage() }) // ... 其余计算属性保持不变 })方案选择localStorage持久化到本地即使关闭浏览器再打开状态依然保留。适合用户个人偏好。sessionStorage仅在当前浏览器标签页有效关闭标签页后状态丢失。适合临时性状态。考虑服务端对于多端同步需求可以将此偏好设置保存到用户配置表中通过API进行同步。初始化时先调用API获取变更时提交到后端。实操心得直接监听isCollapse的变化来自动保存使用watch也是一种方式但将其封装在动作toggleCollapse,setCollapse中更显式也更容易控制例如可以添加防抖或只在特定条件下保存。4.2 响应式布局适配移动端在移动设备上侧边栏通常以抽屉Drawer的形式覆盖在内容上方而不是并排显示。我们可以通过CSS媒体查询和一点点JavaScript逻辑来实现。首先更新Layout.vue!-- src/layout/Layout.vue (更新部分) -- template div classapp-wrapper :class{ hide-sidebar: !sidebar.opened } !-- 移动端遮罩层 -- div v-ifdevice mobile sidebar.opened classdrawer-bg clickhandleClickOutside / !-- 左侧侧边栏 - 在移动端变为固定定位的抽屉 -- div classsidebar-container :style{ width: sidebar.menuWidth, transform: translateX(${sidebar.opened ? 0 : -${sidebar.menuWidth}}) } SidebarMenu :collapsefalse / !-- 移动端不显示折叠图标菜单始终展开 -- /div !-- 右侧主区域 -- div classmain-container :style{ marginLeft: device mobile ? 0 : sidebar.contentMarginLeft } Navbar / div classapp-main router-view / /div /div /div /template script setup import { useLayoutStore } from /stores/layout import { useAppStore } from /stores/app // 新增一个管理应用全局状态的Store import SidebarMenu from ./SidebarMenu.vue import Navbar from ./Navbar.vue import { computed, onMounted, onUnmounted } from vue const layoutStore useLayoutStore() const appStore useAppStore() // 从appStore中获取设备和侧边栏状态 const device computed(() appStore.device) const sidebar computed(() appStore.sidebar) // 点击遮罩层关闭侧边栏移动端 const handleClickOutside () { appStore.closeSidebar() } // 响应式监听窗口大小变化 const handleResize () { const width document.body.getBoundingClientRect().width if (width 768) { appStore.toggleDevice(mobile) appStore.closeSidebar() // 切换到移动端时默认关闭侧边栏 } else { appStore.toggleDevice(desktop) appStore.openSidebar() // 切换到桌面端时默认打开侧边栏 } } onMounted(() { window.addEventListener(resize, handleResize) // 初始化时执行一次 handleResize() }) onUnmounted(() { window.removeEventListener(resize, handleResize) }) /script style scoped .app-wrapper { /* ... 原有样式 */ position: relative; } .sidebar-container { /* ... 原有样式 */ transition: transform 0.3s ease-in-out, width 0.3s ease-in-out; /* 增加transform过渡 */ } /* 移动端样式 */ media (max-width: 768px) { .sidebar-container { position: fixed; top: 0; bottom: 0; z-index: 1001; transform: translateX(-100%); /* 默认隐藏在左侧 */ } .main-container { margin-left: 0 !important; } .drawer-bg { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0, 0, 0, 0.3); z-index: 1000; } } /style同时需要创建或更新appStore来管理全局UI状态// src/stores/app.js import { defineStore } from pinia import { ref } from vue export const useAppStore defineStore(app, () { const device ref(desktop) // desktop or mobile const sidebar ref({ opened: true, // 侧边栏是否打开移动端专用 }) const toggleDevice (val) { device.value val } const toggleSidebar () { sidebar.value.opened !sidebar.value.opened } const closeSidebar () { sidebar.value.opened false } const openSidebar () { sidebar.value.opened true } return { device, sidebar, toggleDevice, toggleSidebar, closeSidebar, openSidebar } })实现要点状态分离将“设备类型”device和“移动端侧边栏开关状态”sidebar.opened从原有的折叠状态中分离出来。折叠isCollapse是桌面端的交互而开关sidebar.opened是移动端的交互。CSS媒体查询使用media (max-width: 768px)来定义移动端的样式将侧边栏改为固定定位position: fixed。Transform动画在移动端我们不再动画化width而是动画化transform: translateX。因为transform属性的动画性能远优于width能实现更流畅的抽屉效果。事件监听通过监听resize事件在窗口大小变化时动态切换设备模式并调整侧边栏的默认状态。4.3 导航栏Navbar.vue与折叠按钮的完善最后我们来完善触发折叠动作的按钮。这个按钮通常放在顶部导航栏。!-- src/layout/components/Navbar.vue -- template div classnavbar div classleft-menu !-- 汉堡包图标按钮 -- div classhamburger-container clicktoggleSidebar svg-icon icon-classhamburger :class{ is-active: isSidebarOpened } / /div !-- 面包屑导航等 -- /div div classright-menu !-- 用户头像、消息等 -- /div /div /template script setup import { computed } from vue import { useLayoutStore } from /stores/layout import { useAppStore } from /stores/app import SvgIcon from /components/SvgIcon/index.vue const layoutStore useLayoutStore() const appStore useAppStore() const device computed(() appStore.device) const isSidebarOpened computed(() { // 桌面端侧边栏是否折叠的反状态。移动端侧边栏是否打开。 if (device.value desktop) { return !layoutStore.isCollapse } else { return appStore.sidebar.opened } }) const toggleSidebar () { if (device.value desktop) { layoutStore.toggleCollapse() } else { appStore.toggleSidebar() } } /script style scoped .navbar { height: 50px; background: #fff; box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08); display: flex; justify-content: space-between; align-items: center; padding: 0 20px; } .hamburger-container { padding: 0 15px; cursor: pointer; line-height: 46px; } .hamburger-container .svg-icon { font-size: 20px; transition: transform 0.3s; } .hamburger-container .is-active { transform: rotate(180deg); } /style逻辑解析一个按钮两种逻辑汉堡包图标按钮的点击事件toggleSidebar需要根据当前设备类型device来分发不同的行为。在桌面端desktop调用layoutStore.toggleCollapse()来切换折叠状态。在移动端mobile调用appStore.toggleSidebar()来打开或关闭抽屉式侧边栏。图标状态通过isSidebarOpened计算属性来控制图标的旋转状态.is-active类给用户清晰的视觉反馈。5. 常见问题与排查技巧实录在实际开发中你可能会遇到以下问题。这里记录了我踩过的坑和解决方案。5.1 菜单折叠后图标与文字对齐问题问题描述当菜单折叠时el-menu-item内的图标和el-sub-menu的标题图标可能因为text-align或flex布局导致位置偏移看起来不居中。解决方案通过自定义CSS覆盖Element-Plus的默认样式。/* 在全局或组件内样式表中添加 */ .el-menu--vertical .el-menu-item, .el-menu--vertical .el-sub-menu__title { text-align: center !important; justify-content: center !important; } .el-menu--collapse .el-menu-item .svg-icon, .el-menu--collapse .el-sub-menu__title .svg-icon { margin-right: 0 !important; }关键是将文本居中对齐并移除折叠状态下图标右边的外边距。5.2 路由切换时菜单高亮丢失或错误问题描述点击菜单跳转路由后高亮状态没有正确更新到新页面对应的菜单项。排查步骤检查default-active绑定确保el-menu的:default-active绑定的是一个响应式值如activeMenu计算属性。检查路由path匹配el-menu的index属性即菜单项的key需要与default-active的值完全匹配。确保resolvePath函数能正确生成嵌套路由的完整路径。检查路由元信息对于非一一对应的路由如详情页需要在路由配置的meta中添加activeMenu字段指向应该高亮的父级菜单路径。// 在路由配置中 { path: /user/detail/:id, component: () import(/views/user/Detail.vue), meta: { title: 用户详情, activeMenu: /user/list // 指定高亮菜单为“用户列表” } }5.3 折叠动画卡顿或闪烁问题描述在菜单项非常多或浏览器性能较低时折叠/展开的动画不流畅。优化方案关闭子菜单过渡如前所述设置el-menu的:collapse-transitionfalse。使用transform替代width动画对于性能要求极高的场景可以考虑更复杂的方案不改变侧边栏的实际width而是用transform: scaleX()或translateX来模拟折叠效果但这需要调整内部所有元素的布局逻辑成本较高。对于大多数后台管理系统关闭collapse-transition已足够。减少菜单项从根本上优化对菜单进行分级、收纳或搜索过滤。5.4 浏览器窗口大小变化导致布局错乱问题描述从桌面端窗口拖拽到移动端大小时侧边栏状态没有及时更新。解决方案确保在Layout.vue的onMounted和onUnmounted中正确添加和移除resize事件监听器并且handleResize函数能准确判断设备类型并更新Store状态。注意判断阈值如768px需要与CSS媒体查询的断点保持一致。5.5 Pinia Store在组件外初始化的时机问题问题描述在layout.js的Store中使用onMounted读取localStorage但某些组件在Store初始化完成前就访问了状态导致读取到默认值。解决方案将初始化逻辑放在应用的根组件或路由守卫中执行确保在渲染依赖此状态的组件之前完成。或者使用store.$subscribe监听状态变化并持久化而不是在动作内保存。// 另一种持久化方案在Store定义外订阅 const layoutStore useLayoutStore() layoutStore.$subscribe((mutation, state) { localStorage.setItem(app-sidebar-collapse, JSON.stringify(state.isCollapse)) }) // 然后在应用的入口文件如main.js或根组件中执行一次初始化读取。我个人在实际项目中更倾向于将持久化逻辑封装在Store动作内部因为这样逻辑更内聚也更容易进行单元测试。只要确保Store在应用初始化早期被创建通常不会有问题。实现一个精致的菜单折叠功能就像打磨一件家具的榫卯。每一个细节的考量——从状态管理的设计、递归组件的渲染到持久化、响应式以及动画性能——都决定了最终产品是“粗糙能用”还是“精致好用”。这个过程让我深刻体会到前端开发不仅是实现需求更是在无数个细节中寻找最佳用户体验的平衡点。希望这篇长文能帮你避开我当年踩过的那些坑更顺畅地构建出体验出色的管理系统界面。