
1. 项目背景与核心价值最近在重构一个后台管理系统从Vue2 Element UI 迁移到 Vue3 Element-Plus。迁移过程中一个看似基础但体验至关重要的功能——左侧菜单的折叠与展开让我重新审视了一遍。很多新手甚至一些有经验的开发者在实现这个功能时往往只停留在“点击按钮菜单宽度变化”的层面却忽略了动画流畅性、状态持久化、路由激活匹配、以及移动端适配等一系列影响用户体验的细节。网上很多教程也止步于基础实现导致大家做出来的菜单交互生硬状态混乱。这次我就结合Vue3的组合式API和Element-Plus的最新组件来完整地拆解一个生产级可用的左侧菜单折叠展开功能。这不仅仅是“二七”可以理解为第二十七次迭代或一个版本代号更是一次对细节的深度打磨。我们将覆盖从基础布局、状态管理、平滑动画到持久化、响应式以及那些容易踩坑的边界情况处理。无论你是刚刚接触Vue3还是正在寻找一个更优雅的菜单解决方案相信这篇内容都能给你带来直接的参考价值。2. 技术栈选型与项目初始化考量在开始动手之前明确我们的技术栈和项目起点至关重要。标题已经指明了是 Vue3 和 Element-Plus这几乎是当前Vue中后台项目的标准搭配。但为什么是它们以及初始化时要注意什么这里有几个关键点。2.1 为什么是Vue3 Element-Plus首先Vue3 带来的组合式 API (Composition API)是核心优势。对于菜单组件这种自身带有复杂内部状态折叠状态、激活路径、打开的子菜单等的模块使用setup语法和ref、computed、watch等函数来组织逻辑比 Vue2 的 Options API 更加清晰和灵活。状态和逻辑可以按功能聚合而不是分散在data、methods、watch等选项中。其次Element-Plus 是对 Element UI 的 Vue3 版本升级。它提供了el-menu组件原生支持垂直模式、手风琴模式、路由集成等是我们实现菜单功能的基础。选择它意味着我们不需要从零开始编写菜单的样式和基础交互可以专注于业务逻辑和体验增强。2.2 项目创建与依赖安装假设你已经有一个基于 Vite 创建的 Vue3 项目这是目前最推荐的方式。如果没有可以通过以下命令快速创建一个npm create vuelatest my-admin-project # 按照提示选择需要的特性通常需要加入 TypeScript 和 Vue Router。然后进入项目目录安装 Element-Plus 和图标库菜单常需要图标cd my-admin-project npm install element-plus element-plus/icons-vue接下来是引入 Element-Plus。对于后台管理系统我推荐使用完整引入虽然体积稍大但省去了按需引入的配置麻烦开发体验更流畅。在main.ts或main.js中import { createApp } from vue import App from ./App.vue import router from ./router import ElementPlus from element-plus import element-plus/dist/index.css import * as ElementPlusIconsVue from element-plus/icons-vue const app createApp(App) // 注册所有图标组件 for (const [key, component] of Object.entries(ElementPlusIconsVue)) { app.component(key, component) } app.use(router) app.use(ElementPlus) app.mount(#app)注意图标注册这一步很重要。Element-Plus 将图标作为独立的组件提供我们需要全局注册后才能直接在模板中使用el-icon。至此基础环境就准备好了。我们的工作将主要集中在一个布局组件如Layout.vue和菜单组件如SidebarMenu.vue中。3. 基础布局构建与菜单组件集成一个典型的后台管理布局包含顶部导航栏、左侧菜单栏、主内容区。这里我们使用 Element-Plus 的el-container系列组件来快速搭建骨架。3.1 使用 ElContainer 构建页面骨架在Layout.vue中我们先搭建一个基础的响应式布局容器。!-- Layout.vue -- template el-container classlayout-container !-- 左侧侧边栏其宽度将受折叠状态控制 -- el-aside :widthasideWidth classlayout-aside SidebarMenu :is-collapseisCollapse / /el-aside el-container !-- 顶部Header放置折叠按钮和用户信息等 -- el-header classlayout-header div classheader-left !-- 折叠/展开触发按钮 -- el-button :iconisCollapse ? Expand : Fold circle plain clicktoggleCollapse / span classsystem-title后台管理系统/span /div div classheader-right.../div /el-header !-- 主内容区使用 el-main 确保内边距和滚动 -- el-main classlayout-main router-view v-slot{ Component } transition namefade-transform modeout-in component :isComponent / /transition /router-view /el-main /el-container /el-container /template script setup langts import { ref, computed } from vue import { Fold, Expand } from element-plus/icons-vue import SidebarMenu from ./SidebarMenu.vue // 控制折叠状态的核心响应式变量 const isCollapse ref(false) // 根据折叠状态动态计算侧边栏宽度 const asideWidth computed(() (isCollapse.value ? 64px : 200px)) // 切换折叠状态的函数 const toggleCollapse () { isCollapse.value !isCollapse.value } /script style scoped langscss .layout-container { height: 100vh; .layout-aside { background-color: #304156; transition: width 0.3s ease-in-out; // 侧边栏宽度过渡动画 overflow: hidden; // 防止菜单内容在收缩时溢出 } .layout-header { display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid #e6e6e6; background-color: #fff; .header-left { display: flex; align-items: center; gap: 16px; } } .layout-main { background-color: #f0f2f5; padding: 20px; } } /style这里有几个关键设计点状态驱动isCollapse是一个布尔类型的ref它是整个菜单折叠状态的核心。它同时控制着侧边栏宽度 (asideWidth) 和按钮图标。计算属性asideWidth是一个计算属性它根据isCollapse的值返回64px折叠或200px展开。这样就将状态与样式解耦了。CSS过渡在.layout-aside的样式中我们为width属性添加了transition: width 0.3s ease-in-out;。这是实现平滑折叠动画的关键。当asideWidth变化时浏览器会自动应用这个过渡效果。3.2 实现 SidebarMenu 菜单组件现在我们来创建SidebarMenu.vue组件它接收isCollapse属性并渲染实际的导航菜单。!-- SidebarMenu.vue -- template el-menu :default-activeactiveMenu :collapseisCollapse :collapse-transitionfalse background-color#304156 text-color#bfcbd9 active-text-color#409eff unique-opened router classsidebar-menu menu-item v-forroute in menuRoutes :keyroute.path :itemroute / /el-menu /template script setup langts import { computed } from vue import { useRoute } from vue-router import MenuItem from ./MenuItem.vue // 定义组件接收的属性 interface Props { isCollapse: boolean } definePropsProps() const route useRoute() // 计算当前激活的菜单项用于高亮 const activeMenu computed(() route.path) // 模拟从后端或路由配置中获取的菜单数据 const menuRoutes [ { path: /dashboard, meta: { title: 仪表盘, icon: Odometer }, }, { path: /user, meta: { title: 用户管理, icon: User }, children: [ { path: /user/list, meta: { title: 用户列表 } }, { path: /user/role, meta: { title: 角色管理 } }, ], }, { path: /system, meta: { title: 系统管理, icon: Setting }, children: [ { path: /system/menu, meta: { title: 菜单管理 } }, { path: /system/log, meta: { title: 操作日志 } }, ], }, ] /script style scoped langscss .sidebar-menu { border-right: none; // 去除默认边框 height: 100%; // 当菜单折叠时让文字隐藏得更优雅而不是突然消失 :deep(.el-menu-item), :deep(.el-sub-menu__title) { span { transition: opacity 0.2s; opacity: 1; } } // 折叠状态下隐藏菜单项文字 :deep(.el-menu--collapse) { .el-menu-item, .el-sub-menu__title { span { opacity: 0; width: 0; overflow: hidden; } } } } /style核心属性解析:collapseisCollapse这是 Element-Plusel-menu接收折叠状态的核心属性。设置为true时菜单会收起为仅图标模式。:collapse-transitionfalse禁用 Element-Plus 自带的折叠动画。因为我们已经在容器层面 (el-aside) 控制了宽度过渡禁用内置动画可以避免两者冲突使效果更平滑。router启用此属性后将index每个菜单项的path作为路由路径进行导航。这是实现点击菜单跳转页面的关键。unique-opened是否只保持一个子菜单展开手风琴模式。在后台管理中通常开启避免页面过于杂乱。:default-activeactiveMenu设置当前激活菜单的高亮。我们通过useRoute()获取当前路由路径并用计算属性activeMenu动态绑定确保页面刷新或通过URL进入时菜单高亮状态正确。3.3 实现递归菜单项组件 MenuItem为了处理嵌套的多级菜单我们需要一个递归组件MenuItem.vue。!-- MenuItem.vue -- template !-- 如果没有子路由渲染 el-menu-item -- el-menu-item v-if!hasChildren :indexitem.path el-icon v-ifitem.meta?.icon component :isitem.meta.icon / /el-icon template #title{{ item.meta?.title }}/template /el-menu-item !-- 如果有子路由渲染 el-sub-menu -- el-sub-menu v-else :indexitem.path template #title el-icon v-ifitem.meta?.icon component :isitem.meta.icon / /el-icon span{{ item.meta?.title }}/span /template !-- 递归调用自身渲染子菜单 -- menu-item v-forchild in item.children :keychild.path :itemchild / /el-sub-menu /template script setup langts import type { RouteRecordRaw } from vue-router interface MenuItem { path: string meta?: { title: string icon?: string } children?: MenuItem[] } interface Props { item: MenuItem } definePropsProps() // 判断当前菜单项是否有子项用于路由导航 const hasChildren (item: MenuItem) { // 这里有一个关键判断如果子项只有一个且该子项的 path 等于父项的 path // 通常我们判断是否有需要展示的子菜单项。 // 一种常见情况父路由本身只是一个布局容器不用于导航其 redirect 到了第一个子路由。 // 在我们的简单数据结构里直接判断 children 是否存在且长度大于0。 return item.children item.children.length 0 } /script这个递归组件是菜单渲染的核心。它根据传入的item数据判断是否有children从而决定渲染为叶子节点 (el-menu-item) 还是父节点 (el-sub-menu)。对于父节点在其插槽内递归调用自身直至渲染完所有层级。至此一个基础的、带有折叠展开功能的菜单就完成了。点击顶部按钮可以看到侧边栏平滑地收起和展开。但这仅仅是开始接下来我们要解决一系列实际开发中会遇到的“坑”。4. 状态持久化让折叠状态记住用户的选择想象一下用户习惯折叠菜单以获得更大的工作区当他刷新页面或重新打开浏览器时菜单又恢复了展开状态体验非常割裂。因此我们需要将isCollapse状态持久化到本地存储LocalStorage中。我们将在Layout.vue中实现这个功能。这里介绍两种方式基础方式和组合式函数封装。4.1 基础实现直接使用 localStorage!-- 在 Layout.vue 的 script setup 部分修改 -- script setup langts import { ref, computed, onMounted } from vue import { Fold, Expand } from element-plus/icons-vue import SidebarMenu from ./SidebarMenu.vue // 从 localStorage 读取初始状态如果没有则默认为 false (展开) const getDefaultCollapse (): boolean { const saved localStorage.getItem(app-sidebar-collapse) return saved ? JSON.parse(saved) : false } const isCollapse ref(getDefaultCollapse()) // 切换状态时同步保存到 localStorage const toggleCollapse () { isCollapse.value !isCollapse.value localStorage.setItem(app-sidebar-collapse, JSON.stringify(isCollapse.value)) } const asideWidth computed(() (isCollapse.value ? 64px : 200px)) // 可选在组件挂载时也可以根据浏览器宽度初始化状态响应式设计 onMounted(() { // 例如在小屏幕设备上默认折叠 // if (window.innerWidth 768) { // isCollapse.value true // localStorage.setItem(app-sidebar-collapse, true) // } }) /script这种方式简单直接但状态管理逻辑和组件逻辑耦合在一起。如果其他组件也需要使用持久化状态代码就会重复。4.2 进阶实现封装 useLocalStorage 组合式函数更好的做法是封装一个可复用的组合式函数useLocalStorage。// composables/useLocalStorage.ts import { ref, watch } from vue export function useLocalStorageT(key: string, defaultValue: T) { // 创建响应式数据初始值从 localStorage 读取 const data refT(() { const item localStorage.getItem(key) try { return item ? JSON.parse(item) : defaultValue } catch { return defaultValue } }) // 监听 data 变化自动同步到 localStorage watch( data, (newVal) { localStorage.setItem(key, JSON.stringify(newVal)) }, { deep: true } // 如果 T 是对象需要深度监听 ) return data }然后在Layout.vue中使用它script setup langts import { ref, computed } from vue import { Fold, Expand } from element-plus/icons-vue import SidebarMenu from ./SidebarMenu.vue import { useLocalStorage } from /composables/useLocalStorage // 使用组合式函数代码非常简洁 const isCollapse useLocalStorageboolean(app-sidebar-collapse, false) const toggleCollapse () { isCollapse.value !isCollapse.value // 注意状态保存已由 useLocalStorage 内部的 watch 自动完成 } const asideWidth computed(() (isCollapse.value ? 64px : 200px)) /script这种方式将状态持久化的逻辑抽象出来使得组件代码更加清晰也易于测试和复用。useLocalStorage返回的也是一个ref你可以像操作普通响应式数据一样操作它所有变更都会自动保存。实操心得对于简单的布尔值或字符串基础方式够用。但对于稍复杂的项目强烈建议采用组合式函数封装。这不仅是为了代码复用更是为了践行 Vue3 组合式 API 的设计思想——将相关的逻辑关注点组合在一起。此外考虑到localStorage是同步操作且可能抛出异常如用户禁用在生产环境中最好将其包裹在try...catch中上述封装已简单处理。5. 响应式设计与移动端适配一个现代化的后台管理系统必须在不同屏幕尺寸下都有良好的表现。我们的菜单在桌面端可以自由折叠展开但在移动端小屏幕下通常需要自动折叠并且可能以抽屉Drawer的形式出现。5.1 基于 CSS Media Query 的初步适配首先我们可以通过 CSS 媒体查询在小屏幕下强制修改一些样式。// 在 Layout.vue 的 style 部分添加 media screen and (max-width: 768px) { .layout-container { .layout-aside { // 在移动端侧边栏通常以抽屉形式覆盖在内容上而不是并排 // 我们先将其隐藏通过一个按钮触发显示 position: fixed !important; left: 0; top: 0; z-index: 2001; height: 100vh; // 初始状态是隐藏的移出屏幕 transform: translateX(-100%); // 展开状态 .is-mobile-open { transform: translateX(0); } // 移除宽度过渡改用 transform 过渡性能更好 transition: transform 0.3s ease-in-out; width: 200px !important; // 移动端抽屉有固定宽度 } // 当侧边栏打开时为主内容区添加一个遮罩层 .layout-mask { position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background-color: rgba(0, 0, 0, 0.5); z-index: 2000; } } }这段 CSS 做了几件事在屏幕宽度小于 768px 时将侧边栏改为固定定位position: fixed使其脱离文档流。默认使用transform: translateX(-100%)将其隐藏在屏幕左侧。定义一个is-mobile-open类当此类被添加时侧边栏滑入 (transform: translateX(0))。将过渡效果从width改为transform因为transform的动画性能通常更好。固定了移动端侧边栏的宽度为200px。5.2 使用 Vue 响应式状态管理移动端逻辑CSS 只处理了样式我们还需要用 Vue 来管理移动端的打开/关闭状态。修改Layout.vuetemplate el-container classlayout-container !-- 移动端遮罩层 -- div v-ifisMobile mobileSidebarOpen classlayout-mask clickcloseMobileSidebar /div !-- 侧边栏添加移动端状态类 -- el-aside :widthasideWidth :class[layout-aside, { is-mobile-open: mobileSidebarOpen }] SidebarMenu :is-collapseisCollapse / /el-aside el-container el-header classlayout-header div classheader-left !-- 移动端下按钮功能变为打开/关闭抽屉 -- el-button v-if!isMobile :iconisCollapse ? Expand : Fold circle plain clicktoggleCollapse / el-button v-else iconMenu circle plain clickopenMobileSidebar / span classsystem-title后台管理系统/span /div /el-header el-main classlayout-main router-view v-slot{ Component } transition namefade-transform modeout-in component :isComponent / /transition /router-view /el-main /el-container /el-container /template script setup langts import { ref, computed, onMounted, onUnmounted } from vue import { Fold, Expand, Menu } from element-plus/icons-vue import SidebarMenu from ./SidebarMenu.vue import { useLocalStorage } from /composables/useLocalStorage const isCollapse useLocalStorageboolean(app-sidebar-collapse, false) // 响应式判断是否为移动端 const isMobile ref(false) // 控制移动端侧边栏抽屉的开关 const mobileSidebarOpen ref(false) // 检查屏幕宽度并更新 isMobile 状态 const checkIsMobile () { isMobile.value window.innerWidth 768 // 如果是移动端且侧边栏是展开状态则自动折叠并关闭抽屉 if (isMobile.value) { isCollapse.value false // 移动端下菜单内部不应用折叠样式因为整个抽屉都是展开的 mobileSidebarOpen.value false } } // 切换桌面端折叠状态 const toggleCollapse () { if (isMobile.value) return // 移动端不执行此逻辑 isCollapse.value !isCollapse.value } // 打开移动端侧边栏抽屉 const openMobileSidebar () { mobileSidebarOpen.value true } // 关闭移动端侧边栏抽屉 const closeMobileSidebar () { mobileSidebarOpen.value false } // 计算属性桌面端根据 isCollapse 计算宽度移动端返回固定宽度由CSS控制 const asideWidth computed(() { if (isMobile.value) { return 200px // 移动端抽屉固定宽度实际显示由CSS的transform控制 } return isCollapse.value ? 64px : 200px }) // 生命周期初始化及监听窗口变化 onMounted(() { checkIsMobile() window.addEventListener(resize, checkIsMobile) }) onUnmounted(() { window.removeEventListener(resize, checkIsMobile) }) /script逻辑解析isMobile通过监听window.resize事件动态判断当前是否处于移动端宽度768px。mobileSidebarOpen专门控制移动端抽屉的开关状态与桌面端的isCollapse状态分离。条件渲染与样式绑定在移动端 (v-ifisMobile)显示一个遮罩层 (layout-mask)点击可关闭抽屉。侧边栏通过:class绑定is-mobile-open类控制其滑入滑出。顶部按钮根据isMobile显示不同的图标和绑定不同的事件桌面端是折叠/展开图标移动端是“汉堡菜单”图标用于打开抽屉。状态隔离在移动端我们强制将isCollapse设为false因为移动端的折叠逻辑是“整个抽屉的显示与隐藏”而不是“菜单内部的收起展开”。这样保证了el-menu组件在移动端抽屉内总是以完整形式展示。踩坑提醒这里有一个常见的冲突点。Element-Plus 的el-menu在collapse状态下会改变子菜单的弹出方式从内联变为浮层。在移动端抽屉中如果菜单是折叠状态子菜单会以浮层形式弹出可能会被抽屉的边界裁剪或位置错乱。因此我们在移动端将isCollapse设为false是必要的确保子菜单在抽屉内正常展开。同时在SidebarMenu.vue中我们传参时也要注意SidebarMenu :is-collapseisMobile ? false : isCollapse /是更严谨的写法。6. 路由激活与菜单高亮的深度处理菜单高亮是导航的核心反馈。虽然我们通过:default-activeroute.path进行了基本绑定但在实际项目中路由结构往往更复杂直接使用route.path可能会高亮失败。6.1 问题场景分析嵌套路由你的路由配置可能是嵌套的例如/system/user对应一个嵌套的router-view。但你的菜单项可能只定义到了/system。此时需要高亮的是/system这个父级菜单。动态路由路径中包含参数如/user/edit/123。你的菜单项路径是/user。你需要匹配到/user并高亮它。重定向路由你访问/被重定向到/dashboard。此时需要高亮的是/dashboard对应的菜单。6.2 实现一个健壮的 activeMenu 计算属性我们需要一个函数能够根据当前路由 (route)从完整的菜单列表 (menuRoutes) 中找到最匹配的那个菜单项路径。修改SidebarMenu.vuescript setup langts import { computed } from vue import { useRoute } from vue-router import MenuItem from ./MenuItem.vue import type { MenuItem as MenuItemType } from ./types // 假设有类型定义 defineProps{ isCollapse: boolean }() const route useRoute() const menuRoutes: MenuItemType[] [ ... ] // 你的菜单数据 /** * 递归查找与当前路由路径最匹配的菜单项 * param path 当前路由路径 * param menuList 菜单列表 * returns 匹配到的菜单项路径未找到则返回当前路由路径 */ const findActiveMenu (path: string, menuList: MenuItemType[]): string { for (const menu of menuList) { // 精确匹配当前路径完全等于菜单路径 if (menu.path path) { return menu.path } // 前缀匹配当前路径以菜单路径开头考虑嵌套路由 // 例如 path/system/user, menu.path/system // 需要确保不是根路径且匹配后下一个字符是 /避免 /sys 匹配到 /system if (path.startsWith(menu.path /) menu.path ! /) { return menu.path } // 递归查找子菜单 if (menu.children menu.children.length 0) { const activePath findActiveMenu(path, menu.children) if (activePath) { // 如果子菜单中找到了可以返回子菜单的路径或者根据需求返回父菜单路径 // 通常我们希望高亮父级菜单即当前这个menu return menu.path } } } return path // 兜底返回当前路径 } // 计算当前激活的菜单项 const activeMenu computed(() { return findActiveMenu(route.path, menuRoutes) }) /script这个findActiveMenu函数实现了精确匹配第一优先级。前缀匹配用于处理嵌套路由高亮父级菜单。递归查找深入子菜单进行匹配。经验技巧在实际项目中菜单数据往往来自后端接口其结构可能更复杂。你可能需要处理meta中定义的activeMenu字段Vue Router 支持或者根据路由的name进行匹配。上述函数是一个基础但有效的解决方案你可以根据项目实际情况调整匹配逻辑。例如有些场景下你希望高亮的是叶子节点菜单而不是父节点那么递归查找时返回activePath而不是menu.path即可。6.3 处理路由变化时菜单的展开状态另一个相关的问题是当通过浏览器地址栏或链接跳转到一个深层路由时对应的父级子菜单应该自动展开。Element-Plus 的el-menu组件有default-openeds属性可以设置默认展开的菜单但它是静态的。我们需要动态设置。我们可以利用 Vue Router 的导航守卫或watch来监听路由变化然后计算出需要展开的菜单索引数组。!-- 在 SidebarMenu.vue 中补充 -- template el-menu :default-activeactiveMenu :default-openedsopenedMenus :collapseisCollapse ...其他属性 ... /el-menu /template script setup langts import { ref, watch, computed } from vue import { useRoute } from vue-router const route useRoute() const menuRoutes [ ... ] const isCollapse defineProps... // 存储当前需要展开的菜单项 index (path) 数组 const openedMenus refstring[]([]) /** * 根据当前活动路径找出所有需要展开的父级菜单路径 */ const updateOpenedMenus (activePath: string, menuList: MenuItemType[]): string[] { const opened: string[] [] const findPath (path: string, list: MenuItemType[], parentPaths: string[] []): boolean { for (const menu of list) { const currentPaths [...parentPaths, menu.path] if (menu.path path || path.startsWith(menu.path /)) { // 找到匹配项将其所有父路径加入展开列表排除自身 opened.push(...parentPaths) return true } if (menu.children) { if (findPath(path, menu.children, currentPaths)) { // 如果在子菜单中找到当前菜单也需要展开 if (!opened.includes(menu.path)) { opened.push(menu.path) } return true } } } return false } findPath(activePath, menuRoutes) // 去重并返回 return [...new Set(opened)] } // 监听 activeMenu 变化更新展开的菜单 watch( () activeMenu.value, (newPath) { openedMenus.value updateOpenedMenus(newPath, menuRoutes) }, { immediate: true } // 立即执行一次以初始化 ) /script这样无论用户通过何种方式进入页面刷新、直接输入URL、点击面包屑等对应的菜单层级都会正确展开高亮也准确无误提供了完整的导航体验。7. 性能优化与细节打磨功能实现后我们还需要关注性能和用户体验细节。7.1 避免不必要的重渲染我们的SidebarMenu和MenuItem组件在isCollapse变化或路由变化时可能会重新渲染。对于大型菜单这可能有性能开销。使用v-once或Object.freeze如果菜单数据是静态的在定义时可以使用Object.freeze冻结或对无需响应的部分使用v-once指令。但我们的菜单数据可能来自接口需谨慎。精细化传递 Props确保只将必要的 props 传递给子组件。例如MenuItem组件只需要当前的item数据不需要知道全局的isCollapse状态。使用computed缓存像activeMenu、asideWidth这样的派生状态一定要用computed计算属性Vue 会帮我们做缓存。7.2 折叠状态下的用户体验提升标题 Tooltip当菜单折叠时鼠标悬停在图标上应该显示该菜单项的完整标题。Element-Plus 的el-menu在collapse状态下会自动为el-sub-menu添加 Tooltip但对于el-menu-item我们需要自己处理。一种简单的方式是给每个el-menu-item包裹一个el-tooltip但这样代码侵入性强。更优雅的方式是利用el-menu的popper-effect和collapse状态下的内置行为通常已经够用。如果不够可以监听isCollapse动态为每个菜单项的根元素添加title属性。折叠动画节奏我们为侧边栏宽度和菜单文字都添加了 CSS 过渡。确保两者的持续时间和缓动函数 (easing-function) 一致或协调例如都使用ease-in-out和0.3s这样动画看起来才是一体的不会脱节。7.3 与 Pinia (状态管理) 的集成在大型项目中菜单的折叠状态可能需要在多个不相关的组件中访问例如一个在页面深处的按钮也想控制菜单折叠。这时将isCollapse放在全局状态管理库如 Pinia中会更合适。// stores/app.ts import { defineStore } from pinia import { useLocalStorage } from /composables/useLocalStorage export const useAppStore defineStore(app, () { const isCollapse useLocalStorageboolean(app-sidebar-collapse, false) const toggleCollapse () { isCollapse.value !isCollapse.value } return { isCollapse, toggleCollapse } })然后在Layout.vue和任何需要的地方引入并使用这个 store!-- Layout.vue -- script setup langts import { useAppStore } from /stores/app import { storeToRefs } from pinia const appStore useAppStore() // 使用 storeToRefs 保持响应式 const { isCollapse } storeToRefs(appStore) const { toggleCollapse } appStore // ... 其余逻辑 /script这样状态管理更加清晰也满足了跨组件状态共享的需求。8. 常见问题排查与解决方案在实际开发中你可能会遇到以下问题问题一菜单折叠后子菜单的弹出位置错乱或者被遮挡。原因Element-Plus 的el-menu在collapse状态下子菜单会以popper浮层形式弹出。这个浮层的z-index可能不够高或者其父容器设置了overflow: hidden。解决方案检查.layout-aside或.sidebar-menu的父容器是否有overflow: hidden。如果有尝试移除或改为overflow: visible。在我们的代码中.layout-aside设置了overflow: hidden是为了防止收缩时内容溢出但这可能会裁剪浮层。一个折中方案是只在非折叠状态下隐藏溢出overflow: hidden;配合overflow: visible !important;在折叠状态下通过:deep(.el-menu--collapse)选择器可能不理想。更稳妥的做法是确保浮层弹出的根节点 (body) 不受影响。Element-Plus 的popper默认会附加到body末尾通常不受父容器影响。如果仍有问题可以调整el-menu的popper-append-to-body属性默认为true应保持并检查全局CSS是否有影响body下元素的样式。问题二路由跳转后页面内容区域滚动条没有复位到顶部。原因这是单页应用 (SPA) 的常见问题。路由切换时Vue Router 复用了组件页面容器我们的el-main的滚动位置保持不变。解决方案在Layout.vue中监听路由变化并滚动主容器到顶部。!-- 在 Layout.vue 的 script 部分 -- import { useRouter } from vue-router const router useRouter() const layoutMainRef refHTMLElement() // 给 el-main 加上 reflayoutMainRef router.afterEach(() { // 等待下一个渲染周期确保 DOM 已更新 nextTick(() { const mainEl layoutMainRef.value?.$el || layoutMainRef.value if (mainEl) { mainEl.scrollTop 0 } }) })问题三在移动端点击菜单项跳转后抽屉不会自动关闭。原因我们的mobileSidebarOpen状态只响应了遮罩层和按钮的点击事件没有监听路由变化。解决方案在Layout.vue中添加一个对路由的监听当路由变化时在移动端自动关闭抽屉。script setup langts import { useRouter } from vue-router const router useRouter() // ... 其他代码 // 监听路由变化在移动端关闭抽屉 router.afterEach(() { if (isMobile.value) { closeMobileSidebar() } }) /script问题四菜单图标在折叠状态下不居中或样式错乱。原因Element-Plus 的el-menu在折叠状态下会为菜单项添加特定的样式类如el-menu--collapse。我们自定义的样式可能与之冲突。解决方案使用深度选择器:deep()来覆盖或调整 Element-Plus 的默认样式并且要确保我们的样式优先级足够。例如确保我们之前写的折叠状态下隐藏文字并添加过渡的 CSS 正确生效。如果图标不居中可以检查.el-menu--collapse .el-menu-item和.el-menu--collapse .el-sub-menu__title的padding和text-align属性。通过以上八个部分的详细拆解我们从零到一构建了一个健壮、美观且用户体验良好的 Vue3 Element-Plus 左侧菜单折叠展开功能。它不仅实现了基础交互更深入解决了状态持久化、响应式适配、路由高亮、性能细节等实际开发中必然会遇到的难题。每个步骤都附带了原理说明和踩坑经验你可以直接将这些代码和思路应用到你的项目中根据实际需求进行调整相信能帮你打造出一个令人满意的导航菜单。