资讯动态

Vue 3 导航栏组件设计:菜单渲染、状态管理与路由联动的工程实践

发布时间:2026/10/16 10:42:03 来源:尧图企业网站定制
组件库系列写到第六篇今天轮到 nav 导航栏组件。很多开发者觉得导航栏简单不就是一排按钮排排队、点一下高亮、再切换页面吗真到自己从零写一个能进组件库的 nav你会发现要处理的事情远不止这些菜单层级、选中态管理、横向纵向自动切换、响应式折叠、键盘可用性、和路由的联动……每一项都藏着坑。这篇就围绕我在写这个轮子时的心得展开目标是让你看完之后能避开重复踩坑写出的导航栏组件能覆盖实际业务中九成以上的场景。1. 开始动手前先把 nav 组件需要解决的问题想清楚1.1 nav 组件到底承担什么职责写组件之前我习惯先回答一个问题这个组件存在的价值是什么。nav 导航栏的核心职责只有两个告诉用户“你现在在哪里”以及“你可以去哪里”。听上去简单但这两件事在真实业务里会衍生出非常多的细节。“你在哪里”对应的是选中态active state。导航栏必须知道当前激活的是哪一项并且以高亮、下划线、或颜色变化等方式让用户一眼看到。这个概念看起来只是current xxx但实际项目中还会有子菜单、面包屑联动、路由嵌套、多级菜单等场景选中态的判断逻辑并没有想象中那么简单。“你可以去哪里”对应的是导航行为。点击菜单项之后组件要去切换页面、更新 URL、或者在单页应用里触发路由跳转。这部分我刻意不让组件直接调用路由实例而是通过事件向外抛出一个select回调由父组件去决定到底要干什么。理由是nav 组件不应该绑死某个具体框架的路由也不应该知道业务里到底有哪些页面。它只负责“用户想点这一项”这个意图后续动作全部交给使用方。这也是组件库设计里的一个基本原则职责单一能抛的就抛出去。除了这两个核心职责导航栏还承担了一些辅助能力比如折叠和展开、多级菜单的弹出、徽标通知的展示、禁用项的处理等。我在写第一版的时候试图把所有这些能力一次性塞进去结果变成了一个巨型组件维护成本极高。后来我注册了一下心情把这些辅助能力拆成了两部分核心导航逻辑只处理选中态和事件派发其他能力通过插槽和配置项开放出来谁需要谁扩展。这样组件本身的体积小了一半适用面反而更广。1.2 组件边界划分导航栏和数据展示怎么解耦很多人在写导航栏的时候最容易犯的错是把导航数据和组件强耦合。比如直接在组件内部写死业务菜单或者把权限控制的逻辑也塞进去导致换一个项目这个组件就废了。我在刚开始写这个系列的第一版轮子时也一样第一版 nav 组件里的菜单数据写死在代码里结果到了第二个项目里想复用发现完全拆不开只能重新写了一个。所以第二轮设计我做了彻底的解耦核心思路是“组件只认数据不认业务”。具体来说nav 组件接收一个items属性其结构是一个数组每个元素包含key、label、children、disabled等字段。组件内部不关心这些数据从哪里来是写死的还是接口返回的是路由表还是运营配置一概不管。组件只是把这个数组渲染成菜单然后根据activeKey决定高亮哪一项。这个设计的附带好处是我可以把导航数据抽象成一个纯对象数组放在独立的文件里维护也可以直接从后端接口拉取甚至可以在测试的时候随手写几条假数据。组件本身是纯表现层的测试就变得非常可靠稳定。当然纯数据驱动并不是万能的遇到像“第三项的前面要插一张广告图”这种需求数据驱动就有点僵硬。我的做法是除了统一的数据驱动模式还提供一个名称为item的作用域插槽允许使用者拿到底层数据后完全自定义这一项的渲染内容。这样既保证了主流程简单统一又保留了大客户定制化的逃生通道。组件边界定清楚之后后续实现就顺了很多。我只需要面对两个问题第一如何根据数据渲染出正确的菜单结构第二当选中态变化时如何通知父组件。这两个问题解决了nav 组件的骨架就算立住了。2. 属性设计把八成场景收进口袋2.1 常用配置项activeKey、items、mode 等属性设计直接决定组件的易用性上限。我做完一轮用户反馈收集后发现 nav 组件被用得最多的配置项并不多核心的其实就这几个activeKey、items、mode、trigger、expandKeys和router。下面这些是我经过几次迭代后沉淀下来的一份配置表你可以直接作为设计参考。属性名类型默认值说明itemsArray[]菜单数据数组支持多级嵌套activeKeyString/Number当前激活项的唯一标识完全受控defaultActiveKeyString/Number默认激活项只在初始化时生效modevertical / horizontalvertical导航栏的方向模式triggerhover / clickhover子菜单弹出方式expandKeysArray[]展开的子菜单 key 列表仅在 vertical 模式生效routerObjectnull路由实例传入后点击菜单自动跳转disabledBooleanfalse是否禁用整个导航栏其中activeKey我一开始是想做成v-model那样支持父组件直接绑定后来发现直接用v-model:activeKey是 Vue 3 里的标准做法很多 push 到 GitHub 上的组件库也都是这么用的所以没有额外再造轮子。defaultActiveKey和activeKey的区别在于前者只在初始化时读一次后者是完全受控的父组件不更新它就永远不变。这两种模式都保留是因为有些场景需要在初始化时自动高亮某一个菜单但后续不让用户随便改另一些场景则需要选中态跟着页面路由不断变化父组件必须强控制。mode这里我特意提供了 vertical 和 horizontal 两种。横向导航适合页面顶部的全局导航纵向导航适合侧边栏。看起来只是布局切换但实际实现时的差异很大横向导航的子菜单一般以弹出层的方式展示纵向导航的子菜单则以内嵌展开的方式展示。如果你要把这个属性做到底需要单独处理弹出层定位、动画、以及响应式菜单的宽度计算。2.2 选型对比用配置项驱动还是插槽驱动写组件库的时候一直有一个争论到底应该用配置项把一切都指定好还是干脆全用插槽让使用者任意发挥我两种方案都试过最终选择了“配置项为主、插槽为辅”的路线。纯配置项的优点特别明显使用简单、数据可序列化、方便后端配置、也方便写文档做示例。一个最小化的代码是这样template devui-nav :itemsmenu v-model:activeKeycurrent modevertical / /template script setup import { ref } from vue const menu [ { key: dashboard, label: 仪表盘 }, { key: settings, label: 系统设置, children: [ { key: account, label: 账号管理 }, { key: security, label: 安全设置 } ]} ] const current ref(dashboard) /script这个示例里使用者不需要知道组件内部怎么渲染只需要按约定提供数据就可以了这对大多数业务场景来说是效率最高的。但纯配置项的缺点也很明显遇到特殊样式、定制图标、或者菜单项里要放头像、徽标、进度条之类的结构时配置项就非常苍白。比如某项目中要求在每个菜单后面显示一个“新旧版本”的切换按钮这绝对是数据驱动很难解释的奇葩需求。插槽驱动则恰恰相反它几乎可以定制一切但会增加使用者的学习成本而且底部结构的一致性容易失控。如果整个组件完全靠插槽驱动相当于把组件的内部逻辑暴露给使用者组件的价值感会降低很多写出来的代码也会很长还不如直接用a加v-for自己写。所以我的折中方案是整体结构用一个默认插槽或者统一的数据渲染但每一层的菜单项都提供label和item插槽。label插槽负责替换那一个菜单项的文字部分item插槽则可以把整个菜单项都给替换掉。这样一来常规项目只需要写好items配置就够了特殊项目也能找到逃生门不会因为一个广告图需求就推翻整个组件。3. 核心实现从渲染到交互的完整过程3.1 菜单渲染逻辑nav 组件内部最核心的模板结构就是利用 Vue 的递归组件思想来渲染多级菜单。假如用户给了三层菜单组件内部会新建一个子组件NavSubMenu然后在NavSubMenu的模板里继续引用自身直到遇到没有children的子项为止。下面是一个简化版的递归渲染代码基于 Vue 3 的script setup写!-- DevNavItem.vue -- template div classdev-nav-item :class{ active: isActive, disabled } clickhandleClick slot namelabel{{ item.label }}/slot /div /template script setup import { computed } from vue const props defineProps({ item: { type: Object, required: true } }) const isActive computed(() props.item.key props.activeKey) /script子菜单的递归结构类似这样只多了对children的遍历和展开状态控制!-- DevNavSubMenu.vue -- template div classdev-nav-submenu div classsubmenu-title clicktoggle slot nametitle{{ title }}/slot span classarrow :class{ open: isOpen }/span /div div v-showisOpen classsubmenu-content template v-forchild in children :keychild.key DevNavItem v-if!child.children :itemchild / DevNavSubMenu v-else :itemchild / /template /div /div /template这里有一个容易忽视的细节在递归遍历时必须要用v-if和v-else配合来区分叶子节点和分支节点而不是用v-show。因为v-show只是切换 CSS 的display属性如果把它用在递归组件上无效的深度结构也会被渲染进 DOM不仅造成性能浪费还会让样式调试变得特别痛苦。我在第一次写的时候就是偷懒用了v-show结果菜单特别多时页面明显卡顿后来改成v-if之后才好转。菜单渲染的另一个关键点是key的唯一性。如果菜单项本身传的key不唯一Vue 在 diff 时会出现致命警告子菜单的展开状态也会互相串。所以我提供了一个防御性的警告函数在watch里检查items是否存在重复 key一旦发现重复就在开发环境里打印一段明显的提醒。这个函数成本极低但能在项目初期帮使用者避掉一个大坑。3.2 选中态切换与事件派发选中态切换是整个 nav 组件的灵魂我的实现思路是组件内部维护一个currentKey变量当用户点击菜单项时先判断该项是否是禁用状态如果是就直接拦截如果不是就把整个item对象通过select事件抛出去同时触发update:activeKey来支持v-model。关键代码参考如下const props defineProps({ items: { type: Array, required: true }, activeKey: { type: [String, Number], default: } }) const emit defineEmits([update:activeKey, select]) const currentKey ref(props.activeKey) watch(() props.activeKey, (val) { currentKey.value val }) function selectItem(item) { if (item.disabled) return currentKey.value item.key emit(update:activeKey, item.key) emit(select, item, { el: ... }) }这里要特意聊一下受控和非受控的问题。我早期写 nav 的时候为了省事组件内部直接修改activeKey对应的变量结果父组件传来的activeKey根本不会变造成了内外状态不一致的 bug。后来我换成了现在的模式activeKey是父组件的唯一数据源子组件内部只保存一份“当前显示值”并且用watch跟随父组件的变化。这个模式下组件永远忠实地反映外部状态不会出现“你说高亮但我不高亮”的问题。事件派发里有一个容易被忽视的点很多组件库的select事件只给一个 key 字符串但业务里往往需要拿到整条数据比如要跳详情页时光有 key 是不够的。所以我在 emit 时把整个item对象都传了出去这样使用者可以在回调里访问到key、label、url甚至自定义字段。当然如果使用者只关心 key也可以只取第一个参数不冲突。3.3 响应式与路由联动导航栏还有一个很常见的需求在窗口变窄时横向菜单能自动折叠成汉堡菜单点击后弹出抽屉或者下拉面板。这部分我的做法是把断点和布局变化逻辑放在组合式函数composable里而不是硬塞进组件模板。我实现了一个useResponsiveNav工具大致逻辑如下export function useResponsiveNav(breakpoint 768) { const isMobile ref(window.innerWidth breakpoint) const update () { isMobile.value window.innerWidth breakpoint } onMounted(() { update() window.addEventListener(resize, update) }) onBeforeUnmount(() { window.removeEventListener(resize, update) }) return { isMobile } }然后在组件里调用当isMobile变为 true 时把横向排列的菜单切换为图标按钮加弹出抽屉的形态。值得注意的是监听resize事件时要加一个简单的防抖不然用户拖动窗口时组件会高频重算导致页面掉帧。我实测下来150 毫秒的延迟已经足够既不会让人感觉到迟钝也不会被连续触发压垮。路由联动是导航栏另一个绕不开的点。我提供的router属性设计成可选注入传入之后组件内部会在select事件里自动调用router.push(item.url)如果没有传入router组件只抛事件不做任何导航动作。这个方案的好处是使用者在项目里既可以直接绑路由也可以完全手工控制导航逻辑取决于业务是否愿意让组件掺和进来。我在内部实现时判断了传入的router是否具备push方法如果缺少这个能力同样在开发环境里给一句明显的报错提示帮助使用者快速定位问题。另外路由变化后选中态也要跟着更新。这个逻辑我放在组件外处理而不是组件内部是因为组件不知道业务里路由和菜单 key 的映射规则有些公司用路由 path 做 key有些用路由 name有些用后台返回的 id完全没法统一。所以组件只提供watch路由变化时禁止自己改选中态一切由父组件把新的activeKey传进来。这样看起来使用者多写了一行代码但换来的却是极端场景下的强可控性。4. 样式与可访问性细节决定导航栏的体感4.1 主题定制和 CSS 变量设计样式是 nav 组件里最容易被低估的部分。一开始我以为只要把高亮颜色写好就行了后来接了好几个业务方的定制需求才发现每个人想要的导航栏样式都不一样有人要深色背景有人要浅色背景有人希望选中项带底色有人希望只加一条 2px 的下划线还有人希望字体大小可以跟着主题缩放。如果把这些样式全部写成常量然后让使用者重新覆盖维护成本会高得离谱。我最终选择用 CSS 变量来做样式定制。组件内所有颜色、间距、圆角都指向var(--dev-nav-primary-color, #4569d4)这类变量使用者只需要在根节点重新定义变量即可不需要关心组件内部每个类名长什么样。:root { --dev-nav-bg: #ffffff; --dev-nav-text-color: #333333; --dev-nav-hover-bg: rgba(69, 105, 212, 0.06); --dev-nav-active-color: #4569d4; --dev-nav-font-size: 14px; } .dev-nav { background: var(--dev-nav-bg); color: var(--dev-nav-text-color); font-size: var(--dev-nav-font-size); }这里有一个实用技巧在定义 CSS 变量时最好给每个变量都设置一个默认值也就是写在var()的第二个参数里。这样使用者不自定义时组件也能正常显示一旦自定义后所有相关组件会同时刷新不需要去纠结具体的覆盖规则。我在写主题定制文档时也只需要把变量列表列出来使用者自己看着改效果非常直观。另外选中态的表达方式我只固定做了两种颜色高亮和底边线。为什么不用背景色因为背景色在选中态和 hover 态之间容易混淆特别是用户把鼠标悬停在高亮项上时很难看出当前是否已经选中。而底边线配合文字颜色变化能明显区分“可点击”和“当前所在”两种状态。这个体验细节从用户反馈来看效果是相当不错的。4.2 键盘操作、焦点管理和 ARIA 语义导航栏通常位于页面顶部很多用户会使用屏幕阅读器或者纯键盘操作所以可访问性不能只是加分项而是必须项。我在写组件时给容器加上了rolenavigation以及aria-label主导航这样读屏器用户打开页面后能直接知道这是一个导航区域。针对多级菜单展开按钮要加aria-expanded属性来表示当前子菜单是否展开菜单项自身在选中时要加aria-currentpage来标记当前页所在的导航项。这些属性看着不起眼但真到了无障碍审核或者极端用户使用场景差距非常明显。键盘交互方面我实现了一套比较常见的操作约定Tab 键进入导航栏后焦点停留在当前激活项上、下方向键可以在菜单项之间移动焦点Enter 键或空格键确认选择对于子菜单的展开则通过 Enter 或右方向键展开Esc 键收起。实现时不需要复杂的全局事件监听只需要在菜单项的tabindex上做文章当前聚焦项设置为 0其他项设置为 -1然后按键时手动修改焦点。这里要提醒一下使用tabindex-1来管理焦点时不要忘记同步更新aria-activedescendant之类的辅助属性否则读屏器不会感知焦点变化。这是我第一次写完键盘支持后自己用读屏器测试时发现的问题光把焦点移动过去没用还要把可读的状态名同步过去才行。这个经验花了我差不多半天的调试时间。5. 常见问题与排查实录我在写导航栏时踩过的坑5.1 弹层导航的溢出问题横向模式的子菜单弹出层最常见的 bug 是当父容器设置了overflow: hidden或者页面顶部有横向滚动时子菜单被裁剪掉用户根本看不到弹出的部分。第一次遇到这个 bug 时我在某内部项目里调试了半天最后才发现是祖先元素上的overflow: hidden惹的祸。这种情况的解决方案有两种。第一种是最省事的在组件内部使用teleport把弹出层挂到body下面完全避免祖先元素的溢出裁剪。但这么做的代价是弹出层的位置计算需要从原来的 DOM 上下文迁移到组件实例的坐标系统需要额外处理滚动偏移量。第二种是提醒使用者给导航栏外层容器留足够的空间尽量避免设置overflow: hidden。这两种方案我不做强制选择而是看使用场景特别深的头部导航建议用teleport侧边栏导航一般不会遇到这个问题用第二种即可。如果你一定要用teleport我建议把定位逻辑封装成一个独立的useDropdownPosition组合函数内部拿到触发节点的getBoundingClientRect()和当前页面滚动位置计算出弹出层的绝对坐标再应用到teleport的内容上。这样至少不会把一堆定位代码堆在组件里后面维护起来会轻松不少。5.2 路由切换后选中态不同步很多接触过 nav 组件的人应该都经历过这个场景点击菜单跳转路由页面内容变化了但菜单高亮还停在上一次点击的位置。最常见的原因是父组件没有监听路由变化并把新的activeKey传给 nav 组件。当初我在设计组件的时候故意把路由联动逻辑对外放开就是希望由父组件来决定 key 的映射规则。排查这个问题的思路很简单先在浏览器控制台打印当前路由变化事件再打印 nav 组件收到的activeKey值。如果路由变了但activeKey没变问题多半出在父组件没有正确监听路由如果activeKey变了但高亮没变那就要检查传给组件的items是否和activeKey对应得上尤其是后台返回的数据里 key 字段可能混入了字符串和数字类型。例如接口返回key: 1而你传activeKey: 1在严格比较下就会认为不是同一个 key高亮自然就不生效。我在组件内部做 key 比较时统一转成字符串来比较算是把这个暗坑直接给填上了。5.3 异步数据更新后选中态丢失nav 组件里还有一类高频场景是异步菜单页面加载后先等接口返回权限菜单再渲染导航栏但此时默认选中的菜单还没加载完毕导致初始化时没有高亮。这个问题的根源是defaultActiveKey只生效了一次而菜单数据在后续某个时刻才填充进来。我的处理方式是把defaultActiveKey与定时器、显式调用结合起来。组件提供refresh()方法使用者在异步数据到位后主动调用一次重新读取当前的activeKey并刷新高亮。第二种方案是使用watch监听items的变化当items从空数组变成有数据时自动把最后一次设置的默认 key 应用上去。两种方案我倾向于第二种因为异步数据到位后主动刷新这个动作很多使用者总会忘记写而组件自己监听items变化就能把这个环节自动化。但注意不要监听得太深items是嵌套数组如果使用深监听deep: true在数据量大时性能会很差。更好的做法是监听items值的整体替换而不是内部某个字段的变化。也就是说只要父组件不原地 push 数据而是整体给一个数组浅监听就够了。我特意在文档里写了一句不要修改items数组的内部项要整体替换数组否则无法保证选中态同步。5.4 移动端点击延迟与 hover 误触发最后一个常见问题是移动端兼容。用户在手机上点击横向导航的子菜单时如果触发方式是triggerhover手指一碰就可能同时触发 hover 和 click 两个状态造成菜单闪一下又关掉的诡异现象。这个问题在很多设备上都出现过特别是部分 Android 浏览器把点击事件视作 touch 加 hover 的组合。解决办法是在组件内部做一次环境判断如果当前设备被识别为触摸设备就自动把trigger强制切换为click并且禁用 hover 相关的 CSS 效果。判断方法可以使用window.matchMedia((hover: none))匹配触摸设备。这个方法比用户代理字符串检测靠谱得多而且和真实的交互能力对齐。我实际测试下来能覆盖绝大多数手机和平板包括一些在触屏笔记本电脑上的特殊情况。这个兼容逻辑虽然只有几行代码但却是移动端体验的分水岭。最后再分享一个小技巧。如果你写的导航栏要同时支持横向和纵向两套布局我建议不要通过 CSS 去切换两套完全不同的 DOM 结构而是在模板里保留两种渲染分支用mode快速切换。这样虽然模板稍显冗余但每个分支里的样式和交互都更纯粹也更好维护。我在这个系列从第一版写到现在nav 组件前后重构过三次最核心的体会就是组件不怕多写几行分支怕的是把所有逻辑挤在一个分支里然后试图用 if 硬撑。把导航栏的职责理清把事件和数据边界划好后面所有需求都会自然而然地变简单。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑