Element UI el-menu组件默认展开菜单的完整解决方案
2026/8/31 13:27:56 网站建设 项目流程

1. 项目背景与核心需求

最近在重构一个后台管理系统,用的是 Vue 2 + Element UI 这套经典组合。产品经理提了个需求,希望系统左侧的导航菜单,在用户进入某些特定页面(比如数据看板)时,能自动展开对应的父级菜单项,而不是让用户自己去点开。这个需求听起来很合理,毕竟谁也不想每次进来都手动去展开菜单找入口。Element UI 的el-menu组件默认是全部折叠的,要实现这个“默认展开”的功能,乍一看很简单,但实际动手时,你会发现官方文档里并没有一个叫default-opened-keys这样的属性直接给你用。这其实是一个典型的“文档里没明说,但社区里都在问”的实践问题。

我翻了不少论坛和 issue,发现很多新手朋友会卡在这里,要么去改组件源码,要么用一些比较“野”的路子。其实,Element UI 已经为我们留好了接口,只是这个接口的名字和用法需要你稍微理解一下它的设计逻辑。今天,我就结合自己的踩坑经历,把el-menu实现默认展开的几种方法,以及背后的原理和注意事项,掰开揉碎了讲清楚。无论你是想展开一个菜单,还是根据路由动态展开多个菜单,看完这篇都能找到答案。

2. 理解 el-menu 的展开机制:default-activedefault-openeds

在动手写代码之前,我们必须先搞清楚 Element UI 的菜单组件是怎么管理“展开”状态的。很多人的第一反应是去找一个类似default-expand-all的属性,但el-menu并没有提供。它的状态控制核心是两个属性:default-activedefault-openeds

default-active这个属性大家比较熟悉,它用于设置当前激活的菜单项(通常是高亮显示的那一项)。它接收一个字符串,这个字符串必须与某个el-menu-itemindex属性值完全一致。它的作用是告诉菜单:“初始化的时候,哪个项目是选中的状态。” 注意,它只控制激活(高亮),并不控制父级菜单的展开与否。

default-openeds这才是控制菜单默认展开的关键属性。它接收一个数组,数组里的每个元素,都是你希望默认展开的子菜单(el-submenuindex值。划重点:它只对el-submenu生效,对普通的el-menu-item无效。它的设计逻辑是,在组件挂载(mounted)时,会根据这个数组,去依次打开对应的子菜单。

这里就引出了第一个,也是最重要的一个坑:default-openeds只在组件初始化时生效一次。这意味着,如果你在组件挂载后,再通过响应式数据(比如this.openedKeys = [‘1-1’])去改变这个数组,菜单的展开状态是不会随之更新的。它不是一个“双向绑定”的属性,而是一个“初始值”设定器。很多同学试图在createdmounted钩子里动态计算这个数组然后赋值,发现菜单没反应,原因就在于此。

那么,如何实现动态展开呢?Element UI 提供了对应的“程序化”控制方式:通过ref获取菜单实例,然后调用其open()方法。我们会在后面的动态方案里详细讲。

为了更直观地理解这两个属性的区别,我们看下面这个对比表格:

属性作用对象接收值类型生效时机是否响应式主要用途
default-activeel-menu-item字符串 (String)初始化时设置默认高亮的菜单项
default-openedsel-submenu数组 (Array)初始化时设置默认展开的子菜单
open()方法el-submenu字符串 (index)任意时刻通过程序控制展开指定菜单

理解了这张表,你就掌握了 Element UI 菜单展开控制的半壁江山。接下来,我们进入实战环节。

3. 基础方案:静态配置默认展开菜单

对于大多数后台管理系统,菜单结构在短期内是固定的。比如,我们有一个这样的菜单结构:

- 系统管理 (index: “1”) - 用户管理 (index: “1-1”) - 角色管理 (index: “1-2”) - 内容管理 (index: “2”) - 文章列表 (index: “2-1”) - 分类管理 (index: “2-2”)

如果我们希望用户一进入系统,就看到“系统管理”这个子菜单是展开的,那么最简单的办法就是在el-menu上直接写死default-openeds

<template> <el-menu :default-openeds="[‘1’]" <!-- 这里!我们希望默认展开‘系统管理’ --> :default-active=“activeIndex” background-color=“#304156” text-color=“#bfcbd9” active-text-color=“#409EFF” > <el-submenu index=“1”> <template slot=“title”> <i class=“el-icon-setting”></i> <span>系统管理</span> </template> <el-menu-item index=“1-1”>用户管理</el-menu-item> <el-menu-item index=“1-2”>角色管理</el-menu-item> </el-submenu> <el-submenu index=“2”> <template slot=“title”> <i class=“el-icon-document”></i> <span>内容管理</span> </template> <el-menu-item index=“2-1”>文章列表</el-menu-item> <el-menu-item index=“2-2”>分类管理</el-menu-item> </el-submenu> </el-menu> </template> <script> export default { data() { return { activeIndex: ‘1-1’ // 默认高亮‘用户管理’ }; } }; </script>

代码解读与注意事项:

  1. :default-openeds=“[‘1’]”: 我们通过一个绑定的数组,将字符串‘1’传给它。这个‘1’必须与<el-submenu index=“1”>中的index属性值完全一致,包括类型(这里是字符串)。如果你写成数字[1],大概率会失效,因为组件内部很可能使用了严格相等(===)或indexOf进行匹配。
  2. 多个菜单展开: 如果你想同时展开“系统管理”和“内容管理”,只需修改数组::default-openeds=“[‘1’, ‘2’]”
  3. default-active的配合: 注意,我们同时设置了:default-active=“activeIndex”‘1-1’。这样,页面加载后,“系统管理”菜单是展开的,并且其下的“用户管理”项是高亮状态。这是一个非常标准的初始化场景。
  4. index的值必须唯一: 这是 Element UI 菜单组件的一个硬性规定。整个菜单树中,所有el-menu-itemel-submenuindex值必须是全局唯一的,不能重复。这是组件内部进行状态管理和事件派发的依据。

踩坑提示:我曾经在一个项目里,因为偷懒用了简单的数字(如1,2)作为index,后来菜单结构变复杂,出现了重复的index,导致点击菜单时,高亮和展开行为完全错乱。最佳实践是使用具有层级关系且唯一的字符串,例如‘system-user’‘content-article’,或者像上面例子中用‘父级-子级’的格式。这不仅能避免冲突,也让代码的可读性更高。

这个方案简单直接,适用于菜单结构稳定、无需根据路由或权限动态变化的场景。但它的问题是“静态”的,一旦你的需求变成“从某个特定页面跳转回来,需要保持菜单展开”,它就无能为力了。这就需要我们引入动态方案。

4. 动态方案:根据路由或状态决定展开项

在实际项目中,更常见的需求是:用户点击浏览器刷新,或者从其他页面通过导航进入时,菜单能“智能地”展开到对应的位置。例如,用户当前正在访问“角色管理”(路由为/system/role),那么侧边栏的“系统管理”菜单就应该自动展开。

这时,静态配置default-openeds就失效了,因为我们需要在组件初始化时,根据当前路由路径计算出应该展开哪个submenuindex

核心思路是:在组件的createdmounted生命周期钩子中,根据当前路由($route)计算出需要展开的菜单index,然后通过ref调用菜单实例的open()方法。

4.1 方案一:使用open()方法(推荐)

这是最灵活、最符合 Vue 响应式理念的做法。

第一步:为el-menu设置ref

<template> <el-menu ref=“sideMenu” <!-- 关键:设置ref --> :default-active=“activeIndex” background-color=“#304156” text-color=“#bfcbd9” active-text-color=“#409EFF” > <!-- ... 菜单结构同上,此处省略 ... --> </el-menu> </template>

第二步:在mounted钩子中计算并展开菜单。

<script> export default { name: ‘Sidebar’, data() { return { activeIndex: ‘’, // 定义一个映射关系:路由路径 -> 需要展开的父菜单index routeToMenuIndex: { ‘/system/user’: ‘1’, ‘/system/role’: ‘1’, ‘/content/article’: ‘2’, ‘/content/category’: ‘2’, }, // 或者更精细的映射:路由路径 -> 需要高亮的菜单项index routeToActiveIndex: { ‘/system/user’: ‘1-1’, ‘/system/role’: ‘1-2’, ‘/content/article’: ‘2-1’, ‘/content/category’: ‘2-2’, } }; }, mounted() { this.initMenuState(); }, watch: { // 监听路由变化,当路由切换时,重新初始化菜单状态 ‘$route.path’() { this.initMenuState(); } }, methods: { initMenuState() { const currentPath = this.$route.path; // 1. 设置当前高亮项 this.activeIndex = this.routeToActiveIndex[currentPath] || ‘’; // 2. 获取需要展开的父菜单index const parentIndexToOpen = this.routeToMenuIndex[currentPath]; if (parentIndexToOpen && this.$refs.sideMenu) { // 关键:调用open方法。注意,nextTick确保DOM已渲染 this.$nextTick(() => { this.$refs.sideMenu.open(parentIndexToOpen); }); } } } }; </script>

原理解析与避坑指南:

  1. 为什么用mounted而不是created因为open()方法是操作真实的 DOM 组件实例。在created阶段,$refs.sideMenu还是undefined,只有在mounted之后,组件挂载完成,ref才会被注册。
  2. 为什么用$nextTick这是一个非常重要的细节。Vue 的数据更新和 DOM 更新是异步的。当我们设置this.activeIndex后,菜单组件需要一点时间来重新计算和渲染内部状态。如果紧接着就调用open(),可能会因为内部状态还未更新而失败。用$nextTick可以确保我们的操作在 DOM 更新循环结束之后执行,此时组件处于一个稳定的状态。
  3. open()方法的特性: 与default-openeds不同,open()方法是响应式的。你可以在任何地方(例如,响应一个按钮点击事件)调用它来展开或关闭菜单(对应的有关闭方法close())。这给了我们极大的灵活性。
  4. 映射关系的维护: 上面例子中的routeToMenuIndexrouteToActiveIndex是硬编码的映射表。在大型项目中,这可能会变得难以维护。一个更优雅的做法是,将菜单配置抽离成一个独立的数组或 JSON 文件,每个菜单项都包含pathindexparentIndex等元信息。然后在initMenuState方法中,遍历这个配置数组,找到与当前路由匹配的项,再递归找到其所有父级菜单的index进行展开。这涉及到菜单权限系统的设计,是一个更深的话题。

4.2 方案二:动态绑定default-openeds(有局限)

你可能想问,既然default-openeds是属性,我能不能用v-bind绑定一个计算属性 (computed),让它动态计算呢?理论上可以,但实践中有个大坑。

<template> <el-menu :default-openeds=“defaultOpeneds” <!-- 绑定计算属性 --> :default-active=“activeIndex” > <!-- ... --> </el-menu> </template> <script> export default { computed: { defaultOpeneds() { const path = this.$route.path; // 根据路由计算需要展开的index if (path.startsWith(‘/system’)) { return [‘1’]; } else if (path.startsWith(‘/content’)) { return [‘2’]; } return []; }, activeIndex() { // ... 计算高亮index } } }; </script>

这个方案的致命缺陷:正如第2节所说,default-openeds只在组件初始化时读取一次。当你从/home跳到/system/user时,路由变了,defaultOpeneds计算属性返回的值也从[]变成了[‘1’]。但是,el-menu组件内部并不会因为这个值的改变而重新展开菜单。它只在第一次创建时,读取了初始的[]

所以,这个方案仅适用于一种特殊情况:你的侧边栏菜单组件会在路由变化时被销毁并重新创建(例如,使用了<router-view :key=“$route.fullPath”>这种强制复用的模式)。在绝大多数单页面应用(SPA)中,侧边栏是常驻组件,不会被销毁,因此这个方案是无效的。不推荐使用。

5. 进阶场景与疑难杂症处理

掌握了基础和动态方案,你已经能解决90%的问题。但在复杂的真实项目中,还会遇到一些边界情况。

5.1 处理多级嵌套菜单的展开

有时候我们的菜单不止两级,可能是三级甚至更多:

- 系统设置 (index: “sys”) - 权限管理 (index: “sys-auth”) - 用户组 (index: “sys-auth-group”) - 操作日志 (index: “sys-auth-log”)

如果当前路由对应的是“操作日志”(index: ‘sys-auth-log’),我们不仅需要展开“权限管理”(index: ‘sys-auth’),还需要展开其父菜单“系统设置”(index: ‘sys’)。el-menuopen()方法一次只能打开一个子菜单。对于多级嵌套,我们需要递归地打开所有父级菜单

我们需要一个函数,根据当前激活项的index,找到其所有的祖先index

methods: { // 假设我们有一个完整的菜单树数据 menuTree // 每个菜单项格式:{ index: ‘…‘, children: […] } findAllParentIndexes(menuItemIndex, menuTree) { const result = []; const findParent = (node, targetIndex, path) => { if (node.index === targetIndex) { // 找到目标节点,返回收集到的父节点路径(不包括自己) return path; } if (node.children) { for (const child of node.children) { const found = findParent(child, targetIndex, […path, node.index]); // 将当前节点加入路径 if (found) { return found; } } } return null; }; // 从根节点开始查找,初始路径为空 for (const rootNode of menuTree) { const parentIndexes = findParent(rootNode, menuItemIndex, []); if (parentIndexes) { // 过滤掉根节点(如果根节点index为空或不需要展开) result.push(…parentIndexes.filter(idx => idx)); break; } } return result; // 返回如 [‘sys’, ‘sys-auth’] }, initMenuState() { const currentActiveIndex = this.calcActiveIndexFromRoute(); // 计算当前高亮项index this.activeIndex = currentActiveIndex; const parentIndexesToOpen = this.findAllParentIndexes(currentActiveIndex, this.menuTree); this.$nextTick(() => { // 依次打开所有父级菜单 parentIndexesToOpen.forEach(index => { this.$refs.sideMenu.open(index); }); }); } }

这个递归查找的逻辑稍微复杂,但它是处理无限级菜单展开的通用解法。如果你的菜单数据是从后端接口获取的,通常也会包含父子关系信息,可以直接利用。

5.2 与 Vuex 状态管理结合

在大型应用中,侧边栏的展开/折叠状态可能需要全局共享(比如,在另一个组件中点击按钮控制菜单折叠)。这时,可以将需要展开的菜单index数组存入 Vuex 的state中。

// store/modules/app.js const state = { sidebarOpenedKeys: [] // 存储需要展开的菜单index数组 }; const mutations = { SET_SIDEBAR_OPENED_KEYS(state, keys) { state.sidebarOpenedKeys = keys; } }; const actions = { updateSidebarOpenedKeys({ commit }, keys) { commit(‘SET_SIDEBAR_OPENED_KEYS’, keys); } }; // 在 Sidebar.vue 组件中 import { mapState } from ‘vuex’; export default { computed: { …mapState(‘app’, [‘sidebarOpenedKeys’]) }, watch: { sidebarOpenedKeys(newVal) { // 当Vuex中的状态变化时,操作菜单组件 this.$nextTick(() => { // 先关闭所有?或者智能对比打开/关闭?这里需要根据业务设计 // 简单做法:遍历newVal,全部打开 newVal.forEach(index => { this.$refs.sideMenu.open(index); }); }); } }, mounted() { // 初始化时,也可以从Vuex读取状态 if (this.sidebarOpenedKeys.length > 0) { this.$nextTick(() => { this.sidebarOpenedKeys.forEach(index => { this.$refs.sideMenu.open(index); }); }); } } };

这样,任何组件都可以通过dispatch(‘app/updateSidebarOpenedKeys’, [‘1’])来控制侧边栏的展开状态,实现了状态与UI的分离。

5.3 浏览器刷新后的状态保持

用户刷新页面后,Vuex 的状态会丢失,default-openeds又会变回初始值。为了提升用户体验,我们常常需要持久化菜单的展开状态。一个常见的做法是结合localStoragesessionStorage

  1. 在菜单展开/折叠时(监听el-menuopenclose事件),将当前的展开键数组保存起来。

    <el-menu @open=“handleMenuOpen” @close=“handleMenuClose” … >
    methods: { handleMenuOpen(index) { const openedKeys = this.$refs.sideMenu.openedMenus; // 注意:这是一个内部属性,不一定稳定 // 更可靠的方式是自己维护一个数组 this.currentOpenedKeys.push(index); localStorage.setItem(‘sidebar_opened_keys’, JSON.stringify(this.currentOpenedKeys)); }, handleMenuClose(index) { // … 从数组中移除index并保存 } }

    注意:直接使用this.$refs.sideMenu.openedMenus可能不是一个好的实践,因为它是 Element UI 组件的内部属性,可能在版本升级中发生变化。更健壮的方式是自己用数据去管理展开状态。

  2. 在应用初始化(如App.vuecreated钩子)或侧边栏组件的created钩子中,从localStorage读取状态并还原。

    created() { const savedKeys = JSON.parse(localStorage.getItem(‘sidebar_opened_keys’) || ‘[]’); // 将 savedKeys 存入 Vuex 或组件的 data 中 this.$store.dispatch(‘app/updateSidebarOpenedKeys’, savedKeys); }

这样,即使用户刷新页面,侧边栏也能恢复到刷新前的展开状态。当然,是否需要这个功能,取决于具体的产品需求。

6. 性能优化与最佳实践总结

在实现功能的同时,我们也需要考虑代码的性能和可维护性。

  1. 避免在watchcomputed中频繁进行复杂计算: 像findAllParentIndexes这样的递归函数,如果菜单树很大,频繁执行会有性能开销。建议将计算结果缓存起来,或者只在路由真正发生变化时计算一次。

  2. index的设计哲学: 强烈建议使用有意义的、唯一的字符串作为index,例如‘system:user’‘content:article:list’。避免使用简单的自增数字,这在后期维护和调试时会是噩梦。

  3. 菜单数据归一化: 理想情况下,侧边栏的渲染数据、路由配置、权限配置应该有一个统一的来源。可以创建一个src/router/menu.js文件,导出一个包含所有菜单信息的数组,里面定义了pathcomponentmeta(包含titleiconindex等)。这样,无论是侧边栏渲染、路由守卫权限判断,还是本文讨论的默认展开逻辑,都可以基于同一份数据,极大减少重复和出错的概率。

  4. 关于unique-opened属性el-menu有一个unique-opened属性,设置为true可以保持最多只有一个子菜单展开。如果你的产品设计如此,那么本文讨论的“默认展开多个”的需求就不存在了。但即使在这种情况下,动态决定展开“哪一个”的需求依然存在,解决方案依然是使用open()方法。

  5. 测试边界条件

    • 路由不存在于菜单映射中时,菜单应该如何处理?(默认折叠或展开某个首页菜单)
    • 在移动端或折叠侧边栏后,展开状态是否应该重置?
    • 用户手动折叠了一个菜单后,路由变化是否应该强制展开?这需要和产品经理确认交互细节。

回过头看,设置 Element UI 菜单的默认展开,核心就在于理解default-openeds的“一次性”特性,并熟练掌握ref+open()方法这套动态控制组合拳。从简单的静态配置,到结合路由的动态计算,再到处理多级嵌套和状态持久化,每一步都需要对组件的行为有清晰的认识。希望这篇近六千字的详细拆解,能帮你彻底搞定这个看似简单却暗藏玄机的问题。在实际项目中,选择最适合你场景的方案,并注意维护好菜单的元数据,就能让导航体验丝滑流畅。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询