第十五章 - 动态导航菜单 UI 渲染
第十五章 - 动态导航菜单 UI 渲染
Prorise第十五章 Nav 组件构建:从样式到状态
在第十四章,我们完成了 Collapsible 和 Tooltip 两个 UI 依赖的构建。现在,所有的 “积木” 已经准备就绪,是时候开始组装我们的第一个复杂业务组件了:垂直导航菜单 (NavVertical)。
本章将是一次组件组合能力的集中检验。我们将整合:
- 数据流(
useNavDataHook - 第十三章) - 交互组件(
Collapsible- 第十四章) - 提示组件(
Tooltip- 第十四章) - 样式系统(vanilla-extract + CVA - 第六、八章)
- 状态管理(Zustand - 待扩展)
15.1 构建基础样式与变体
在开始构建组件之前,我们需要先为 NavItem(导航项)建立样式基础。这包括两部分:子元素的精细样式和根元素的状态变体。
15.1.1. 架构决策:vanilla-extract 与 CVA 的职责分离
我们的项目使用 vanilla-extract 作为主题系统的核心(见第六章),它提供了类型安全的编译时 CSS 生成能力。同时,我们在第八章建立了 CVA 作为组件变体管理的标准模式。
对于 Nav 组件的样式,我们将这两者进行明确分工:
nav-item.css.ts: 使用 vanilla-extract 的style函数,负责定义NavItem内部各个子元素(图标、标题、副标题等)的精细样式。这些样式与主题系统深度集成,具备类型安全。nav-item.variants.ts: 使用 CVA,负责定义NavItem根元素 的状态变体(如active,disabled)。这使得状态管理声明化,并与Button等其他组件保持架构一致性。
这种分离确保了每个文件都有单一的职责,提高了代码的可维护性。
15.1.2. 创建文件与目录结构
首先创建 Nav 组件的样式相关文件:
1 | mkdir -p src/layouts/dashboard/nav/vertical |
我们的目标文件结构将是:
1 | . 📂 nav |
15.1.3. 构建子元素样式 - nav-item.css.ts
这个文件只包含用 vanilla-extract 定义的、与状态无关的子元素样式。
步骤 1:导入依赖与文件骨架
文件路径: src/layouts/dashboard/nav/vertical/nav-item.css.ts
1 | import { style } from '@vanilla-extract/css'; |
我们只需要导入 vanilla-extract 的 style 函数和我们的主题变量 themeVars。
步骤 2:定义图标和文本容器样式
NavItem 的基础布局由三部分组成:图标、文本区(包含标题和副标题)、可选的徽章。
1 | export const navItemStyles = { |
关键点:
flexShrink: 0确保图标不会被压缩。flex: '1 1 auto'让文本容器占据剩余空间。
步骤 3:定义标题、副标题和辅助元素样式
继续添加标题 (title)、副标题 (caption)、徽章 (info) 和箭头 (arrow) 的样式,并与主题令牌深度集成。
1 | export const navItemStyles = { |
架构价值:
- 输入
themeVars.时,IDE 会提供类型安全的自动补全,这是 vanilla-extract 的核心优势。 - 间距、颜色、字体均使用主题变量,确保了全局视觉一致性。
15.1.4. 构建根元素状态变体 - nav-item.variants.ts
现在,我们在一个独立的文件中处理所有与状态相关的样式。
步骤 1:导入依赖与文件骨架
文件路径: src/layouts/dashboard/nav/vertical/nav-item.variants.ts
1 | import { cva } from 'class-variance-authority'; |
这个文件只依赖 CVA。
步骤 2:定义根元素的基础样式
我们使用 CVA 定义 NavItem 根元素的共享基础样式。这些是原子化的 CSS 类名,通常来自 Tailwind CSS。
1 | export const navItemVariants = cva( |
步骤 3:添加 active 和 disabled 状态变体
现在为 CVA 添加 active(激活)和 disabled(禁用)变体:
1 | export const navItemVariants = cva( |
步骤 4:处理复合变体与默认值
最后,处理多个状态同时出现的边缘情况(例如,激活且禁用),并设置默认值。
1 | export const navItemVariants = cva( |
完整的变体逻辑:
navItemVariants({ active: false, disabled: false })→ 正常状态navItemVariants({ active: true, disabled: false })→ 激活状态navItemVariants({ active: false, disabled: true })→ 禁用状态navItemVariants({ active: true, disabled: true })→ 激活且禁用(复合变体生效)
15.2 构建链接渲染器 - NavItemRenderer
在定义好样式基础后,我们需要解决一个核心问题:如何正确渲染不同类型的导航项?
15.2.1. 渲染需求分析
导航项并非总是路由链接,它可能处于三种状态:
状态 1:禁用状态
导航项被标记为禁用时,应该渲染为普通的 <div>,不可点击:
1 | // 期望: |
状态 2:可展开状态
导航项包含子项时,点击应该展开/折叠子菜单,而非跳转路由:
1 | // 期望: |
状态 3:路由链接状态
导航项是叶子节点时,应该渲染为可导航的链接:
1 | // 期望: |
这三种状态需要不同的 HTML 元素和交互逻辑,但它们的 视觉呈现(className 和 children)是相同的。因此,我们需要一个适配器组件来处理这种多态渲染。
15.2.2. 定义类型契约
在创建渲染器之前,我们需要先定义导航项的数据结构。
步骤 1:创建类型文件
文件路径: src/layouts/dashboard/nav/vertical/types.ts
1 | touch src/layouts/dashboard/nav/vertical/types.ts |
步骤 2:定义数据、状态和选项类型
我们将导航项的 Props 分为数据、状态和选项三类,最后组合起来。
1 | // src/layouts/dashboard/nav/vertical/types.ts |
这个类型组合实现了:
- 继承原生
div的所有属性(className、onClick等) - 包含数据字段(
path、title等) - 包含状态标记(
active、disabled等) - 包含选项字段(
depth、hasChild等)
15.2.3. 创建渲染器组件
现在我们基于类型契约创建 NavItemRenderer,它的职责是根据导航项的状态选择合适的 HTML 元素。
步骤 1:创建文件与基础结构
文件路径: src/layouts/dashboard/nav/vertical/NavItemRenderer.tsx
1 | touch src/layouts/dashboard/nav/vertical/NavItemRenderer.tsx |
先定义组件的 Props 类型:
1 | // src/layouts/dashboard/nav/vertical/NavItemRenderer.tsx |
设计思路:
item:提供完整的导航项信息,用于判断渲染逻辑className:由父组件传入的样式,保持渲染器的样式无关性children:具体的内容节点(图标、标题等),渲染器只负责包裹
步骤 2:创建路由链接适配器
对于路由链接,我们先创建一个适配器组件,将 React Router 的 <Link> 封装为统一的接口。
文件路径: src/routes/components/router-link.tsx
1 | touch src/routes/components/router-link.tsx |
这个组件将 to prop 改名为更语义化的 href:
1 | // src/routes/components/router-link.tsx |
步骤 3:完成渲染器逻辑
现在回到 NavItemRenderer,添加完整的渲染逻辑:
1 | // src/layouts/dashboard/nav/vertical/NavItemRenderer.tsx |
15.2.4. 架构优势
这个渲染器组件体现了几个关键设计原则:
- 单一职责: 渲染器只负责决定使用哪个 HTML 元素,不关心样式、内容和状态管理。
- 开闭原则: 如果未来需要支持外部链接,只需添加一个新的
if分支,而无需修改现有代码。 - 类型安全: TypeScript 确保了所有必需的 props 都被正确传递。
15.3 构建原子组件 - nav-item.tsx
在完成样式(15.1)和渲染器(15.2)后,现在我们开始组装 NavItem 组件。这是导航菜单的原子单元,负责渲染单个导航项的完整视觉呈现。
15.3.1. 组件职责分析
NavItem 需要完成三个核心任务:
- 内容组装: 将数据字段(图标、标题、副标题等)渲染为具体的 DOM 结构。
- 样式应用: 使用
nav-item.css.ts和nav-item.variants.ts为各元素应用正确样式。 - 渲染逻辑委托: 将组装好的内容和样式传递给
NavItemRenderer,由它决定最终渲染为<div>还是<Link>。
15.3.2. 渐进式构建组件
步骤 1:创建文件与基础结构
文件路径: src/layouts/dashboard/nav/vertical/nav-item.tsx
1 | touch src/layouts/dashboard/nav/vertical/nav-item.tsx |
建立组件的基础框架,并从我们新创建的样式文件中导入:
1 | // src/layouts/dashboard/nav/vertical/nav-item.tsx |
步骤 2:组装内容 (content)
我们将导航项的所有视觉元素(图标、文本、徽章、箭头)组装到一个 content 变量中。
1 | export function NavItem(item: NavItemProps) { |
架构价值:这里复用了我们在第十四章构建的 Tooltip 组件,展示了组件组合的威力。
步骤 3:计算根元素样式并渲染
使用 CVA 变体函数 navItemVariants 计算根元素的 className,然后将所有内容传递给 NavItemRenderer。
1 | export function NavItem(item: NavItemProps) { |
组合层次:
1 | NavItem (原子组件) |
15.3.4. 与现有组件的集成
这个 NavItem 组件完美地集成了我们之前构建的所有"积木":
| 依赖组件 | 来源章节/文件 | 用途 |
|---|---|---|
Icon | 第七章 | 渲染 Iconify 图标 |
Tooltip | 第十四章 | 副标题悬浮提示 |
navItemVariants | 15.1 (nav-item.variants.ts) | CVA 状态变体 |
navItemStyles | 15.1 (nav-item.css.ts) | vanilla-extract 子元素样式 |
NavItemRenderer | 15.2 | 多态渲染逻辑 |
cn | 第九章 | 类名合并工具 |
这就是组件驱动开发(CDD)的威力:每个组件都是可独立测试、可复用的单元,最终组合成复杂的 UI。
15.4 构建递归核心 - NavList
现在我们进入本章的核心部分。NavList 是整个导航系统的"大脑",它负责:
- 管理单个导航项的展开/折叠状态
- 基于当前路由自动判断
active状态 - 递归渲染子项(这是关键!)
- 处理
hidden等边缘情况
15.4.1. 理解递归渲染的必要性
问题场景:考虑以下菜单结构
1 | Dashboard (一级) |
这是一个典型的树形结构,深度不固定。如果用传统的循环渲染,我们需要写:
1 | // ❌ 问题方案:只能处理固定层级 |
这种方案有两个致命缺陷:
- 无法处理动态深度:如果菜单有 4 层、5 层,代码会失控。
- 违反 DRY 原则:每一层都在重复相同的渲染逻辑。
解决方案:递归组件
递归组件是指 组件在自己的渲染逻辑中调用自己。这让我们可以用一个组件处理任意深度的树形结构:
1 | function NavList({ data, depth }) { |
这种模式的优雅之处在于:
- 一次定义,无限深度:无论菜单有多少层,逻辑都是一致的。
- 符合数据结构:树形数据用树形组件渲染,天然契合。
- 易于维护:修改一处,所有层级同步更新。
15.4.2. 定义类型契约
在实现递归组件之前,我们先来定义一下他的它的类型
1 | // src/layouts/dashboard/nav/vertical/types.ts |
设计理念:
data:传入单个导航项,而非数组。这让组件职责更清晰。depth:可选,默认为 1(一级菜单),每次递归时+1。
15.4.3. 渐进式构建 NavList
步骤 1:创建文件与基础结构
文件路径: src/layouts/dashboard/nav/vertical/nav-list.tsx
1 | touch src/layouts/dashboard/nav/vertical/nav-list.tsx |
先搭建组件骨架,引入必要的依赖:
1 | // src/layouts/dashboard/nav/vertical/nav-list.tsx |
依赖说明:
Collapsible:来自第十四章,用于管理展开/折叠动画。useLocation:React Router 提供,用于获取当前路由。useState:管理本地展开状态。
步骤 2:实现路由匹配逻辑
导航项的 active 状态应该由"当前路由是否匹配该项的 path"决定。
1 | export function NavList({ data, depth = 1 }: NavListProps) { |
设计解析:
使用 includes 而非 === 的原因:
1 | // 假设 data.path = "/management" |
这符合常见的导航交互:当你在"用户管理"页面时,"Management"父级也应该保持激活状态。
步骤 3:管理展开/折叠状态
展开状态应该由两个因素决定:
- 初始状态:如果当前路由匹配,默认展开。
- 用户交互:点击时切换。
1 | export function NavList({ data, depth = 1 }: NavListProps) { |
状态管理要点:
useState(isActive):初始值基于路由,确保激活路径默认展开。handleClick只在hasChild为true时生效,避免叶子节点被误点击。
步骤 4:处理 hidden 边缘情况
有些菜单项可能被权限系统标记为 hidden,我们需要提前返回 null。
1 | export function NavList({ data, depth = 1 }: NavListProps) { |
这个检查应该在最终渲染之前,避免执行不必要的计算和副作用。
步骤 5:完整渲染逻辑(含递归)
现在我们将所有逻辑整合,并实现递归渲染:
1 | export function NavList({ data, depth = 1 }: NavListProps) { |
15.4.4. 递归逻辑深度解析
让我们用一个具体例子跟踪递归过程:
数据输入:
1 | { |
递归执行流程:
1 | 第 1 次调用 <NavList data={Management} depth={1} /> |
关键观察:
- 每次递归,
depth都会+1,可用于样式缩进。 - 递归的终止条件是
hasChild === false,即叶子节点。 - 每个
NavList实例都维护自己的open状态,互不干扰。
15.4.5. 样式细节:缩进与间距
注意 CollapsibleContent 内部的样式:
1 | <div className="ml-4 mt-1 flex flex-col gap-1"> |
ml-4:左侧外边距,创建视觉缩进。mt-1:顶部间距,与父项分离。flex flex-col gap-1:垂直布局,子项之间有间隙。
这些细节营造了清晰的层级视觉关系。
15.4.6. 架构优势总结
NavList 的递归设计体现了几个软件工程原则:
- 分治策略:将"渲染整棵树"分解为"渲染当前节点 + 渲染子树"。
- 状态隔离:每个节点的展开状态独立管理,避免状态污染。
- 可扩展性:未来添加拖拽排序、权限过滤,只需在递归逻辑中插入对应处理。
- 性能优化预留:可以在递归入口添加
React.memo,避免不必要的深度重渲染。
15.5 构建分组容器 - NavGroup
在完成了 NavList 的递归核心后,我们现在需要构建一个更上层的组件:NavGroup。它的职责是将导航项按照业务逻辑分组,并为每个分组提供一个可折叠的标题。
15.5.1. 理解分组的必要性
问题场景:在企业级应用中,导航菜单通常会按功能模块分组:
1 | 📁 Dashboard (分组 1) |
如果没有分组,所有菜单项会挤在一起,用户难以快速定位。NavGroup 通过视觉分隔和语义化标题,提升了导航的可用性。
关键特性:
- 可折叠的分组标题:用户可以收起不常用的分组,减少视觉干扰。
- 国际化支持:分组标题需要支持多语言切换。
- 默认展开:初始状态应该是展开的,方便用户浏览。
15.5.2. 定义类型契约
在实现组件之前,先明确它的数据结构。
1 | // src/layouts/dashboard/nav/vertical/types.ts |
设计理念:
name:可选字段。有些分组可能不需要标题(如首页的快捷入口)。items:导航项数组。每个项会被传递给NavList进行递归渲染。
15.5.3. 渐进式构建 NavGroup
步骤 1:创建文件与基础结构
文件路径: src/layouts/dashboard/nav/vertical/nav-group.tsx
1 | touch src/layouts/dashboard/nav/vertical/nav-group.tsx |
先搭建组件骨架:
1 | // src/layouts/dashboard/nav/vertical/nav-group.tsx |
依赖说明:
Collapsible:用于实现分组的展开/折叠功能。NavList:递归渲染每个导航项。
步骤 2:管理折叠状态
分组的展开状态应该默认为 true,允许用户手动切换。
1 | import { useState } from 'react'; |
设计解析:
- 使用
useState(true)而非useState(false):初始状态展开,用户能立即看到所有菜单。 toggleOpen函数:作为点击处理器传递给标题组件。
步骤 3:构建分组标题组件
分组标题需要具备以下特性:
- 显示分组名称(后续支持国际化)
- 显示展开/折叠指示箭头
- 悬浮时提供视觉反馈
我们将标题抽取为一个独立的子组件,保持 NavGroup 的简洁。
src/layouts/dashboard/nav/vertical/components/GroupTitle.tsx
1 | import { Icon } from '@/components/icons/Icon'; |
设计亮点:
- 渐进式交互:箭头默认隐藏(
opacity-0),hover 时淡入(group-hover:opacity-100),减少视觉噪音。 - 动态定位:
absolute left-[-4px]让箭头超出容器边界,创造"从外部滑入"的效果。 - 状态同步:
rotate-90在open为true时生效,视觉上明确表达当前状态。
步骤 4:完整渲染逻辑
现在我们将标题和内容区整合到 Collapsible 结构中:
1 | export function NavGroup({ name, items }: NavGroupProps) { |
架构要点:
- 条件渲染:
{name && ...}确保无标题的分组不渲染触发器。 asChild模式:让CollapsibleTrigger使用GroupTitle作为触发元素,而非额外包装一层 DOM。key策略:优先使用item.title,回退到index,确保列表稳定性。depth={1}:分组是最外层容器,其下的导航项从第 1 层开始计数。
15.5.4. 组件层次关系
现在我们可以看到完整的组件树:
1 | NavGroup (分组容器) |
这种层次清晰的结构使得每个组件都专注于单一职责:
NavGroup:管理分组级别的折叠状态GroupTitle:处理标题的视觉呈现和交互NavList:处理树形导航的递归逻辑NavItem:处理单个导航项的渲染
15.5.5. 边缘情况处理
让我们考虑几个边缘情况:
情况 1:空分组
1 | <NavGroup name="Empty Group" items={[]} /> |
渲染结果:标题可见,但内容区为空。这是合理的,用户会知道这个分组暂时没有内容。
情况 2:无标题分组
1 | <NavGroup items={[...]} /> |
渲染结果:直接显示导航项列表,无折叠功能。适用于"快捷入口"等场景。
情况 3:单项分组
1 | <NavGroup name="Single" items={[{ title: "Home", path: "/" }]} /> |
渲染结果:正常显示,虽然只有一项,但分组结构保持一致性。
15.5.6. 架构优势
NavGroup 的设计体现了几个关键原则:
- 组合优于继承:通过组合
Collapsible、NavList等现有组件,而非从头构建。 - 关注点分离:标题、折叠逻辑、列表渲染各自独立。
- 灵活性:
name可选,支持有标题和无标题两种模式。 - 可扩展性:未来添加"全部展开/收起"等全局操作,只需在此层注入逻辑。
15.6 构建主容器 - NavVertical
现在我们已经构建了导航系统的所有核心部分,是时候创建最外层的容器组件了。NavVertical 的职责非常简单:接收数据,遍历渲染所有 NavGroup。
15.6.1. 组件职责分析
NavVertical 是一个"哑组件"(Presentational Component),它只负责:
- 接收数据:从
useNavData()获取的分组数据。 - 遍历渲染:将每个分组传递给
NavGroup。 - 布局样式:定义导航菜单的整体布局(垂直排列、间距等)。
它不关心:
- 数据从哪里来(由父组件决定)
- 数据如何转换(已在
useNavData完成) - 导航项如何渲染(由
NavGroup→NavList→NavItem处理)
这种"无状态容器"的设计使得组件极易测试和复用。
15.6.2. 定义类型契约
1 | // src/layouts/dashboard/nav/vertical/types.ts |
设计理念:
- 继承
<nav>的所有原生属性(className、aria-label等),增强可访问性。 data是分组数组,与useNavData()的返回值类型完全匹配。
15.6.3. 完整实现
文件路径: src/layouts/dashboard/nav/vertical/nav-vertical.tsx
1 | touch src/layouts/dashboard/nav/vertical/nav-vertical.tsx |
组件实现非常简洁:
1 | // src/layouts/dashboard/nav/vertical/nav-vertical.tsx |
15.6.4. 代码解析
让我们逐行分析这个看似简单的实现:
1. Props 解构
1 | { data, className, ...props } |
data:必需,导航数据。className:可选,允许父组件注入自定义样式。...props:透传其他<nav>原生属性(如aria-label)。
2. 样式组合
1 | className={cn('flex w-full flex-col gap-1', className)} |
- 基础样式:
flex w-full flex-col gap-1flex flex-col:垂直布局w-full:占满父容器宽度gap-1:分组之间有 4px 间距(Tailwind 的gap-1= 0.25rem = 4px)
cn():确保外部传入的className能正确合并或覆盖基础样式。
3. 列表渲染
1 | {data.map((group, index) => ( |
key策略:优先使用group.name,如果分组无名称则使用index。- 直接透传
name和items,保持数据流清晰。
15.6.5. 使用示例
1 | import { useNavData } from '@/hooks/menu/useNavData'; |
15.6.6. 架构优势
NavVertical 的简洁设计带来了几个优势:
高内聚,低耦合
- 只依赖
NavGroup,不关心更深层的实现。 - 数据结构变化时,只需修改
NavGroup,不影响此层。
- 只依赖
易于测试
1
2
3
4
5
6
7
8// 测试用例:渲染正确数量的分组
const mockData = [
{ name: 'Group 1', items: [...] },
{ name: 'Group 2', items: [...] },
];
render(<NavVertical data={mockData} />);
expect(screen.getAllByRole('group')).toHaveLength(2);样式可控
1
2
3
4
5// 外部可注入自定义样式
<NavVertical
data={navData}
className="px-4 py-2" // 添加内边距
/>语义化 HTML
- 使用
<nav>标签,而非<div>,增强可访问性。 - 支持 ARIA 属性,辅助技术(如屏幕阅读器)能正确识别导航区域。
- 使用
15.6.7. 为什么不在此层处理数据获取?
你可能会问:为什么不在 NavVertical 内部调用 useNavData(),而是通过 props 传入?
反例(紧耦合设计):
1 | // ❌ 不推荐:组件直接依赖数据源 |
问题:
- 测试困难:无法 mock 数据,必须搭建完整的数据流。
- 复用受限:如果要用其他数据源(如静态配置),需要修改组件。
- 责任混乱:组件既负责"如何渲染",又负责"数据从哪来"。
当前设计(依赖注入):
1 | // ✓ 推荐:数据通过 props 注入 |
优势:
- 关注点分离:
NavVertical只关心渲染,数据获取由父组件负责。 - 高度可测:可以传入任意 mock 数据。
- 灵活复用:可以轻松切换数据源。
15.7 创建统一导出文件
为了方便外部使用,我们需要创建一个统一的导出文件。
文件路径: src/layouts/dashboard/nav/vertical/index.tsx
1 | touch src/layouts/dashboard/nav/vertical/index.tsx |
1 | // src/layouts/dashboard/nav/vertical/index.tsx |
使用示例:
1 | // 外部文件可以这样导入 |
15.8 集成验证:在 DashboardLayout 中使用
现在所有组件都已就绪,是时候将它们集成到实际的布局中了。
15.8.1. 回顾数据流
在开始集成之前,让我们回顾一下完整的数据流:
1 | ┌─────────────────────────────────────────┐ |
15.8.2. 修改 DashboardLayout
打开第 12 章创建的 DashboardLayout 文件:
文件路径: src/layouts/dashboard/index.tsx
找到侧边栏的占位符代码,替换为真实的导航组件:
1 | // src/layouts/dashboard/index.tsx |
15.8.3. 关键集成点解析
1. 数据获取
1 | const navData = useNavData(); |
- 调用我们在第 13 章创建的 Hook。
- 自动处理:API 请求 → 状态同步 → 数据转换。
- 组件只需关心"使用数据",不关心"数据从哪来"。
2. ScrollArea 包裹
1 | <ScrollArea className="h-[calc(100vh-var(--layout-header-height))] px-2"> |
- 高度计算:
100vh - header高度,确保导航区域占满剩余空间。 - 内边距:
px-2让导航项与容器边缘有间距。 - 滚动溢出:当菜单项过多时,自动显示滚动条。
3. CSS 变量复用
1 | style={{ width: 'var(--layout-nav-width)' }} |
- 复用第 12 章在
:root定义的全局变量。 - 保证布局尺寸的一致性。
15.8.4. 完整验证流程
现在启动开发服务器,验证完整功能:
1 | pnpm dev |
预期效果:
数据加载
- 打开浏览器控制台,查看 Network 标签。
- 应该能看到
/api/menu/list的请求(MSW 拦截)。 - React Query DevTools 中能看到缓存的数据。
菜单渲染
- 侧边栏显示完整的导航树。
- 分组标题可见(如 “Dashboard”、“Management”)。
- 导航项正确显示图标和标题。
交互功能
- 点击分组标题,能展开/收起。
- hover 导航项,背景色变化。
- 点击叶子节点(如 “Dashboard”),路由跳转。
激活状态
- 当前路由对应的导航项高亮显示。
- 父级导航项也应该高亮(如
/management/user下,“Management” 也高亮)。
递归结构
- 多级菜单正确缩进(每层
ml-4)。 - 展开/折叠动画流畅(Collapsible 的 CSS 过渡)。
- 多级菜单正确缩进(每层
15.8.5. 常见问题排查
问题 1:导航菜单不显示
1 | // 检查数据是否加载 |
问题 2:点击导航项不跳转
1 | // 检查 NavItemRenderer 的逻辑 |
问题 3:激活状态不正确
1 | // 检查 NavList 中的路由匹配逻辑 |
问题 4:样式不生效
1 | // 检查 vanilla-extract 是否正确编译 |
15.9 本章小结
在本章中,我们完成了一个功能完整、架构清晰的垂直导航系统。让我们回顾关键成果:
构建成果
1. 样式系统(15.1)
nav-item.css.ts:vanilla-extract 子元素样式nav-item.variants.ts:CVA 状态变体- 职责分离:类型安全 + 声明式变体管理
2. 渲染策略(15.2)
NavItemRenderer:多态渲染适配器RouterLink:React Router 封装- 单一职责:只负责选择 HTML 元素
3. 原子组件(15.3)
NavItem:导航项的视觉呈现- 组合了 Icon、Tooltip 等子组件
- 样式应用 + 内容组装 + 渲染委托
4. 递归核心(15.4)
NavList:处理树形结构的递归渲染- 路由匹配、状态管理、深度追踪
- 分治策略 + 状态隔离
5. 分组容器(15.5)
NavGroup:按功能模块分组- 可折叠标题 + 导航项列表
- 渐进式交互设计
6. 主容器(15.6)
NavVertical:无状态的组合容器- 数据注入 + 遍历渲染
- 高内聚、低耦合
7. 集成验证(15.8)
- 连接数据流(useNavData)
- 集成到 DashboardLayout
- 完整功能验证
架构亮点
1. 清晰的组件层次
1 | NavVertical (主容器) |
2. 职责分离
- 每个组件只做一件事
- 样式、逻辑、渲染分离
- 易于测试和维护
3. 数据驱动
- 组件不关心数据来源
- 通过 props 注入数据
- 支持任意数据源
4. 递归设计
- 一次定义,处理无限深度
- 符合树形数据结构
- 状态独立,互不干扰
5. 类型安全
- TypeScript 全覆盖
- vanilla-extract 类型安全
- 编译时错误检查
技术整合
本章整合了前面所有章节的成果:
| 技术 | 章节 | 用途 |
|---|---|---|
| vanilla-extract | 第 5-6 章 | 主题系统、子元素样式 |
| CVA | 第 8 章 | 状态变体管理 |
| Tailwind CSS | 第 4 章 | 原子化样式 |
| React Router | 第 11 章 | 路由匹配、导航 |
| Zustand | 第 6 章 | 全局菜单状态 |
| React Query | 第 13 章 | 服务端状态管理 |
| MSW | 第 13 章 | API Mock |
| Collapsible | 第 14 章 | 折叠动画 |
| Tooltip | 第 14 章 | 悬浮提示 |
| Icon | 第 10 章 | 图标系统 |
| ScrollArea | 第 12 章 | 滚动容器 |
下一步预告
在第 16 章,我们将继续完善导航系统:
- 16.1 Mini 模式支持:折叠导航栏,只显示图标
- 16.2 Horizontal 布局:顶部水平导航
- 16.3 Mobile 适配:响应式导航抽屉
- 16.4 权限过滤:基于用户权限动态过滤菜单
- 16.5 性能优化:React.memo、虚拟滚动













