导航菜单的选中高亮,是后台管理系统里最容易被低估的一个细节。点一下菜单,页面确实跳走了,但左边高亮还赖在原来那一项上;按 F5 刷新,子菜单"啪"一下全部收起,高亮消失得干干净净;想在详情页让"用户管理"这一项保持点亮,结果怎么调都对不上。这一类问题在 Nuxt 里用 Ant Design Vue 的 a-menu 做导航时特别典型,因为它从来不是"配一个属性就好"的事情,背后是受控组件、路由状态、SSR 首屏、折叠态弹出层四个互相牵制的点。这篇内容就把 a-menu 配合路由使用的整套方案拆开讲:怎么让点击菜单真正跳到对应路由,怎么让刷新或切换页面后自动选中并高亮当前菜单,以及中间那些文档里不会写、只有踩过才知道的坑。适合刚接手管理后台、对 Vue 受控组件概念还比较模糊的同学,也适合已经在用 Nuxt 但高亮逻辑一直靠"打补丁"维持的同学。下面的方案以 Nuxt 3 + Ant Design Vue 4 为主线,最后会单独给一份 Nuxt 2 + ant-design-vue 1.x 的差异对照,老项目也能直接套。
1. 菜单高亮为什么总在刷新后丢:先把问题拆成四份
1.1 a-menu 的选中态是受控属性,不是路由的附属品
很多人第一反应是"菜单不是应该自己知道当前在哪个页面吗",实际并不是。a-menu 组件本身对路由一无所知,它只认selectedKeys这个数组。这个数组是"受控属性",意思是:组件内部不会自己维护它,你传什么它就显示什么,用户点击菜单项时它只是发出一个click或select事件告诉你"我被点了",至于要不要改高亮,完全由外部决定。
这就解释了为什么刷新后高亮一定会丢:页面重新加载,你组件里的selectedKeys初始值是空数组,菜单自然一项都不亮。它的初始值不会自动变成当前路由,因为组件压根不知道当前路由是什么。想让它在首屏就亮,你得在组件初始化时把route.path算进去。
同理,openKeys控制的是展开的子菜单,它也是受控的。刷新后openKeys是空数组,子菜单就全部折叠。这两个状态必须分别处理,不能指望一个搞定另一个。我在早期项目里就犯过这个错:只同步了selectedKeys,结果高亮对了,但当前项藏在折叠的子菜单里,用户看不见,等于没做。
1.2 四种接入路线的取舍,以及我为什么选"单向绑定 + 路由派生"
把 a-menu 和路由接起来,社区里流传的写法大致有四种,效果和坑都不一样,值得先摆在一起对比。
第一种是把router-link塞进a-menu-item里,靠链接自己跳转。这种写法最省事,但菜单的高亮和路由是两套状态,点完之后菜单内部会自己更新选中项,路由也在变,两边一旦不同步就会出现"地址变了高亮没变"。而且链接只覆盖文字区域,点菜单项的空白处没反应,体验很别扭。
第二种是用v-model:selectedKeys双向绑定,再加一个@click里router.push。这看起来最符合 Vue 的习惯,但问题在于双向绑定意味着菜单内部也能改这个值。点击瞬间菜单先把高亮挪过去,如果router.push因为权限拦截、重复导航、路由不存在等原因没成功,高亮就停在了一个根本没有对应页面的菜单项上,用户刷新一下又跳回去,非常诡异。
第三种是监听@select事件手动调router.push,同时用watch监听路由变化反向更新selectedKeys。这是能跑通的,但如果你忘了在immediate: true,首屏还是空的。
第四种是我现在统一采用的方式:selectedKeys只做单向绑定,值完全从当前路由computed派生出来,不给菜单任何修改它的机会;openKeys则保留受控,但只允许"补齐"不允许"覆盖"。跳转动作交给点击事件或者渲染出来的链接,选中态永远由路由这个唯一数据源决定。这样做的直接好处是:只要页面渲染出来了,高亮就一定是正确的,因为它压根不是"另一个状态",而是路由的另一种展示形式。路由不出错,高亮就不会错。
这个思路还有个衍生价值:任何一个"当前状态"类的 UI,比如面包屑、页签、步骤条,都可以从同一个路由源派生。你不需要额外维护一份状态,也就不存在两份状态对不上的可能。
2. 环境准备与目录规划:Nuxt 3 接 Ant Design Vue 4
2.1 依赖安装与样式、插件的注册方式
Nuxt 3 接 Ant Design Vue 4 的步骤不多,但有三个地方容易漏,漏一个就是白屏或者样式错乱。
先装依赖。除了组件库本体,图标包要单独装,因为菜单里的图标基本都要用:
npm i ant-design-vue @ant-design/icons-vue然后在nuxt.config.ts里引入样式。Ant Design Vue 4 的样式入口是reset.css,不是老版本的dist/antd.css,这一点很多人抄旧教程会踩坑:
export default defineNuxtConfig({ css: ['ant-design-vue/dist/reset.css'], build: { transpile: ['ant-design-vue'], }, })transpile这一项不是必须的,但加上没有副作用。Nuxt 3 的 SSR 构建对 ESM 的解析策略比较严格,个别版本里不加会报某个模块不是 ESM 的错误,出现这种情况再加也来得及。
接着注册插件。在plugins/目录下建一个文件,把组件库全量挂上去:
// plugins/antd.ts import Antd from 'ant-design-vue' export default defineNuxtPlugin((nuxtApp) => { nuxtApp.vueApp.use(Antd) })这里我做了一个取舍:用全量注册而不是按需导入。按需导入通常靠unplugin-vue-components配合解析器,构建产物确实小一些,但在 Nuxt 的 SSR 场景下,解析器有时会把组件识别成只在客户端存在,导致首屏渲染出来的菜单是空的,等水合完成才出现,视觉上就是"闪一下"。管理后台的组件库体积本来就大,省下那一两百 KB 换来首屏稳定,我认为很值。真要优化,等业务稳定后再按页面拆包更划算。
第三点容易漏的是:a-menu的图标不能直接写组件名,要么在模板里显式引入,要么通过h()渲染。后面代码里我用一个图标映射表解决。
2.2 菜单数据的落点:为什么单独抽一个 config/menu.ts
菜单数据绝对不要写死在布局组件里。我见过不少项目,菜单结构在layouts/default.vue里硬编码几十行模板,后来要加权限过滤、要改成分组、要从接口拉配置,全得推翻重来。
我的习惯是在config/menu.ts里定义一棵纯数据的树,只描述"有哪些菜单、对应哪个地址、有什么子项",不带任何渲染逻辑。布局组件只负责把树翻译成组件要的items,权限过滤也只作用在这棵树上。这样一来,菜单结构变更、权限变更、面包屑复用,改的都是同一处。
树节点的类型定义建议就用下面这份,字段少但够用:
// config/menu.ts export interface MenuNode { key: string label: string icon?: string children?: MenuNode[] hidden?: boolean } export const menuTree: MenuNode[] = [ { key: '/dashboard', label: '工作台', icon: 'DashboardOutlined' }, { key: 'group:system', label: '系统管理', icon: 'SettingOutlined', children: [ { key: '/system/user', label: '用户管理' }, { key: '/system/role', label: '角色管理' }, { key: '/system/log', label: '操作日志' }, ], }, { key: 'group:content', label: '内容管理', icon: 'FileTextOutlined', children: [ { key: '/content/list', label: '内容列表' }, { key: '/content/audit', label: '内容审核' }, ], }, ]注意分组节点的 key 用了group:前缀,这是个刻意的设计,下一节会详细说为什么。现在只要记住一件事:凡是能产出路由地址的节点,key 必须和路由 path 完全一致;分组节点必须和任何 path 都不一致。
3. 菜单数据建模:key 存什么,决定后面 80% 的坑
3.1 用路由 path 当 key,以及分组节点必须换个前缀
菜单的 key 有三种常见存法:自增 ID、业务编码、路由 path。前两种在跳转时都要额外维护一张"编码到地址"的映射表,加一个页面就要改两处,时间长了必然有人漏。用 path 当 key 的好处是省掉了映射,selectedKeys = [route.path]这一句就能完成同步。
但用 path 当 key 有个必须提前想清楚的副作用:分组节点的 key 该写什么。如果你写成/system,那么当某天真的新增了一个/system页面时,就会出现"点分组标题跳转了,同时还把子菜单收起来"的怪现象。更糟的是,selectedKeys和openKeys里可能同时出现/system,菜单会同时把它当成可选中项和可展开项,行为不可预测。
给分组统一加一个不会出现在 URL 里的前缀,比如group:,问题就彻底消失了。任何以group:开头的 key,在处理点击事件时直接忽略,在推导父级时作为展开项处理,两套语义泾渭分明。这个小约定能省掉后面无数排查时间,我建议直接照抄。
还有个小细节:key 的值要和你实际写路由时的大小写、层级保持一致。Nuxt 是文件路由,pages/system/user.vue对应的 path 是/system/user,和菜单里写的完全一致,不会出错。但如果项目里配了router.options.routes手动改写,或者用了别名,就得逐条核对一遍,别想当然。
3.2 从当前路由反推父级 key 的递归算法
刷新后子菜单全部收起,根因就是openKeys是空数组。要解决,得在拿到当前路由后,反推出它所有祖先分组的 key,把它们塞进openKeys。
树的深度一般不会超过三层,所以用最朴素的深度优先递归就够了,没必要上什么复杂算法。核心逻辑是:一路带着"已走过的父级 key 列表"往下找,命中了就把这条路径返回。
export function findParentKeys( nodes: MenuNode[], target: string, trail: string[] = [] ): string[] | null { for (const node of nodes) { if (node.key === target) return trail if (node.children?.length) { const found = findParentKeys(node.children, target, [...trail, node.key]) if (found) return found } } return null }返回null和返回空数组是两个不同的语义,一定要区分开:[]表示"找到了,但它是顶级节点,没有父级",null表示"整棵树里压根没有这个 key"。前者你把openKeys设成空数组是对的,后者说明当前路由不在菜单配置里(比如详情页、404 页),这时候就不该动openKeys,否则用户正在浏览的菜单会被意外收起。
这里还有一个容易忽略的体验问题:openKeys到底该"覆盖"还是"补齐"。覆盖的意思是每次路由变化,openKeys直接等于当前路由的父级列表,其他展开的分组全部收起。补齐的意思是保留用户手动展开的分组,只把当前路由需要的父级加进去。我更倾向于补齐,因为用户经常会一边看着当前页,一边展开别的分组准备下一步操作,一覆盖就被打断。写成Array.from(new Set([...openKeys.value, ...parents]))就能实现补齐效果。
3.3 动态路由与详情页的兜底:meta.activeMenu 与最长前缀匹配
真实项目里,菜单项和路由不是一一对应的。列表页/content/list点进去的详情页是/content/detail/123,这个详情页通常不在菜单里,但产品要求左侧仍然高亮"内容列表"。这时候selectedKeys = [route.path]就失效了,因为树里根本没有这个 key。
最干净的做法是用路由元信息声明"我归属哪个菜单"。Nuxt 3 里直接写definePageMeta:
// pages/content/detail/[id].vue definePageMeta({ activeMenu: '/content/list', })然后在菜单组件里优先取这个值:
const activePath = computed(() => { const meta = route.meta.activeMenu return typeof meta === 'string' && meta ? meta : normalize(route.path) })这个方案的好处是可控、显式,缺点是要在每个详情页写一行。页数多了嫌烦的话,可以再加一层自动兜底:如果整棵树里找不到当前 path,就取所有叶子节点里、是当前 path 前缀且长度最长的那个。
export function matchByPrefix(nodes: MenuNode[], path: string): string | null { let best: string | null = null const walk = (list: MenuNode[]) => { for (const node of list) { if (!node.key.startsWith('group:')) { if ( (path === node.key || path.startsWith(node.key + '/')) && (!best || node.key.length > best.length) ) { best = node.key } } if (node.children?.length) walk(node.children) } } walk(nodes) return best }前缀匹配一定要带上node.key + '/'这个斜杠,否则/content/list会匹配到/content/li这种不存在的边界情况,也会把/content/list2误判成同一个。这个细节我在一个多语言站点上栽过,两个语言的路径前缀太像,不加斜杠直接串了。
匹配的优先级建议是:meta.activeMenu> 精确匹配 > 最长前缀匹配 > 空。四层兜底下来,基本不会出现"高亮跑到奇怪地方"的情况。
4. 核心实现:一个刷新不丢高亮的 LayoutMenu 组件
4.1 用 items 数据驱动渲染,还是用 slot 手写
Ant Design Vue 4 的 Menu 支持两种写法:items属性传数据,或者用a-menu-item、a-sub-menu子组件手写模板。老项目里手写模板的多,新项目我建议一律用items。
原因有三个。第一,items的 key 是明确的,不会出现"忘记给子组件写 key 导致选中失效"这种低级错误,而 slot 写法里 key 到底该写在外层template v-for上还是子组件上,很多人搞混,写完一跑发现选中态没反应。第二,items天然适合递归翻译,任意深度的树都能用一个函数搞定,不用在模板里写嵌套的v-if。第三,SSR 渲染时items的序列化结果更稳定,不容易出现客户端服务端渲染不一致的水合警告。
翻译函数大概长这样:
import { h } from 'vue' import { RouterLink } from 'vue-router' import type { ItemType } from 'ant-design-vue' import { iconMap } from '@/config/icons' function toItems(nodes: MenuNode[]): ItemType[] { return nodes.map((node) => { const icon = node.icon ? () => h(iconMap[node.icon!]) : undefined if (node.children?.length) { return { key: node.key, label: node.label, icon, children: toItems(node.children), } } return { key: node.key, icon, label: () => h(RouterLink, { to: node.key }, () => node.label), } }) }叶子节点的label用函数返回一个RouterLink,这一步是有意为之。渲染出真实的<a>标签意味着用户可以按住 Ctrl 点击在新标签页打开、可以右键"在新窗口打开"、可以复制链接地址,这些都是纯@click跳转做不到的。管理后台的用户往往要同时开好几个页面,这个能力很重要。
代价是点击链接时,事件会冒泡到 Menu,触发它的click事件。不过因为我们的selectedKeys是单向绑定的,Menu 改了也没用,最终高亮还是跟着路由走,不会出现双状态打架。
图标映射表单独放一个文件,把要用的图标都列进去,避免整个图标包被打进首屏:
// config/icons.ts import { DashboardOutlined, SettingOutlined, FileTextOutlined, } from '@ant-design/icons-vue' export const iconMap: Record<string, any> = { DashboardOutlined, SettingOutlined, FileTextOutlined, }4.2 selectedKeys 单向派生与 openKeys 的受控更新
组件的状态部分,是整个方案的核心,代码不多但每一行都有讲究。
<script setup lang="ts"> import { computed, ref, watch } from 'vue' import { useRoute } from 'vue-router' import { menuTree } from '@/config/menu' import { findParentKeys, matchByPrefix } from '@/utils/menu' const props = defineProps<{ collapsed: boolean }>() const emit = defineEmits<{ (e: 'navigate'): void }>() const route = useRoute() function normalize(p: string) { if (p.length > 1 && p.endsWith('/')) return p.slice(0, -1) return p } const activePath = computed(() => { const meta = route.meta.activeMenu if (typeof meta === 'string' && meta) return normalize(meta) const path = normalize(route.path) const exact = findParentKeys(menuTree, path) if (exact) return path return matchByPrefix(menuTree, path) ?? path }) const selectedKeys = computed(() => [activePath.value]) const openKeys = ref<string[]>([]) function syncOpenKeys() { const parents = findParentKeys(menuTree, activePath.value) if (!parents) return openKeys.value = Array.from(new Set([...openKeys.value, ...parents])) } watch(activePath, syncOpenKeys, { immediate: true }) function onOpenChange(keys: (string | number)[]) { openKeys.value = keys as string[] } </script>几个关键点展开说。
selectedKeys用computed而不是ref,意味着它没有 setter,任何试图修改它的地方都会在控制台报错。这不是限制,反而是保护——它杜绝了"某处代码偷偷改了高亮"的可能性。
openKeys用ref+onOpenChange手动同步,而不是v-model:openKeys。两者效果一样,但手写更容易在中间插入自己的逻辑,比如加个最大展开数量限制,或者记录用户偏好。用v-model看起来很简洁,一旦要在变更时做点别的事就得拆开,不如一开始就写清楚。
watch的immediate: true是必须的。少了它,首屏渲染时syncOpenKeys不会执行,刷新后子菜单依然收起。加上之后,SSR 阶段也会跑一次,服务端输出的 HTML 里子菜单就是展开的,不会出现"客户端加载完才展开"的跳动。
findParentKeys返回null时直接 return,不动openKeys。这就是前面说的兜底:详情页、404 页这些不在菜单里的路由,不应该影响用户当前的展开状态。
4.3 点击跳转的三种写法与各自的适用场景
因为叶子节点的 label 已经是RouterLink了,点击跳转其实是链接自己完成的,@click只需要处理副作用。但知道另外两种写法的存在和适用场景,对维护老代码有帮助。
第一种,纯@click里router.push。适合不支持新标签页打开的场景,比如移动端,或者是嵌在应用内的二级导航。写法上要注意先去重再跳:
function onMenuClick({ key }: { key: string | number }) { const target = String(key) emit('navigate') if (!target.startsWith('/')) return if (normalize(route.path) === target) return navigateTo(target) }在 Nuxt 里优先用navigateTo而不是router.push,前者会自动处理外部链接、baseURL前缀、以及一些边界情况,后者只管内部路由。判等那一步也不能省,虽然 vue-router 4 对重复导航已经静默处理了,但每次点击都触发一次导航解析,在菜单这种高频点击的地方纯属浪费。
第二种,a-menu-item里嵌router-link。这是老项目最常见的写法,注意要把链接铺满整个菜单项,靠 CSS 把a标签设成display: block,否则只有文字区域可点。
第三种,click事件里做权限二次校验,校验通过才跳。这种在需要"点击时弹确认框"或者"记录点击日志"的场景下必须用,因为链接点击没法拦截。做法是把 label 换回纯文字,跳转逻辑全部收到@click里,并且调用domEvent.preventDefault()阻止默认行为。这种场景下就没法支持新标签页了,属于必要的取舍。
4.4 折叠态下 openKeys 必须"交出控制权"
这是全文最容易翻车的一个点,我单独拿出来讲。
侧边栏收起(collapsed = true)时,内联菜单会变成图标模式,子菜单变成鼠标悬停弹出的浮层。如果此时openKeys仍然是受控的,会出现一个很怪的现象:鼠标移上去,浮层不弹;或者弹一下立刻消失;或者弹出来了但点不到里面的菜单项。
根因是:浮层的展开收起由 Menu 内部的 hover 逻辑控制,它也想改openKeys,但你传了一个固定数组,它改不动,内外状态一打架,浮层就抽搐。
解决办法是在折叠时干脆不传这个属性,让它退回非受控模式:
const menuOpenKeys = computed(() => props.collapsed ? undefined : openKeys.value )模板里绑定:open-keys="menuOpenKeys"。因为组件内部判断是否受控的依据就是"这个 prop 是不是undefined",传undefined就完全交还控制权,浮层恢复正常。
但这样带来一个新问题:从折叠态展开回来时,openKeys还是折叠前那一份,不会自动补上当前路由的父级。所以还得加一个监听:
watch( () => props.collapsed, (val) => { if (!val) syncOpenKeys() } )顺带说一句,折叠状态本身建议持久化。用户把侧边栏收起来,多半是屏幕小或者想专心看内容,刷新一下又展开回来会很烦。用useCookie存一个布尔值就够:
const collapsed = useCookie<boolean>('sider-collapsed', { default: () => false, })用 cookie 而不是 localStorage,是因为它在 SSR 阶段就能读到,服务端渲染出来的 HTML 里侧边栏宽度就是对的,不会出现"先展开再收起"的闪动。这个细节决定了首屏观感,值得多花两行代码。
5. 常见问题排查实录:九个真实踩过的坑
5.1 高亮与展开类问题
高亮相关的故障表现趋同但成因各异,我给一张速查表,再挑几个展开讲。
| 现象 | 根因 | 处理方式 |
|---|---|---|
| 刷新后高亮全部丢失 | selectedKeys初始为空 | 改成从activePath计算派生 |
| 刷新后子菜单收起 | openKeys初始为空 | watch加immediate: true补齐父级 |
| 点了菜单地址变了高亮没变 | 用了v-model:selectedKeys,内部状态覆盖 | 改单向绑定 |
| 高亮跳到了分组标题上 | 分组 key 与某个 path 重复 | 分组 key 加group:前缀 |
| 从详情页返回列表,高亮在详情页没跟上 | 缓存导致组件未重新求值 | onActivated里再同步一次 |
| 折叠后浮层点不进去 | openKeys处于受控状态 | 折叠时传undefined |
关于"返回列表高亮不跟上",我单独说一句。如果页面开了 keep-alive 缓存,从详情页点浏览器返回,列表页组件不会重新创建,某些依赖onMounted的同步逻辑不会执行。菜单组件如果放在布局层,通常不受影响,因为布局不缓存;但如果菜单做成了页面内的二级导航,就必须在onActivated里再补一次同步。这个坑很难复现,因为只有开了缓存、且菜单不在布局层时才出现。
5.2 匹配失败类问题:query、尾斜杠、base 与动态参数
route.path和route.fullPath的区别,是这一类问题的头号来源。fullPath包含查询参数和 hash,/content/list?page=2&size=20是完整值。如果你图省事用fullPath当 key,那么用户翻页之后(页码写进 URL),高亮立刻消失,因为菜单里没有带参数的 key。
永远用route.path,并且只做一次去尾斜杠的归一化。不要用window.location.pathname,因为它包含部署的子路径前缀,本地开发时对得上、部署到子目录后就全错。
尾斜杠的问题也值得警惕。Nuxt 的router.options里可以配trailingSlash: true,一旦开启,访问/system/user会被重定向到/system/user/,route.path随之带上了斜杠,和菜单 key 对不上。要么全局关掉这个选项,要么在计算activePath时统一归一化,两处都行,但必须统一处理,别一半带斜杠一半不带。
动态路由参数的问题前面讲过,用meta.activeMenu兜底。这里补充一个容易漏的场景:如果列表页本身带查询参数(比如按状态筛选),而这些参数也拼进了路径(/content/list/audit),那就需要把每一个变体都写进菜单的hidden节点里,让它们参与匹配但不显示。这也是我为什么建议在menuTree类型里留一个hidden字段。
5.3 SSR、hydration 与异步菜单的时序问题
菜单数据如果是从接口拉的、并且按用户权限过滤,就会引入时序问题。典型表现是:首屏菜单是空的或者全量的,等客户端拿到数据后突然变成另一份,同时高亮错位。
根因是服务端和客户端拿到的菜单树不一致。如果服务端渲染时menus还是空数组,findParentKeys会返回null,openKeys保持空;客户端数据回来后菜单有了,但watch没有触发(因为activePath没变),openKeys就一直是空的。
解决办法是让syncOpenKeys同时监听菜单树:
watch([activePath, () => menuTree.value], syncOpenKeys, { immediate: true })如果菜单数据来自useFetch或useAsyncData,Nuxt 会把它序列化进 HTML,服务端和客户端拿到的是同一份,天然一致,这类问题就不存在。所以我强烈建议:权限相关的菜单数据在服务端就取好,走useAsyncData传递,不要放在onMounted里拉。后者不仅会闪,还会导致水合警告。
另一个必须避开的是在setup顶层直接访问浏览器 API。菜单里有用户头像、通知数量之类的信息时,很容易写出localStorage.getItem(...)或者window.innerWidth,SSR 阶段直接报window is not defined。这类代码一律挪到onMounted里,或者用import.meta.client判断。
提示:调试水合问题最简单的方式是打开浏览器控制台看有没有 hydration mismatch 警告。如果有,先在菜单组件里搜索
window、document、localStorage三个关键词,八成的警告都出在这。
6. 往上再走一层:面包屑、权限过滤与移动端抽屉
6.1 复用同一份树做面包屑
findParentKeys那个递归函数改两行就能复用来做面包屑,这是"单一数据源"带来的直接收益。面包屑需要的不是 key,而是从根到当前节点的 label 链:
export function findLabelChain( nodes: MenuNode[], target: string, trail: MenuNode[] = [] ): MenuNode[] | null { for (const node of nodes) { if (node.key === target) return [...trail, node] if (node.children?.length) { const found = findLabelChain(node.children, target, [...trail, node]) if (found) return found } } return null }这里有个体验细节:详情页的面包屑通常需要多一级,比如"内容管理 / 内容列表 / 详情"。前三段从菜单树拿,最后一段从route.meta.title拿,拼起来即可。因为两者的数据源不同,不用纠结要不要把详情页也塞进菜单树里——塞进去反而会污染左侧菜单。
6.2 权限裁剪后的高亮兜底
菜单按权限过滤之后会出现一种情况:当前路由在用户有权限访问的范围内,但它所属的父级分组被裁掉了。比如某个角色能直接访问/system/log,但"系统管理"这个分组本身不可见。这时候菜单里全是顶级叶子,findParentKeys返回null,高亮就没了。
处理方式是过滤的时候做"向上补全":只要有任意一个子节点可见,父级就必须保留。可以用一个后序遍历的过滤函数:
export function filterMenu(nodes: MenuNode[], can: (k: string) => boolean): MenuNode[] { return nodes .map((node) => { if (node.children?.length) { const children = filterMenu(node.children, can) if (!children.length) return null return { ...node, children } } return can(node.key) ? node : null }) .filter(Boolean) as MenuNode[] }这种"子可见则父可见"的规则,比"父不可见则子全隐藏"更符合实际权限模型,因为权限通常按页面粒度授予,分组只是个视觉容器。写反了会出现"有权限但菜单里找不到入口"的投诉,排查起来还挺费劲。
6.3 折叠与移动端抽屉的联动
同一个菜单组件,在桌面端放在a-layout-sider里,在移动端放在a-drawer里。抽屉场景下,点击菜单项后要自动关闭抽屉,否则用户得手动点一下遮罩。
做法是在@click里 emit 一个navigate事件,由父组件决定关闭抽屉还是做别的:
function onMenuClick({ key }: { key: string | number }) { const target = String(key) if (!target.startsWith('/')) return emit('navigate') }注意这里没有做router.push,因为跳转已经由 label 上的RouterLink完成了,这里只发事件。父组件监听@navigate把drawerOpen设成false。
还有一个桌面端和移动端共用的细节:抽屉里的菜单不应该处于折叠态。所以传给组件的collapsed要区分来源:
const menuCollapsed = computed(() => isMobile.value ? false : collapsed.value)移动端判断用useMediaQuery或者自己写个matchMedia监听都行,注意初始值要在onMounted里取,别在setup顶层访问window,又回到 SSR 那个坑里去了。
7. Nuxt 2 + ant-design-vue 1.x 的差异清单
7.1 写法差异对照
老项目还在 Nuxt 2 + ant-design-vue 1.x 上的不少,思路完全一样,但语法要换一套。把关键差异列出来,迁移的时候逐条对着改就行。
| 关注点 | Nuxt 3 + ant-design-vue 4 | Nuxt 2 + ant-design-vue 1.x |
|---|---|---|
| 获取路由 | const route = useRoute() | this.$route |
| 路由监听 | watch(activePath, fn) | watch: { '$route.path': fn } |
| 布局位置 | layouts/default.vue+<NuxtPage /> | layouts/default.vue+<nuxt /> |
| 跳转 | navigateTo(target) | this.$router.push(target) |
| 组件注册 | nuxtApp.vueApp.use(Antd) | plugins/antd-ui.js里Vue.use(Antd) |
| 样式引入 | ant-design-vue/dist/reset.css | ant-design-vue/dist/antd.css |
| 图标 | @ant-design/icons-vue | 同左,但版本要对应 |
| 双向绑定 | v-model:openKeys | :openKeys+@openChange(无.sync) |
特别提醒:1.x 的 Menu 没有update:openKeys事件,所以v-model:openKeys是无效的,必须老老实实:openKeys加@openChange手动赋值。有些人看 4.x 的文档抄到 2.x 项目里,写完了发现展开状态死活不变,就是这个原因。
7.2 迁移与共存时的注意点
如果是老项目改造,建议一步一步来,别一次性全换。
第一步只做数据抽离:把写死在模板里的菜单搬到config/menu.js,这一步不涉及任何框架差异,风险最低,但收益最大,后面所有改动都基于这棵树。
第二步改高亮逻辑:从"点击时手动 set"改成"从路由派生"。这一步改完,刷新丢高亮的问题就解决了大半。
第三步才处理折叠态和抽屉这类边界情况。
有个小坑要提:Nuxt 2 的watch监听$route时,如果配置了trailingSlash,重定向过程中$route.path会先变成带斜杠的值再变回来,监听函数会被触发两次。如果同步逻辑里有副作用(比如打点上报),会重复上报。加个防抖或者判等就能规避。
另外,1.x 的a-menu-item在disabled状态下点击不会触发click,但鼠标样式仍然会变,用户容易以为是自己点歪了。要做权限不可见的菜单,直接过滤掉比设disabled体验好,这也是我在filterMenu里选择"过滤而非禁用"的原因。
菜单这块东西,说出来都是小问题,但每一个小问题在真实项目里都会变成用户的疑问工单。我自己最深的体会是:凡是"当前在哪儿"这类状态,只能有一个来源。路由是唯一真相,菜单、面包屑、页签、步骤条全都是它的投影,投影之间不许互相写。这个原则立住了,后面加多少种导航形态都不会乱。反倒是一开始图省事用了双向绑定,等到需求加进来——要记住上次展开、要在详情页高亮父级、要做权限过滤——每一处都得回头改,改动量比一开始就写对要大得多。另外一个小技巧:调试高亮问题时,先在菜单组件里把selectedKeys、openKeys、activePath三个值console.log出来,看一眼就知道是哪一层没对上,比在模板里瞎试快得多。