第十五章 - 动态导航菜单 UI 渲染

第十五章 Nav 组件构建:从样式到状态

在第十四章,我们完成了 CollapsibleTooltip 两个 UI 依赖的构建。现在,所有的 “积木” 已经准备就绪,是时候开始组装我们的第一个复杂业务组件了:垂直导航菜单 (NavVertical)

本章将是一次组件组合能力的集中检验。我们将整合:

  • 数据流(useNavData Hook - 第十三章)
  • 交互组件(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
2
3
mkdir -p src/layouts/dashboard/nav/vertical
touch src/layouts/dashboard/nav/vertical/nav-item.css.ts
touch src/layouts/dashboard/nav/vertical/nav-item.variants.ts

我们的目标文件结构将是:

1
2
3
4
5
6
. 📂 nav
├── 📄 index.tsx
├── 📄 nav-vertical.tsx
└── 📂 vertical/
├── 📄 nav-item.css.ts # (vanilla-extract) 子元素样式
└── 📄 nav-item.variants.ts # (CVA) 根元素状态变体

15.1.3. 构建子元素样式 - nav-item.css.ts

这个文件只包含用 vanilla-extract 定义的、与状态无关的子元素样式。

步骤 1:导入依赖与文件骨架

文件路径: src/layouts/dashboard/nav/vertical/nav-item.css.ts

1
2
import { style } from '@vanilla-extract/css';
import { themeVars } from '@/theme/theme.css';

我们只需要导入 vanilla-extract 的 style 函数和我们的主题变量 themeVars

步骤 2:定义图标和文本容器样式

NavItem 的基础布局由三部分组成:图标、文本区(包含标题和副标题)、可选的徽章。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
export const navItemStyles = {
// 图标容器:固定尺寸,居中对齐
icon: style({
display: 'inline-flex',
flexShrink: 0, // 不允许收缩
width: 22,
height: 22,
justifyContent: 'center',
alignItems: 'center',
}),

// 文本容器:占据剩余空间,垂直布局
texts: style({
display: 'inline-flex',
flexDirection: 'column',
justifyContent: 'center',
flex: '1 1 auto', // 自动伸缩填充
}),
};

关键点

  • flexShrink: 0 确保图标不会被压缩。
  • flex: '1 1 auto' 让文本容器占据剩余空间。

步骤 3:定义标题、副标题和辅助元素样式

继续添加标题 (title)、副标题 (caption)、徽章 (info) 和箭头 (arrow) 的样式,并与主题令牌深度集成。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
export const navItemStyles = {
icon: style({ /* ... */ }),
texts: style({ /* ... */ }),

// 主标题:单行截断,使用主题字体
title: style({
display: '-webkit-box',
WebkitBoxOrient: 'vertical',
WebkitLineClamp: 1, // 单行显示
overflow: 'hidden',
textOverflow: 'ellipsis', // 超出显示省略号
fontSize: themeVars.typography.fontSize.sm,
fontWeight: themeVars.typography.fontWeight.medium,
lineHeight: themeVars.typography.lineHeight.normal,
textAlign: 'left',
}),

// 副标题:更小的字号,禁用状态的文本颜色
caption: style({
display: '-webkit-box',
WebkitLineClamp: 1,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
textOverflow: 'ellipsis',
fontSize: themeVars.typography.fontSize.xs,
fontWeight: themeVars.typography.fontWeight.normal,
color: themeVars.colors.text.disabled.value,
lineHeight: themeVars.typography.lineHeight.normal,
textAlign: 'left',
}),

// Info 徽章容器
info: style({
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
marginLeft: themeVars.spacing[2], // 使用主题间距
}),

// 箭头图标(用于展开/折叠)
arrow: style({
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
width: 16,
height: 16,
marginLeft: themeVars.spacing[2],
transition: 'transform 0.3s ease-in-out',
}),
};

架构价值

  • 输入 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
export const navItemVariants = cva(
// 基础样式数组(所有状态共享)
[
'inline-flex', // 内联弹性布局
'w-full', // 宽度 100%
'items-center', // 垂直居中
'rounded-md', // 中等圆角
'px-2', // 水平内边距
'py-1.5', // 垂直内边距
'text-sm', // 小号文字
'transition-all', // 所有属性过渡
'duration-300', // 动画时长
'ease-in-out', // 缓动函数
],
{
// 变体定义将在这里添加
},
);

步骤 3:添加 activedisabled 状态变体

现在为 CVA 添加 active(激活)和 disabled(禁用)变体:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
export const navItemVariants = cva(
[ /* 基础样式 */ ],
{
variants: {
// 激活状态变体
active: {
true: [
'bg-primary/10', // 主题色 10% 透明度背景
'hover:bg-primary/15', // hover 时稍深
'text-primary', // 文字使用主题色
],
false: [
'text-text-secondary', // 默认次要文本颜色
'hover:bg-action-hover', // hover 时浅色背景
],
},

// 禁用状态变体
disabled: {
true: [
'cursor-not-allowed', // 禁用光标
'hover:bg-transparent', // 取消 hover 背景
'text-action-disabled', // 禁用文本颜色
'opacity-60', // 降低不透明度
],
false: [
'cursor-pointer', // 正常光标
],
},
},
},
);

步骤 4:处理复合变体与默认值

最后,处理多个状态同时出现的边缘情况(例如,激活且禁用),并设置默认值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
export const navItemVariants = cva(
[ /* 基础样式 */ ],
{
variants: {
active: { /* ... */ },
disabled: { /* ... */ },
},

// 复合变体:处理状态组合
compoundVariants: [
{
active: true,
disabled: true,
// 禁用状态覆盖激活状态
class: 'bg-transparent text-action-disabled',
},
],

// 默认值
defaultVariants: {
active: false,
disabled: false,
},
},
);

完整的变体逻辑

  • 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
// 期望:
<div className="...">Dashboard</div>

状态 2:可展开状态
导航项包含子项时,点击应该展开/折叠子菜单,而非跳转路由:

1
2
3
4
// 期望:
<div className="..." onClick={toggleOpen}>
Management
</div>

状态 3:路由链接状态
导航项是叶子节点时,应该渲染为可导航的链接:

1
2
3
4
// 期望:
<Link to="/dashboard" className="...">
Dashboard
</Link>

这三种状态需要不同的 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// src/layouts/dashboard/nav/vertical/types.ts

// 导航项的基础数据
export type NavItemData = {
path: string; // 路由路径
title: string; // 显示标题
icon?: string | React.ReactNode; // 图标(Iconify 名称或 React 元素)
caption?: string; // 副标题/说明文字
info?: React.ReactNode; // 徽章/信息节点
children?: NavItemData[]; // 子菜单
} & NavItemState;


// 导航项的状态标记
export type NavItemState = {
open?: boolean; // 是否展开
active?: boolean; // 是否激活(当前路由匹配)
disabled?: boolean; // 是否禁用
hidden?: boolean; // 是否隐藏
onClick?: (event: React.MouseEvent) => void; // 点击事件
};
// 导航项的辅助选项
export type NavItemOptions = {
depth?: number; // 嵌套层级(用于缩进)
hasChild?: boolean; // 是否有子项(影响渲染逻辑)
};

// 完整的导航项 Props
export type NavItemProps = React.ComponentProps<"div"> &
NavItemData &
NavItemOptions;

这个类型组合实现了:

  • 继承原生 div 的所有属性(classNameonClick 等)
  • 包含数据字段(pathtitle 等)
  • 包含状态标记(activedisabled 等)
  • 包含选项字段(depthhasChild 等)

15.2.3. 创建渲染器组件

现在我们基于类型契约创建 NavItemRenderer,它的职责是根据导航项的状态选择合适的 HTML 元素。

步骤 1:创建文件与基础结构

文件路径: src/layouts/dashboard/nav/vertical/NavItemRenderer.tsx

1
touch src/layouts/dashboard/nav/vertical/NavItemRenderer.tsx

先定义组件的 Props 类型:

1
2
3
4
5
6
7
8
// src/layouts/dashboard/nav/vertical/NavItemRenderer.tsx
import type { NavItemProps } from './types';

type NavItemRendererProps = {
item: NavItemProps; // 导航项数据
className: string; // 样式类名
children: React.ReactNode; // 子元素
};

设计思路

  • 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
2
3
4
5
6
7
8
9
10
11
12
13
14
// src/routes/components/router-link.tsx
import { Link } from 'react-router-dom';
import { LinkProps } from 'react-router-dom';

interface RouterLinkProps extends Omit<LinkProps, 'to'> {
href: string; // 使用 href 而非 to,更符合语义
}

export const RouterLink: React.FC<RouterLinkProps> = ({
href,
...props
}) => (
<Link to={href} {...props} />
);

步骤 3:完成渲染器逻辑

现在回到 NavItemRenderer,添加完整的渲染逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// src/layouts/dashboard/nav/vertical/NavItemRenderer.tsx
import { RouterLink } from '@/routes/components/router-link';
import type { NavItemProps } from './types';

type NavItemRendererProps = {
item: NavItemProps;
className: string;
children: React.ReactNode;
};

export const NavItemRenderer: React.FC<NavItemRendererProps> = ({
item,
className,
children,
}) => {
const { disabled, hasChild, path, onClick } = item;

// 情况 1:禁用状态 → 渲染为普通 div
if (disabled) {
return <div className={className}>{children}</div>;
}

// 情况 2:有子项(可展开) → 渲染为可点击的 div
if (hasChild) {
return (
<div className={className} onClick={onClick}>
{children}
</div>
);
}

// 情况 3:路由链接(默认情况)
return (
<RouterLink href={path} className={className}>
{children}
</RouterLink>
);
};

15.2.4. 架构优势

这个渲染器组件体现了几个关键设计原则:

  • 单一职责: 渲染器只负责决定使用哪个 HTML 元素,不关心样式、内容和状态管理。
  • 开闭原则: 如果未来需要支持外部链接,只需添加一个新的 if 分支,而无需修改现有代码。
  • 类型安全: TypeScript 确保了所有必需的 props 都被正确传递。

15.3 构建原子组件 - nav-item.tsx

在完成样式(15.1)和渲染器(15.2)后,现在我们开始组装 NavItem 组件。这是导航菜单的原子单元,负责渲染单个导航项的完整视觉呈现。

15.3.1. 组件职责分析

NavItem 需要完成三个核心任务:

  1. 内容组装: 将数据字段(图标、标题、副标题等)渲染为具体的 DOM 结构。
  2. 样式应用: 使用 nav-item.css.tsnav-item.variants.ts 为各元素应用正确样式。
  3. 渲染逻辑委托: 将组装好的内容和样式传递给 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// src/layouts/dashboard/nav/vertical/nav-item.tsx
import { Icon } from '@/components/icons/Icon';
import { NavItemRenderer } from './NavItemRenderer';
// [核心] 分别从两个文件导入样式和变体
import { navItemStyles } from './nav-item.css.ts';
import { navItemVariants } from './nav-item.variants.ts';
import type { NavItemProps } from './types';
import { cn } from '@/utils/cn';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip/tooltip';

export function NavItem(item: NavItemProps) {
const {
title, icon, caption, info,
hasChild, open, active, disabled
} = item;

// TODO: 组装内容
// TODO: 计算样式
// TODO: 渲染组件

return null;
}

步骤 2:组装内容 (content)

我们将导航项的所有视觉元素(图标、文本、徽章、箭头)组装到一个 content 变量中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
export function NavItem(item: NavItemProps) {
const { /* ...props... */ } = item;

const content = (
<>
{/* 图标部分 */}
{icon && (
<span className={navItemStyles.icon}>
{typeof icon === 'string' ? <Icon icon={icon} /> : icon}
</span>
)}

{/* 文本区 */}
<span className={navItemStyles.texts}>
{/* 标题 */}
<span className={navItemStyles.title}>{title}</span>

{/* 副标题(带 Tooltip) */}
{caption && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className={navItemStyles.caption}>{caption}</span>
</TooltipTrigger>
<TooltipContent side="top" align="start">
{caption}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</span>

{/* 徽章/信息 */}
{info && <span className={navItemStyles.info}>{info}</span>}

{/* 箭头(可展开项) */}
{hasChild && (
<Icon
icon="eva:arrow-ios-forward-fill"
className={navItemStyles.arrow}
style={{
transform: open ? 'rotate(90deg)' : 'rotate(0deg)',
}}
/>
)}
</>
);

// ...
return null;
}

架构价值:这里复用了我们在第十四章构建的 Tooltip 组件,展示了组件组合的威力。

步骤 3:计算根元素样式并渲染

使用 CVA 变体函数 navItemVariants 计算根元素的 className,然后将所有内容传递给 NavItemRenderer

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
export function NavItem(item: NavItemProps) {
const { active, disabled /* ...其他 props... */ } = item;

const content = (
<>
{/* ... 上一步的 content JSX ... */}
</>
);

// 使用 CVA 计算根元素的 className
const itemClassName = cn(
navItemVariants({ active, disabled }),
'min-h-[44px]', // 最小高度确保足够的点击区域
);

return (
<NavItemRenderer item={item} className={itemClassName}>
{content}
</NavItemRenderer>
);
}```

### 15.3.3. 组件组合的验证

现在我们可以验证整个组合链条:
​```tsx
// 使用示例
< NavItem
path = "/dashboard"
title = "Dashboard"
icon = "ic: baseline-dashboard"
caption = "System Overview"
active ={true}
/>

组合层次

1
2
3
4
5
NavItem (原子组件)
├─ 内容组装 (图标、标题、Tooltip、箭头)
├─ 样式计算 (navItemVariants + navItemStyles)
└─ NavItemRenderer (渲染策略)
└─ RouterLink / div (最终 HTML 元素)

15.3.4. 与现有组件的集成

这个 NavItem 组件完美地集成了我们之前构建的所有"积木":

依赖组件来源章节/文件用途
Icon第七章渲染 Iconify 图标
Tooltip第十四章副标题悬浮提示
navItemVariants15.1 (nav-item.variants.ts)CVA 状态变体
navItemStyles15.1 (nav-item.css.ts)vanilla-extract 子元素样式
NavItemRenderer15.2多态渲染逻辑
cn第九章类名合并工具

这就是组件驱动开发(CDD)的威力:每个组件都是可独立测试、可复用的单元,最终组合成复杂的 UI。


15.4 构建递归核心 - NavList

现在我们进入本章的核心部分。NavList 是整个导航系统的"大脑",它负责:

  • 管理单个导航项的展开/折叠状态
  • 基于当前路由自动判断 active 状态
  • 递归渲染子项(这是关键!)
  • 处理 hidden 等边缘情况

15.4.1. 理解递归渲染的必要性

问题场景:考虑以下菜单结构

1
2
3
4
5
6
Dashboard (一级)
Management (一级,有子项)
├─ User (二级)
├─ System (二级,有子项)
│ ├─ Role (三级)
│ └─ Permission (三级)

这是一个典型的树形结构,深度不固定。如果用传统的循环渲染,我们需要写:

1
2
3
4
5
6
7
8
9
10
11
12
// ❌ 问题方案:只能处理固定层级
{items.map(item => (
<NavItem {...item}>
{item.children?.map(child => (
<NavItem {...child}>
{child.children?.map(grandchild => (
<NavItem {...grandchild} />
))}
</NavItem>
))}
</NavItem>
))}

这种方案有两个致命缺陷:

  1. 无法处理动态深度:如果菜单有 4 层、5 层,代码会失控。
  2. 违反 DRY 原则:每一层都在重复相同的渲染逻辑。

解决方案:递归组件

递归组件是指 组件在自己的渲染逻辑中调用自己。这让我们可以用一个组件处理任意深度的树形结构:

1
2
3
4
5
6
7
8
9
10
11
function NavList({ data, depth }) {
return (
<div>
<NavItem {...data} depth={depth} />
{data.children?.map(child => (
// [关键] NavList 调用自己,深度 +1
<NavList data={child} depth={depth + 1} />
))}
</div>
);
}

这种模式的优雅之处在于:

  • 一次定义,无限深度:无论菜单有多少层,逻辑都是一致的。
  • 符合数据结构:树形数据用树形组件渲染,天然契合。
  • 易于维护:修改一处,所有层级同步更新。

15.4.2. 定义类型契约

在实现递归组件之前,我们先来定义一下他的它的类型

1
2
3
4
5
6
7
// src/layouts/dashboard/nav/vertical/types.ts

// NavList 的 Props
export type NavListProps = {
data: NavItemData; // 当前导航项的数据
depth?: number; // 当前层级(默认 1)
};

设计理念

  • 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
2
3
4
5
6
7
8
9
10
11
12
13
14
// src/layouts/dashboard/nav/vertical/nav-list.tsx
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible/collapsible';
import { useState } from 'react';
import { useLocation } from 'react-router-dom';
import type { NavListProps } from './types';
import { NavItem } from './nav-item';

export function NavList({ data, depth = 1 }: NavListProps) {
// TODO: 判断 active 状态
// TODO: 管理 open 状态
// TODO: 处理子项递归

return null;
}

依赖说明

  • Collapsible:来自第十四章,用于管理展开/折叠动画。
  • useLocation:React Router 提供,用于获取当前路由。
  • useState:管理本地展开状态。

步骤 2:实现路由匹配逻辑

导航项的 active 状态应该由"当前路由是否匹配该项的 path"决定。

1
2
3
4
5
6
7
8
export function NavList({ data, depth = 1 }: NavListProps) {
const location = useLocation();

// [核心] 判断当前路由是否在该导航项的路径下
const isActive = location.pathname.includes(data.path);

// ...
}

设计解析

使用 includes 而非 === 的原因:

1
2
3
4
5
6
7
8
// 假设 data.path = "/management"
// location.pathname = "/management/user"

// ✓ includes 会匹配:父路由也应该高亮
isActive = true

// ✗ 严格相等会失败:子路由下父级不高亮
isActive = false

这符合常见的导航交互:当你在"用户管理"页面时,"Management"父级也应该保持激活状态。

步骤 3:管理展开/折叠状态

展开状态应该由两个因素决定:

  1. 初始状态:如果当前路由匹配,默认展开。
  2. 用户交互:点击时切换。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
export function NavList({ data, depth = 1 }: NavListProps) {
const location = useLocation();
const isActive = location.pathname.includes(data.path);

// [核心] 初始状态跟随 active,之后由用户控制
const [open, setOpen] = useState(isActive);

// 判断是否有子项
const hasChild = data.children && data.children.length > 0;

// 点击处理:只在有子项时切换状态
const handleClick = () => {
if (hasChild) {
setOpen(! open);
}
};

// ...
}

状态管理要点

  • useState(isActive):初始值基于路由,确保激活路径默认展开。
  • handleClick 只在 hasChildtrue 时生效,避免叶子节点被误点击。

步骤 4:处理 hidden 边缘情况

有些菜单项可能被权限系统标记为 hidden,我们需要提前返回 null

1
2
3
4
5
6
7
8
9
10
export function NavList({ data, depth = 1 }: NavListProps) {
// ... 前面的逻辑 ...

// [边缘处理] 隐藏的项目直接不渲染
if (data.hidden) {
return null;
}

// ...
}

这个检查应该在最终渲染之前,避免执行不必要的计算和副作用。

步骤 5:完整渲染逻辑(含递归)

现在我们将所有逻辑整合,并实现递归渲染:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
export function NavList({ data, depth = 1 }: NavListProps) {
const location = useLocation();
const isActive = location.pathname.includes(data.path);
const [open, setOpen] = useState(isActive);
const hasChild = data.children && data.children.length > 0;

const handleClick = () => {
if (hasChild) {
setOpen(! open);
}
};

if (data.hidden) {
return null;
}

return (
<Collapsible open={open} onOpenChange={setOpen} data-nav-type="list">
{/* 触发器:渲染当前项 */}
<CollapsibleTrigger className="w-full">
< NavItem
// 数据 props
title ={data.title}
path ={data.path}
icon ={data.icon}
info ={data.info}
caption ={data.caption}
// 状态 props
open ={open}
active ={isActive}
disabled ={data.disabled}
// 选项 props
hasChild ={hasChild}
depth ={depth}
// 事件
onClick ={handleClick}
/>
</CollapsibleTrigger>
{/* 内容区:递归渲染子项 */}
{hasChild && (
<CollapsibleContent>
<div className="ml-4 mt-1 flex flex-col gap-1">
{data.children?.map((child: NavItemData) => (
// [核心递归] NavList 调用自己,深度 +1
<NavList key={child.title} data={child} depth={depth + 1} />
))}
</div>
</CollapsibleContent>
)}
</Collapsible>
);
}

15.4.4. 递归逻辑深度解析

让我们用一个具体例子跟踪递归过程:

数据输入

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
{
title: "Management",
path: "/management",
children: [
{
title: "User",
path: "/management/user",
children: []
},
{
title: "System",
path: "/management/system",
children: [
{ title: "Role", path: "/management/system/role", children: [] }
]
}
]
}

递归执行流程

1
2
3
4
5
6
7
8
9
10
11
12
第 1 次调用 <NavList data={Management} depth={1} />
├─ 渲染 NavItem (Management, depth = 1)
└─ 有子项,进入 CollapsibleContent
├─ 第 2 次调用 <NavList data={User} depth={2} />
│ └─ 渲染 NavItem (User, depth = 2)
│ └─ 无子项,结束
└─ 第 3 次调用 <NavList data={System} depth={2} />
├─ 渲染 NavItem (System, depth = 2)
└─ 有子项,进入 CollapsibleContent
└─ 第 4 次调用 <NavList data={Role} depth={3} />
└─ 渲染 NavItem (Role, depth = 3)
└─ 无子项,结束

关键观察

  1. 每次递归,depth 都会 +1,可用于样式缩进。
  2. 递归的终止条件是 hasChild === false,即叶子节点。
  3. 每个 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
2
3
4
5
6
7
8
9
10
11
12
13
📁 Dashboard (分组 1)
├─ Workbench
├─ Analysis

📁 Management (分组 2)
├─ User
├─ System
├─ Role
└─ Permission

📁 Functions (分组 3)
├─ Clipboard
└─ Token Expired

如果没有分组,所有菜单项会挤在一起,用户难以快速定位。NavGroup 通过视觉分隔和语义化标题,提升了导航的可用性。

关键特性

  1. 可折叠的分组标题:用户可以收起不常用的分组,减少视觉干扰。
  2. 国际化支持:分组标题需要支持多语言切换。
  3. 默认展开:初始状态应该是展开的,方便用户浏览。

15.5.2. 定义类型契约

在实现组件之前,先明确它的数据结构。

1
2
3
4
5
6
7
// src/layouts/dashboard/nav/vertical/types.ts

// NavGroup 的 Props
export type NavGroupProps = {
name?: string; // 分组名称(可选,如果没有则不渲染标题)
items: NavItemData[]; // 该分组下的导航项列表
};

设计理念

  • 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
2
3
4
5
6
7
8
9
10
11
12
// src/layouts/dashboard/nav/vertical/nav-group.tsx
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible/collapsible';
import type { NavGroupProps } from './types';
import { NavList } from './nav-list';

export function NavGroup({ name, items }: NavGroupProps) {
// TODO: 管理折叠状态
// TODO: 渲染分组标题
// TODO: 渲染导航项列表

return null;
}

依赖说明

  • Collapsible:用于实现分组的展开/折叠功能。
  • NavList:递归渲染每个导航项。

步骤 2:管理折叠状态

分组的展开状态应该默认为 true,允许用户手动切换。

1
2
3
4
5
6
7
8
9
10
11
12
import { useState } from 'react';

export function NavGroup({ name, items }: NavGroupProps) {
// [核心] 默认展开,用户可点击切换
const [open, setOpen] = useState(true);

const toggleOpen = () => {
setOpen(!open);
};

// ...
}

设计解析

  • 使用 useState(true) 而非 useState(false):初始状态展开,用户能立即看到所有菜单。
  • toggleOpen 函数:作为点击处理器传递给标题组件。

步骤 3:构建分组标题组件

分组标题需要具备以下特性:

  1. 显示分组名称(后续支持国际化)
  2. 显示展开/折叠指示箭头
  3. 悬浮时提供视觉反馈

我们将标题抽取为一个独立的子组件,保持 NavGroup 的简洁。

src/layouts/dashboard/nav/vertical/components/GroupTitle.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
import { Icon } from '@/components/icons/Icon';
import { cn } from '@/utils/cn';

// [子组件] 分组标题
export function GroupTitle({
name,
open,
onClick
}: {
name: string;
open: boolean;
onClick: () => void;
}) {
return (
<button
type='button'
className={cn(
'group w-full inline-flex items-center justify-start relative gap-2 cursor-pointer',
'pt-4 pr-2 pb-2 pl-3',
'transition-all duration-300 ease-in-out',
'hover:pl-4', // hover 时增加左边距创建"吸引"效果
)}
onClick={onClick}
>
{/* 左侧指示箭头:默认隐藏,hover 时显示 */}
<Icon
icon="eva:arrow-ios-forward-fill"
className={cn(
'absolute left-[-4px] h-4 w-4 inline-flex shrink-0',
'transition-all duration-300 ease-in-out',
'opacity-0 group-hover:opacity-100', // hover 时淡入
{
'rotate-90': open, // 展开时旋转 90
}
)}
/>

{/* 分组名称 */}
<span
className={cn(
'text-xs font-medium',
'transition-all duration-300 ease-in-out',
'text-text-disabled',
'hover:text-text-primary',
)}
>
{name}
</span>
</button>
);
}

设计亮点

  1. 渐进式交互:箭头默认隐藏(opacity-0),hover 时淡入(group-hover:opacity-100),减少视觉噪音。
  2. 动态定位absolute left-[-4px] 让箭头超出容器边界,创造"从外部滑入"的效果。
  3. 状态同步rotate-90opentrue 时生效,视觉上明确表达当前状态。

步骤 4:完整渲染逻辑

现在我们将标题和内容区整合到 Collapsible 结构中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
export function NavGroup({ name, items }: NavGroupProps) {
const [open, setOpen] = useState(true);

const toggleOpen = () => {
setOpen(!open);
};

return (
<Collapsible open={open} onOpenChange={setOpen}>
{/* 触发器:分组标题 */}
{name && (
<CollapsibleTrigger asChild>
<GroupTitle name={name} open={open} onClick={toggleOpen} />
</CollapsibleTrigger>
)}

{/* 内容区:导航项列表 */}
<CollapsibleContent>
<ul className="flex w-full flex-col gap-1">
{items.map((item, index) => (
<NavList
key={item.title || index}
data={item}
depth={1} // 分组下的第一层从 depth=1 开始
/>
))}
</ul>
</CollapsibleContent>
</Collapsible>
);
}

架构要点

  1. 条件渲染{name && ...} 确保无标题的分组不渲染触发器。
  2. asChild 模式:让 CollapsibleTrigger 使用 GroupTitle 作为触发元素,而非额外包装一层 DOM。
  3. key 策略:优先使用 item.title,回退到 index,确保列表稳定性。
  4. depth={1}:分组是最外层容器,其下的导航项从第 1 层开始计数。

15.5.4. 组件层次关系

现在我们可以看到完整的组件树:

1
2
3
4
5
6
7
8
NavGroup (分组容器)
├─ GroupTitle (分组标题,可选)
│ ├─ Icon (箭头指示)
│ └─ span (分组名称)
└─ CollapsibleContent
└─ NavList (递归渲染每个导航项)
├─ NavItem (当前项)
└─ NavList[] (子项,递归)

这种层次清晰的结构使得每个组件都专注于单一职责:

  • 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 的设计体现了几个关键原则:

  • 组合优于继承:通过组合 CollapsibleNavList 等现有组件,而非从头构建。
  • 关注点分离:标题、折叠逻辑、列表渲染各自独立。
  • 灵活性name 可选,支持有标题和无标题两种模式。
  • 可扩展性:未来添加"全部展开/收起"等全局操作,只需在此层注入逻辑。

15.6 构建主容器 - NavVertical

现在我们已经构建了导航系统的所有核心部分,是时候创建最外层的容器组件了。NavVertical 的职责非常简单:接收数据,遍历渲染所有 NavGroup

15.6.1. 组件职责分析

NavVertical 是一个"哑组件"(Presentational Component),它只负责:

  1. 接收数据:从 useNavData() 获取的分组数据。
  2. 遍历渲染:将每个分组传递给 NavGroup
  3. 布局样式:定义导航菜单的整体布局(垂直排列、间距等)。

它不关心:

  • 数据从哪里来(由父组件决定)
  • 数据如何转换(已在 useNavData 完成)
  • 导航项如何渲染(由 NavGroupNavListNavItem 处理)

这种"无状态容器"的设计使得组件极易测试和复用。

15.6.2. 定义类型契约

1
2
3
4
5
6
7
8
9
// src/layouts/dashboard/nav/vertical/types.ts

// NavVertical 的 Props
export type NavVerticalProps = React.ComponentProps<'nav'> & {
data: {
name?: string; // 分组名称
items: NavItemData[]; // 导航项列表
}[];
};

设计理念

  • 继承 <nav> 的所有原生属性(classNamearia-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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// src/layouts/dashboard/nav/vertical/nav-vertical.tsx
import { cn } from '@/utils/cn';
import type { NavVerticalProps } from './types';
import { NavGroup } from './nav-group';

export function NavVertical({ data, className, ...props }: NavVerticalProps) {
return (
<nav
className={cn('flex w-full flex-col gap-1', className)}
{...props}
>
{data.map((group, index) => (
<NavGroup
key={group.name || index}
name={group.name}
items={group.items}
/>
))}
</nav>
);
}

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-1
    • flex flex-col:垂直布局
    • w-full:占满父容器宽度
    • gap-1:分组之间有 4px 间距(Tailwind 的 gap-1 = 0.25rem = 4px)
  • cn():确保外部传入的 className 能正确合并或覆盖基础样式。

3. 列表渲染

1
2
3
4
5
6
7
{data.map((group, index) => (
<NavGroup
key={group.name || index}
name={group.name}
items={group.items}
/>
))}
  • key 策略:优先使用 group.name,如果分组无名称则使用 index
  • 直接透传 nameitems,保持数据流清晰。

15.6.5. 使用示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import { useNavData } from '@/hooks/menu/useNavData';
import { NavVertical } from './nav-vertical';

function DashboardSidebar() {
const navData = useNavData(); // 从 Hook 获取数据

return (
<div className="w-64 h-full bg-background">
<NavVertical
data={navData}
aria-label="Main navigation" // 无障碍支持
/>
</div>
);
}

15.6.6. 架构优势

NavVertical 的简洁设计带来了几个优势:

  1. 高内聚,低耦合

    • 只依赖 NavGroup,不关心更深层的实现。
    • 数据结构变化时,只需修改 NavGroup,不影响此层。
  2. 易于测试

    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);
  3. 样式可控

    1
    2
    3
    4
    5
    // 外部可注入自定义样式
    <NavVertical
    data={navData}
    className="px-4 py-2" // 添加内边距
    />
  4. 语义化 HTML

    • 使用 <nav> 标签,而非 <div>,增强可访问性。
    • 支持 ARIA 属性,辅助技术(如屏幕阅读器)能正确识别导航区域。

15.6.7. 为什么不在此层处理数据获取?

你可能会问:为什么不在 NavVertical 内部调用 useNavData(),而是通过 props 传入?

反例(紧耦合设计)

1
2
3
4
5
6
7
8
9
10
// ❌ 不推荐:组件直接依赖数据源
export function NavVertical() {
const navData = useNavData(); // 硬编码数据获取

return (
<nav>
{navData.map(...)}
</nav>
);
}

问题

  1. 测试困难:无法 mock 数据,必须搭建完整的数据流。
  2. 复用受限:如果要用其他数据源(如静态配置),需要修改组件。
  3. 责任混乱:组件既负责"如何渲染",又负责"数据从哪来"。

当前设计(依赖注入)

1
2
// ✓ 推荐:数据通过 props 注入
<NavVertical data={navData} />

优势

  1. 关注点分离NavVertical 只关心渲染,数据获取由父组件负责。
  2. 高度可测:可以传入任意 mock 数据。
  3. 灵活复用:可以轻松切换数据源。

15.7 创建统一导出文件

为了方便外部使用,我们需要创建一个统一的导出文件。

文件路径: src/layouts/dashboard/nav/vertical/index.tsx

1
touch src/layouts/dashboard/nav/vertical/index.tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
// src/layouts/dashboard/nav/vertical/index.tsx

// 导出主容器组件
export { NavVertical } from './nav-vertical';

// 导出类型(供外部使用)
export type {
NavVerticalProps,
NavItemData,
NavItemProps,
NavListProps,
NavGroupProps,
} from './types';

使用示例

1
2
3
// 外部文件可以这样导入
import { NavVertical } from '@/layouts/dashboard/nav/vertical';
import type { NavItemData } from '@/layouts/dashboard/nav/vertical';

15.8 集成验证:在 DashboardLayout 中使用

现在所有组件都已就绪,是时候将它们集成到实际的布局中了。

15.8.1. 回顾数据流

在开始集成之前,让我们回顾一下完整的数据流:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
┌─────────────────────────────────────────┐
│ 1. MSW Mock API (第 13 章) │
│ DB_MENU (扁平数据) │
└─────────────┬───────────────────────────┘
│ menuService.getMenuList()

┌─────────────────────────────────────────┐
│ 2. React Query (第 13 章) │
│ useMenuQuery() - 缓存管理 │
└─────────────┬───────────────────────────┘
│ 自动同步到 Zustand

┌─────────────────────────────────────────┐
│ 3. Zustand Store (第 13 章) │
│ menuStore - 全局状态 │
└─────────────┬───────────────────────────┘
│ useMenuTree()

┌─────────────────────────────────────────┐
│ 4. 数据转换 (第 13 章) │
│ useNavData() + convertMenuToNavData() │
│ MenuEntity[] → NavData[] │
└─────────────┬───────────────────────────┘
│ 传递给组件

┌─────────────────────────────────────────┐
│ 5. 组件渲染 (第 15 章) │
│ NavVertical → NavGroup → NavList │
│ → NavItem → NavItemRenderer │
└─────────────────────────────────────────┘

15.8.2. 修改 DashboardLayout

打开第 12 章创建的 DashboardLayout 文件:

文件路径: src/layouts/dashboard/index.tsx

找到侧边栏的占位符代码,替换为真实的导航组件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
// src/layouts/dashboard/index.tsx
import { useNavData } from '@/hooks/menu/useNavData';
import { NavVertical } from './nav/vertical';
import { ScrollArea } from '@/components/ui/scrollArea/scroll-area';

export function DashboardLayout() {
// [新增] 获取导航数据
const navData = useNavData();

return (
<div className="flex h-screen w-screen">
{/* 侧边栏 */}
<aside
className="w-[var(--layout-nav-width)] h-full border-r border-dashed"
style={{
width: 'var(--layout-nav-width)', // 使用 CSS 变量 12 章定义
}}
>
{/* Logo 区域 */}
<div className="h-[var(--layout-header-height)] flex items-center px-4">
<span className="text-xl font-bold">Prorise Admin</span>
</div>

{/* 导航菜单区域(带滚动) */}
<ScrollArea className="h-[calc(100vh-var(--layout-header-height))] px-2">
<NavVertical data={navData} />
</ScrollArea>
</aside>

{/* 主内容区 */}
<main className="flex-1 flex flex-col overflow-hidden">
{/* Header */}
<header className="h-[var(--layout-header-height)] border-b flex items-center px-4">
<span>Header Placeholder</span>
</header>

{/* 内容区 */}
<div className="flex-1 overflow-auto p-4">
{/* Outlet 渲染子路由 */}
<Outlet />
</div>
</main>
</div>
);
}

15.8.3. 关键集成点解析

1. 数据获取

1
const navData = useNavData();
  • 调用我们在第 13 章创建的 Hook。
  • 自动处理:API 请求 → 状态同步 → 数据转换。
  • 组件只需关心"使用数据",不关心"数据从哪来"。

2. ScrollArea 包裹

1
2
3
<ScrollArea className="h-[calc(100vh-var(--layout-header-height))] px-2">
<NavVertical data={navData} />
</ScrollArea>
  • 高度计算100vh - header高度,确保导航区域占满剩余空间。
  • 内边距px-2 让导航项与容器边缘有间距。
  • 滚动溢出:当菜单项过多时,自动显示滚动条。

3. CSS 变量复用

1
2
style={{ width: 'var(--layout-nav-width)' }}
className="h-[var(--layout-header-height)]"
  • 复用第 12 章在 :root 定义的全局变量。
  • 保证布局尺寸的一致性。

15.8.4. 完整验证流程

现在启动开发服务器,验证完整功能:

1
pnpm dev

预期效果

  1. 数据加载

    • 打开浏览器控制台,查看 Network 标签。
    • 应该能看到 /api/menu/list 的请求(MSW 拦截)。
    • React Query DevTools 中能看到缓存的数据。
  2. 菜单渲染

    • 侧边栏显示完整的导航树。
    • 分组标题可见(如 “Dashboard”、“Management”)。
    • 导航项正确显示图标和标题。
  3. 交互功能

    • 点击分组标题,能展开/收起。
    • hover 导航项,背景色变化。
    • 点击叶子节点(如 “Dashboard”),路由跳转。
  4. 激活状态

    • 当前路由对应的导航项高亮显示。
    • 父级导航项也应该高亮(如 /management/user 下,“Management” 也高亮)。
  5. 递归结构

    • 多级菜单正确缩进(每层 ml-4)。
    • 展开/折叠动画流畅(Collapsible 的 CSS 过渡)。

15.8.5. 常见问题排查

问题 1:导航菜单不显示

1
2
3
4
5
6
7
// 检查数据是否加载
console.log('navData:', navData);

// 如果是空数组,检查:
// 1. MSW 是否正确启动(main.tsx)
// 2. menuService 的 API 路径是否正确
// 3. useMenuQuery 是否被调用

问题 2:点击导航项不跳转

1
2
3
4
5
// 检查 NavItemRenderer 的逻辑
// 确保 hasChild=false 的项渲染为 <RouterLink>

// 检查路由配置
// 确保对应的路由已在 router/sections 中定义

问题 3:激活状态不正确

1
2
3
4
// 检查 NavList 中的路由匹配逻辑
const isActive = location.pathname.includes(data.path);

// 确保 data.path 与实际路由一致

问题 4:样式不生效

1
2
3
4
5
// 检查 vanilla-extract 是否正确编译
// 确保 vite.config.ts 中配置了 vanillaExtractPlugin

// 检查 Tailwind 配置
// 确保 content 路径包含了 layouts/**/*.tsx

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
2
3
4
5
NavVertical (主容器)
└─ NavGroup (分组)
└─ NavList (递归核心)
└─ NavItem (原子组件)
└─ NavItemRenderer (渲染策略)

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、虚拟滚动