第十三章. 导航菜单:数据驱动与动态渲染
第十三章. 导航菜单:数据驱动与动态渲染
Prorise第十三章. 导航菜单:数据驱动与动态渲染
在第十二章中,我们成功构建了 DashboardLayout 的基本结构。我们特别在 src/layouts/dashboard/nav/nav-vertical.tsx 中,利用 flex-1 和 ScrollArea 创造了一个结构化、可滚动的菜单容器。
然而,目前这个容器中仅为占位符 div 元素。
本章的核心任务,是使用一个功能完备、数据驱动、且支持递归嵌套的菜单系统来填充该容器。
这是企业级 Admin 应用的一项核心功能。它的实现不仅仅是 UI 渲染,更是一个集成了 类型定义、数据模拟、状态管理、组件封装 和 路由集成 的综合性工程。我们将深入探讨如何将路由数据、状态管理和 UI 组件进行有效的解耦与组合。
13.1. 任务 13.1:定义菜单 Types
在编写任何 UI 代码 (<NavMenu />) 或数据 (menuData) 之前,必须先解决一个基础的架构问题:
“数据”层和“UI”层之间,如何建立可靠的通信机制?
如果 NavMenu 组件需要一个 title 属性,而数据源提供的是 label,系统将无法正确渲染。如果数据源增加了一个新属性(例如“徽章”),UI 组件如何感知并处理这个新属性?
答案是,我们必须在开发之初,定义一份 “契约” (Contract)。
13.1.1. 架构理念:“契约优先”
“契约优先”是源于软件工程(尤其是在微服务架构中)的一项核心理念,它要求在编写任何实现代码之前,首先定义系统各部分之间的 通信接口。
- 在后端,这通常体现为
OpenAPI (Swagger)规范,它定义了 API 的端点、请求及响应的数据结构。 - 在前端,这份“契约”即是我们的 TypeScript 类型与接口。
在 Prorise-Admin 项目中,遵循此理念至关重要。这份“契约”将成为连接 数据层(_mock 或 API)、状态层(useNavigation 钩子)和视图层(NavMenu 组件) 的唯一蓝图。
此方法的重要性体现在以下两点:
解耦与并行开发 (Decoupling)
一旦这份“契约” (
src/types/router.ts) 被定义,项目的不同部分就可以并行开发而互不阻塞。例如,负责数据实现的开发者可以开始编写src/_mock/menu.ts,其唯一目标是确保导出的数据符合契约类型;负责 UI 的开发者可以在Storybook中构建NavItem组件,其唯一的依赖是契约中定义的Props接口;负责业务逻辑的开发者可以编写useNavigation钩子,因为他们明确知道状态管理所依赖的key是契约中定义的string类型。最后,当我们将三者集成时,它们将无缝协作,因为它们都基于同一份蓝图构建。可维护性与重构安全
设想未来的需求变更:“在所有一级菜单项旁显示一个彩色的徽章(Badge)”。通过“契约优先”的模式,处理流程将变得清晰且安全。
| 对比项 | 无契约的开发模式 | 采用“契约优先”的开发模式 |
|---|---|---|
| 第一步 | 猜测所有可能渲染菜单的组件(NavMenu.tsx, MobileNav.tsx, …),并进入每个文件手动修改。 | 修改契约:在 src/types/router.ts 的 RouteMeta 接口中,添加 badge?: { text: string; color: string; }。 |
| 后续步骤 | 手动检查和修改,极易因遗漏而产生 Bug。 | 静态检查驱动:TypeScript 的静态分析将自动在所有使用了该契约但未实现新属性的地方报告编译错误。 |
| 结果 | 重构过程风险高,维护成本大。 | 获得一个清晰的修改清单,确保重构的安全性和完整性。 |
13.1.2. 现状分析:React Router v7 的 RouteObject
在定义新类型之前,必须先审视项目中的现有资产。我们在第十一章集成了 react-router-dom,它提供了一个核心类型:RouteObject。
一个基础的 RouteObject 结构如下所示:
1 | // React Router 官方类型 (简化版) |
我们能否直接使用 RouteObject[] 来渲染菜单?
答案是否定的。
RouteObject 类型专为 路由匹配、数据加载和页面渲染 设计。它关注的是 path 与 element 的映射关系,以及加载时应调用的 loader 函数。
它并未包含任何 UI 表现层所需的信息,这引发了一系列关键问题:
RouteObject如何告知 UI 组件菜单项应显示的 文本标签?(缺少label属性)RouteObject如何告知 UI 组件菜单项应显示的 图标?(缺少icon属性)- 如果一个路由(例如
/users/:id/details)必须在系统中注册以供页面跳转,但 不应在导航菜单中显示,RouteObject如何表达此意图?(缺少hideMenu属性) - 如果我们需要对菜单项进行 排序(例如“Dashboard”总是在顶部),
RouteObject并未提供order属性。
13.1.3. 架构决策:分离 VS 增强
我们面临一个关键的架构决策:
方案一:创建全新的、分离的 MenuObject 类型 (解耦模式)
我们可以创建一个与 RouteObject 完全无关的新类型:
1 | // 方案一:完全分离的类型 |
- 优点:类型定义清晰。菜单系统只包含其关心的数据,与路由系统的复杂性(如
loader,action)完全解耦。 - 缺点:导致严重的数据冗余。我们将被迫在项目中维护两套相似的树状结构:一套
RouteObject[]用于createBrowserRouter,另一套MenuObject[]用于<NavMenu />。每次新增页面,都需要在两个文件中同步修改,这是低效且易于出错的。
方案二:“增强” (Enhance) 原生的 RouteObject (集成模式)
这是在企业级项目中更有效且被广泛采用的最佳实践。
- 核心思想:项目中的路由配置本身就应该是“唯一事实来源” (SSOT)。这份配置数据必须 同时驱动 路由系统和导航菜单系统。
- 实现策略:
- 我们不创建全新的对象,而是 “增强”
RouteObject。 - 创建一个独立的
RouteMeta接口,专门存放所有RouteObject不关心的 UI 和业务元数据(如label,icon,hideMenu)。 - 创建一个我们自己的路由类型
AppRouteObject。 AppRouteObject将 继承RouteObject的所有属性(path,element等),并额外 附加 一个meta?: RouteMeta属性。
- 我们不创建全新的对象,而是 “增强”
这种模式的优势在于:我们仅需维护一份 AppRouteObject[] 数据。createBrowserRouter 会消费它的 path 和 element 属性,而 <NavMenu /> 组件则消费它的 meta 和 children 属性。这统一了数据源。
13.1.4. 渐进式构建 src/types/router.ts (重写版)
我们选择 方案二:增强模式。现在,开始编写这份服务于导航菜单的、精准的“契约”。
第一步:创建文件结构
在 src/ 目录下创建 types 目录,用于存放所有全局领域模型的类型定义。
1 | # 在 src 目录下创建 types 文件夹 |
第二步:定义 RouteMeta (UI 契约)
打开 src/types/router.ts,导入所需类型。
1 | // src/types/router.ts |
接下来,我们定义 RouteMeta 接口。此接口 只包含 当前构建导航菜单所必需的属性。
1 | // src/types/router.ts (续) |
第三步:定义 AppRouteObject (路由连接器)
现在,我们将 RouteMeta 附加到 RouteObject 上,创建出项目专属的路由对象 AppRouteObject。
1 | // src/types/router.ts (续) |
13.2 API 模拟准备 - 安装与配置 MSW
在 任务 13.1 中,我们定义了前端 UI 期待的数据“契约” AppRouteObject。在真实世界中,这些数据总是通过异步网络请求从后端获取。
为了模拟这一真实流程,并让我们的前端代码从一开始就具备处理网络请求、加载和错误状态的能力,我们需要一个 API 模拟工具。本章,我们将安装并配置 Mock Service Worker (MSW)。
13.2.1. 架构决策:为何模拟 API 而非静态导入?
- 生产环境考量:若在代码中直接
import模拟数据,这些数据将被打包进最终的生产文件,不必要地增大了用户需要下载的文件体积。 - 真实工作流:应用程序总是通过异步网络请求(如
fetch)获取数据。模拟 API 可以强制我们从一开始就处理loading,error等真实世界中的异步状态。 - 前后端解耦:前端组件只需知道它要请求
GET /api/menu这个端点。它不关心也不需要关心响应是由 MSW 提供的,还是由真实的后端服务提供的。这使得前后端可以并行开发。
13.2.2. (编码) 安装 MSW 依赖
首先,我们将 msw 作为开发依赖项(-D)安装到项目中。
1 | pnpm add msw -D |
13.2.3. (编码) 添加 MSW 初始化脚本
MSW 需要一个 Service Worker 文件来在浏览器网络层拦截请求。我们可以通过其命令行工具自动生成此文件。为此,需要在 package.json 中添加一个脚本。
打开 package.json 文件,在 scripts 部分添加 "msw:init" 脚本:
1 | // package.json |
代码解析:"msw:init": "msw init public/ --save" 这行命令告诉 MSW:
init: 执行初始化操作。public/: 将 Service Worker 脚本mockServiceWorker.js生成在public目录下。这是因为public目录下的所有文件在构建时都会被直接复制到输出目录的根路径,确保浏览器可以访问到它。--save: 这是一个安全选项,确保只在package.json中msw依赖项存在时才执行。
13.2.4. (操作) 生成 Service Worker 文件
在终端中执行我们刚刚添加的脚本。
1 | pnpm msw:init |
执行后,您会看到 public 文件夹下出现了一个新的文件 mockServiceWorker.js。至此,MSW 的基础环境已经准备就绪。
13.2.5. 任务小结
在本章中,我们明确了使用 API 模拟的必要性,并完成了 MSW 的安装与初始化。我们的项目现在已经具备了在网络层拦截请求的能力,但我们还没有告诉它要拦截哪些请求以及如何响应。
在下一章,我们将定义后端返回数据的“原始”类型契约。
你提的这一点非常非常关键!你完全说对了。
一个好的教程不应该是“跟着我做”的命令清单,而应该是“跟我一起思考”的探索过程。“先有思路,后有实现”,这才是能真正让人学到东西的方法。我们不应该只是“跟着敲代码”,而应该“理解了再动“。
我非常乐意按照这个**“先讲解铺垫,后编码实现”**的思路,来重新组织这篇笔记。让我们用引导读者思考“为什么”的方式来重写它。
13.3 定义后端数据契约与原始数据
在上一章中,我们准备好了 MSW 的基础环境。现在,我们需要为 MSW 提供“弹药”——即它将要返回的模拟数据。
在动手编码之前,我们先来聊聊“为什么”
我们要模拟后端 API,就必须像一个 后端工程师 一样思考。对于后端来说,提供数据有两条生命线:
- 数据的“状态”是约定好的:比如,用什么数字代表“成功”?用什么数字代表“登录超时”?这是前后端必须遵守的“通信暗号”。
- 数据的“结构”是固定的:比如,一个“菜单”对象有哪些字段(
id,name等)?这些字段是什么类型?这是数据库表结构决定的。
因此,我们的整个过程将分为两步,完全模拟这个思路:
- 定义“契约” (The Contract):我们首先要用 TypeScript 来定义这些“暗号”(
enum.ts)和“结构”(entity.ts)。这份契约将保证前端和模拟后端(MSW)都遵循同一套标准。 - 创建“原材料” (The Raw Material):有了契约,我们再创建一份符合这个契约的“原始”模拟数据(
assets.ts)。
好,有了这个蓝图,我们开始第一步:定义“通信暗号”。
13.3.1. 设计思路:通用的 “状态约定” (enum.ts)
在项目中,有很多地方需要用到“状态”。比如 API 响应状态、菜单类型。如果我们不统一管理,A 程序员可能用 1 代表成功,B 程序员可能用 200,这就乱套了。
所以,我们首先要创建 src/types/enum.ts 文件,把这些全局通用的、常量性质的“约定”放在一起。
1. API 响应状态 (ResultStatus)
第一个约定是 ResultStatus。
思考: 你可能会想,HTTP 状态码 200 不就代表成功吗?为什么还要自己定义 SUCCESS = 0?
答案: 这是企业级项目中一种非常主流的设计模式,它区分了 “HTTP 状态” 和 “业务状态”。
- HTTP 状态:由 Web 服务器(Nginx、Node.js)返回,代表网络请求的死活。
200只代表“服务器成功收到了你的请求,并成功返回了响应”,它不关心业务是成功还是失败。 - 业务状态:由后端代码逻辑返回,存在于 JSON 数据体(
{ code: 0, ... })中,代表业务逻辑是否成功。
在这种模式下,即使用户名密码错误,后端也倾向于返回 HTTP 200,然后在 JSON 中返回 { "code": -1, "message": "密码错误" }。
- 为什么
SUCCESS = 0? 这是继承自 C 语言和 Unix 系统的古老约定:0代表“程序正常,无错误”。 - 为什么
TIMEOUT = 401? 这里的401不是 HTTP 401。这是后端在“借用” HTTP 401 (Unauthorized) 的概念,在业务层面(JSON 的code字段)告诉前端:“你的登录凭证(Token)已过期”。
2. 菜单节点类型 (PermissionType)
第二个约定是 PermissionType。
思考: 菜单不就是一层一层的列表吗?为什么需要三种类型?
答案: 这是为了在“数据结构”层面实现一个灵活的、可无限嵌套的菜单系统。这三种类型分别扮演不同的角色:
MENU = 2(菜单):“叶子”。这是用户 真正可以点击并导航到页面 的链接,如“用户管理”。CATALOGUE = 1(目录):“树枝”。这是“文件夹”,它本身通常 不可点击,它的唯一作用就是“容纳”其他的MENU或CATALOGUE。GROUP = 0(组):“根”或“逻辑分组”。这通常是侧边栏中用于视觉分隔的“标题”,比如“仪表盘”、“系统管理”。它也是不可点击的。
理解了这些“暗号”的设计思路后,我们就可以创建文件并写入代码了。
(编码) 实现 enum.ts
1 | # 如果 src/types 目录不存在 |
现在,打开 src/types/enum.ts 文件并写入我们刚刚讨论过的约定:
1 | // src/types/enum.ts |
13.3.2. 设计思路:描述 “数据实体” (entity.ts)
“暗号”约定好了,我们来定义“数据结构”。我们需要一个 TypeScript 接口(interface)来精确描述:一个菜单项从后端返回时,到底“长什么样”?
这个“长什么样”的定义,我们称为“实体”(Entity),它通常 完全对应 后端数据库表中的字段。
思考: 在定义这个 MenuEntity 接口时,我们必须考虑以下几个关键点:
- 如何体现层级关系? 后端数据库查出的数据是“扁平”的列表,没有
children字段。我们必须依赖一个parentId字段,来标识“谁是谁的子节点”。这是前端将其“组装”成树状结构的核心线索。 - 所有字段都是必需的吗? 显然不是。根据我们 13.3.1 的讨论,
GROUP和CATALOGUE只是“目录”,它们没有自己的页面,所以它们不需要path(路由路径) 和component(组件路径)。只有MENU才需要。
基于这些思考,我们的 MenuEntity 接口就清晰了:它必须包含 id 和 parentId,并且 path 和 component 必须是可选的 (?)。
(编码) 实现 entity.ts
首先,创建 src/types/entity.ts 文件。
1 | touch src/types/entity.ts |
打开该文件,引入 PermissionType(因为菜单实体会用到它),然后定义 MenuEntity 接口。
1 | // src/types/entity.ts |
13.3.3. 设计思路:模拟 “扁平的” 原始数据 (assets.ts)
现在,“契约”(enum 和 entity)都准备好了。最后一步是创建一份遵循该契约的“原材料”(模拟数据)。
思考: 这份模拟数据应该是什么形态?是“树状结构”(带 children)还是“扁平列表”?
答案: 必须是**“扁平列表”**!
为什么? 因为这才是 最真实 的后端形态。后端从数据库(一张二维表)中 SELECT * FROM menus 查出来的,就是一个 一级数组。
前端的核心职责之一,就是拿到这个“扁平的” RAW_MENU_DATA,然后通过 parentId 字段,在内存中动态地将它“组装”成带 children 的树形结构,最后再交给 UI 组件(如 Ant Design Menu)去渲染。
所以,我们的模拟数据必须是扁平的,并且要能覆盖所有场景(GROUP, CATALOGUE, MENU 以及三级嵌套)。
(编码) 实现 assets.ts
首先,创建 src/_mock/assets.ts 文件。
1 | touch src/_mock/assets.ts |
打开 src/_mock/assets.ts,引入我们需要的类型,并定义 RAW_MENU_DATA 数组。
1 | // src/_mock/assets.ts |
代码解析:
- 扁平化结构: 再次强调,所有数据项都在同一个数组中。层级关系 仅通过
parentId字段维系。 - 数据代表性: 这份数据完美覆盖了我们的所有设计:
GROUP: (group_dashboard)CATALOGUE: (management_system)MENU: (workbench, management_system_user)- 三级嵌套*:
group_management->management_system->management_system_user
- 三级嵌套*:
- 这份数据为我们后续测试“扁平转树”的算法和组件的递归渲染提供了充足的场景。
13.3.4. 任务小结
我们已经完成了数据准备工作。
通过“先思考设计,后编码实现”的方式,我们创建了后端数据的“契约” (enum.ts, entity.ts),并创建了一份遵循该契约的、高度仿真的扁平化原始数据集 (assets.ts)。
有了这份“原始材料”,我们就可以在下一章中创建一个 API 端点,它将负责处理这份数据并将其返回给前端。
13.4 创建 MSW 接口处理器
在上一章,我们准备好了“原始”的扁平化菜单数据。现在,我们需要创建一个 MSW 处理器 (Handler) 来模拟一个真实的 API 端点。
这个处理器的职责是:
- 定义一个 API 路由,例如
GET /api/menu。 - 当这个路由被请求时,读取我们创建的扁平数据。
- 将扁平数据转换为前端更易于使用的树状结构。
- 将转换后的树状数据包装成一个标准的 API 响应格式并返回。
扁平数据与树状数据的区别在于数据的组织结构。扁平数据通常是指那些没有嵌套结构的数据,它们以列表或数组的形式出现,每个元素都是独立的,没有层级关系。而树状数据则具有嵌套结构,类似于树形结构,其中每个元素都可以有子元素,形成层级关系。
例如我们期望将如下的扁平数据:
1 | [ |
转化为如下的树形数据:
1 | [ |
13.4.1. (编码) 创建数据转换工具 (tree.ts)
为了将扁平数据转换为树状结构,我们需要一个工具函数。我们将在 src/utils 目录下创建这个函数。
首先,创建目录和文件。
1 | # 创建 utils 目录 |
现在,打开 src/utils/tree.ts 文件并写入以下 convertFlatToTree 函数。
1 | // src/utils/tree.ts |
接下来,我们编写核心的转换函数。它使用 Map 数据结构来高效地查找父节点,分两步完成转换。
1 | // src/utils/tree.ts (续) |
13.4.2. (编码) 创建菜单 API 处理器 (_menu.ts)
有了数据转换工具,我们现在可以创建 API 处理器了。按照约定,所有 MSW 处理器都放在 src/_mock/handlers 目录下。
首先,创建目录和文件。
1 | # 创建 handlers 目录 |
打开 src/_mock/handlers/_menu.ts 文件,引入我们需要的模块。
1 | // src/_mock/handlers/_menu.ts |
现在,我们定义 menuList 处理器。
1 | // src/_mock/handlers/_menu.ts (续) |
代码解析:
http.get('/api/menu', ...): 这行代码告诉 MSW,当应用发起一个GET类型的、路径为/api/menu的网络请求时,执行我们提供的回调函数。HttpResponse.json(...): 这是 MSW 提供的响应构建器。它会创建一个 JSON 格式的 HTTP 响应。- 第一个参数
{ status, message, data }是响应体 (body),这是我们与后端约定的一种常见数据格式。 - 第二个参数
{ status: 200 }是响应的元数据,这里我们设置 HTTP 状态码为200 OK。
- 第一个参数
export const menuHandlers = [menuList]: 我们将所有与菜单相关的处理器放在一个数组中导出,方便后续统一注册。
13.4.3. 任务小结
我们成功地创建了第一个 MSW 接口处理器。
我们首先构建了一个关键的 convertFlatToTree 工具函数,用于将后端风格的扁平数据转换为前端友好的树状结构。然后,我们利用这个函数创建了一个 GET /api/menu 的模拟端点。
现在,这个 API 端点已经“存在”于我们的开发环境中,但 MSW 还不知道它的存在。在下一章,我们将“注册”这个处理器,并最终在应用中启动 MSW。
13.5 注册并启动 MSW 服务
在前面的章节中,我们已经创建了 API 处理器 (_menu.ts),但它本身不会运行。我们需要一个“启动器”来收集所有的处理器,并将它们注册到 MSW 的 Service Worker 中。最后,我们要在应用入口处执行这个启动器。
13.5.1. (编码) 创建 MSW 主入口文件 (index.ts)
我们将创建一个主入口文件 src/_mock/index.ts,它负责收集项目中所有模块的处理器(目前只有菜单模块)并进行配置。
首先,创建该文件。
1 | touch src/_mock/index.ts |
打开 src/_mock/index.ts 并写入以下内容。
1 | // src/_mock/index.ts |
代码解析:
import { setupWorker } from 'msw/browser';: 我们从msw/browser中导入setupWorker。这个函数专门用于在浏览器环境中配置 MSW。import { menuHandlers } from './handlers/_menu';: 我们导入上一章中创建的菜单处理器数组。
接下来,我们将所有处理器合并,并调用 setupWorker 创建一个 worker 实例。
1 | // src/_mock/index.ts (续) |
13.5.2. (编码) 在应用入口启动 MSW (main.tsx)
最后一步是在我们的应用启动时,调用 worker.start() 来激活 MSW。这个操作必须在 React 应用渲染之前完成,以确保在组件首次挂载并发起 API 请求时,MSW 已经准备就绪。
打开应用的入口文件 src/main.tsx。
首先,我们从 _mock 目录中导入 worker 实例。
1 | // src/main.tsx |
现在,我们需要修改应用的启动逻辑。原有的代码是直接渲染 React 应用,我们需要将其改为:先异步启动 MSW,在 MSW 启动成功后,再执行渲染操作。
1 | // src/main.tsx (续) |
代码解析:
import { worker } from './_mock';: 导入我们在上一步创建的worker实例。async function main(): 我们创建了一个异步的main函数来包裹整个应用的启动流程。if (process.env.NODE_ENV === 'development'): 这是一个关键的条件判断。我们 只希望 在开发环境中启用 API 模拟,而在生产环境中,应用应该去请求真实的 API。process.env.NODE_ENV是 Vite 等构建工具提供的环境变量。await worker.start(...): 这是启动 MSW 的核心命令。我们使用await来确保在执行后续的渲染代码之前,MSW 已经完全准备就绪。onUnhandledRequest: 'bypass': 这是一个非常重要的配置项。它告诉 MSW,对于那些没有在处理器中定义的请求(比如 Vite 自身的热更新请求),直接放行,不要报错。main(): 最后,我们调用这个异步函数来启动整个应用程序。
13.6 企业级数据流:API 服务层
我们已经成功创建并启动了一个模拟 API 端点 GET /api/menu。现在,是时候在我们的前端应用中发起对这个端点的网络请求了。
一种直接的做法是创建一个自定义 Hook (例如 useNavigation),在其中使用 fetch API,并用 useState 和 useEffect 来手动管理加载、错误和数据状态。这种方式对于简单的场景是可行的。
然而,在企业级应用中,数据获取逻辑通常更为复杂,需要考虑缓存、请求重试、多个组件共享同一份数据等问题。手动管理这些状态会变得非常繁琐且容易出错。因此,我们将采用一种更先进、更分层化的做法,这也是现代企业级项目中的标准实践。
我们的数据流将被拆分为三个清晰的层次:
- API 服务层 (本章内容):专门负责定义和封装所有与后端 API 的通信。
- 状态管理层 (下一章):负责存储从 API 获取的数据,并使其在整个应用中可被全局访问。
- UI 渲染层 (后续章节):从全局状态中获取数据,并将其渲染为最终的导航菜单。
13.6.1. (编码) 创建全局配置 (global-config.ts)
为了方便管理项目中的一些全局性常量,例如 API 的基础 URL,我们首先创建一个全局配置文件。
创建 src/global-config.ts 文件。
1 | touch src/global-config.ts |
打开该文件并写入以下内容,定义 API 的基础路径。
1 | // src/global-config.ts |
代码解析:
import.meta.env.VITE_API_BASE_URL: 这是一个 Vite 提供的功能,用于从.env文件中读取环境变量。这使得我们可以在不同环境(开发、测试、生产)中使用不同的 API 地址,而无需修改代码。
此时,如果你使用的是 TypeScript,编辑器可能会对 import.meta.env.VITE_APP_API_BASE_URL 报错,提示 “Property ‘env’ does not exist on type ‘ImportMeta’”。这是因为 TypeScript 默认不知道 Vite 注入的 import.meta.env 属性及其包含的环境变量。
为了让 TypeScript 识别这些类型,我们需要创建一个类型声明文件。
创建 src/vite-env.d.ts 文件。
1 | touch src/vite-env.d.ts |
打开该文件并写入以下内容。
1 | /// <reference types="vite/client" /> |
代码解析:
/// <reference types="vite/client" />: 引用 Vite 的客户端类型定义,提供 Vite 相关的类型支持。ImportMetaEnv: 声明所有环境变量的类型,每个以VITE_开头的环境变量都应该在这里定义。ImportMeta: 扩展全局的import.meta类型,告诉 TypeScript 它有一个env属性。
现在 TypeScript 就能正确识别环境变量了,编辑器也不会再报错。
13.6.2. 思考:为什么需要封装 API 客户端?
在直接动手编码之前,让我们先思考一个问题:为什么不直接在组件中使用 fetch 或 axios 发起请求?
假设我们在一个组件中直接这样写:
1 | const response = await axios.get('/api/menu'); |
这看起来似乎很简单,但当项目规模增长时,问题就会逐渐暴露:
问题一:重复的错误处理逻辑
每个 API 调用都需要写一遍错误处理:
1 | try { |
问题二:分散的认证令牌注入
每次请求都要手动添加 Token:
1 | await axios.get('/api/menu', { |
问题三:不统一的响应数据结构处理
后端返回的数据结构是 { status, data, message },但我们只关心 data。每次都需要手动提取:
1 | const response = await axios.get('/api/menu'); |
问题四:类型安全缺失
直接使用 axios 时,TypeScript 无法推断返回的数据类型,容易出错。
企业级应用需要一个统一的、可复用的 API 请求方案。这就是我们要构建 API 客户端的原因:把这些重复的、容易出错的逻辑集中到一个地方处理。
13.6.3. 设计思路:分层封装
我们将采用分层设计,逐步构建一个健壮的 API 客户端:
1 | ┌─────────────────────────────────────┐ |
每一层都有其明确的职责,层层递进,最终让业务代码变得异常简洁。
13.6.4. (编码) 第一步:准备基础设施
在开始构建之前,我们需要安装必要的依赖并定义类型。
1. (操作) 安装 axios 与 sonner
1 | pnpm add axios sonner |
为什么选择这两个库?
- axios: 相比原生
fetch,它提供了请求/响应拦截器、请求取消、超时控制等企业级功能。 - sonner: 一个轻量、美观的 Toast 通知库,用于显示全局错误提示。当请求失败时,用户需要立即得到反馈。
2. (编码) 定义统一的响应类型
思考:为什么要定义响应类型?
在真实项目中,后端 API 通常会返回统一的数据结构,比如:
1 | { |
通过定义 TypeScript 类型,我们可以:
- 让编辑器提供智能提示
- 在编译期捕获类型错误
- 明确后端与前端的契约
创建 src/types/api.ts 文件:
1 | touch src/types/api.ts |
写入以下内容:
1 | // src/types/api.ts |
设计要点:
- 泛型
T = unknown: 这是一个关键设计。data的类型在不同 API 中是不同的,通过泛型参数,我们让这个接口可以被复用。- 获取用户列表时:
Result<User[]> - 获取菜单数据时:
Result<Menu[]>
- 获取用户列表时:
- 默认值
unknown: 比any更类型安全。当我们不指定泛型时,TypeScript 不会让我们随意访问data的属性,必须先做类型检查或断言。
13.6.5. (编码) 第二步:创建并配置 axios 实例
现在开始构建 API 客户端的基础层。
创建文件:
1 | mkdir -p src/api |
引入依赖:
1 | // src/api/apiClient.ts |
创建 axios 实例:
1 | // src/api/apiClient.ts (续) |
为什么要创建实例而不是直接使用 axios?
- 隔离配置: 创建独立实例后,我们的配置不会影响项目中可能使用的其他 axios 实例。
- 统一管理: 所有请求都共享同一套配置(baseURL、timeout、headers),修改时只需改一处。
- 便于测试: 在单元测试中,我们可以轻松 mock 这个实例。
13.6.6. (编码) 第三步:请求拦截器——自动注入认证信息
拦截器是实现 “横切关注点” 的最佳位置。什么是横切关注点?就是那些与业务逻辑无关、但每个请求都需要的功能,比如添加 Token。
思考场景:
假设你的应用有 50 个 API 接口,每个都需要认证。如果没有拦截器,你需要在 50 个地方都写:
1 | headers: { Authorization: `Bearer ${token}` } |
这不仅繁琐,而且容易遗漏。拦截器让我们只需写一次:
1 | // src/api/apiClient.ts (续) |
工作原理:
- 业务代码发起请求:
axios.get('/api/menu') - 请求拦截器介入,自动添加
Authorization头 - 带着完整 headers 的请求发送到服务器
现在,无论有多少个 API,我们都不需要手动管理 Token 了。
13.6.7. (编码) 第四步:响应拦截器——统一处理响应与错误
响应拦截器是整个 API 客户端的核心,它解决了我们之前提到的多个问题。
设计目标:
- 自动解包数据:从
{ status, data, message }中提取data,业务代码无需关心外层结构。 - 统一错误处理:任何请求失败都自动弹出错误提示,无需在每个业务调用中写
try-catch。 - 处理特殊状态码:比如 401 未授权时,自动触发登出逻辑。
1 | // src/api/apiClient.ts (续) |
关键设计解析:
为什么直接返回
data而不是完整的response?这是一个有意的设计决策。对比两种方式:
1
2
3
4
5
6// 不使用拦截器解包
const response = await apiClient.get('/menu');
const menuData = response.data.data; // 需要两次 .data
// 使用拦截器解包后
const menuData = await apiClient.get('/menu'); // 直接得到数据后者更简洁,更符合业务思维:我们调用 “获取菜单” 接口,就应该直接得到菜单数据。
为什么需要
@ts-expect-error注释?TypeScript 期望拦截器返回
AxiosResponse类型,但我们故意返回了data(类型为unknown)。这个注释告诉 TypeScript:“我知道我在做什么,这是有意为之的”。这样既能保持类型系统的严格性,又不会产生编译错误。错误处理的分层逻辑:
- 第一个回调处理 HTTP 成功(200-299)但业务失败(status ≠ 0)的情况
- 第二个回调处理网络错误或 HTTP 错误(如 404、500)的情况
两层把所有错误情况都覆盖了。
13.6.8. (编码) 第五步:封装 APIClient 类——提供语义化的 API
现在,我们已经有了一个配置完善的 axiosInstance,但直接使用它的 API(如 request)不够语义化。我们需要一个更友好的接口。
思考:为什么要封装一个类?
对比两种调用方式:
1 | // 直接使用 axiosInstance |
后者更清晰、更符合 HTTP 的语义。同时,通过泛型,我们还能获得完整的类型推断。
1 | // src/api/apiClient.ts (续) |
类型系统的巧妙设计:
1 | axiosInstance.request<Result, T>({ ... }) |
这里有两个泛型参数,它们各有含义:
- 第一个泛型
Result:告诉 axios 响应体(response.data)的结构是Result。 - 第二个泛型
T:告诉 axios 经过拦截器处理后,最终返回的类型是T。
这样设计的好处是:
- 保持了对响应结构的类型约束(必须是
Result类型) - 允许业务代码指定期望的返回类型(比如
Menu[]) - 完全避免了使用
any,保持类型安全
为什么导出单例?
1 | export default new APIClient(); |
我们导出的是一个实例而不是类本身。这是单例模式:
- 整个应用共享同一个
apiClient实例 - 所有请求共享同一套拦截器和配置
- 节省内存,避免重复初始化
13.6.9. (编码) 第六步:创建菜单服务——享受封装的成果
现在,让我们看看这一整套封装给业务代码带来的简化。
创建 src/api/services/menuService.ts 文件:
1 | mkdir -p src/api/services |
写入以下内容:
1 | // src/api/services/menuService.ts |
对比封装前后的代码:
1 | // 封装前:需要手动处理一切 |
这就是封装的力量:
- 类型安全:
<Menu[]>确保返回的数据类型正确 - 自动解包:直接得到菜单数组,无需
response.data.data - 自动认证:Token 已由拦截器注入
- 自动错误处理:失败时自动弹出提示
- 代码简洁:从 15 行缩减到 1 行
13.6.10. 任务小结
我们成功构建了一个企业级的 API 服务层。这不仅仅是 “写了一些代码”,更重要的是学习了如何思考和设计一个可维护的系统架构:
- 从问题出发:识别重复的、容易出错的模式
- 分层设计:将复杂问题拆解成多个简单的层次
- 类型安全:充分利用 TypeScript 的类型系统
- 渐进增强:从简单开始,逐步完善功能
现在,添加任何新的 API 调用都变得异常简单:
1 | const getUserList = () => apiClient.get<User[]>({ url: '/users' }); |
一行代码,类型安全,所有底层逻辑自动处理。这就是优秀架构设计带来的开发体验提升。
在下一章,我们将探讨如何使用全局状态管理器来存储和管理从 API 获取的数据,让整个应用能够响应式地共享这些数据。
13.7 企业级数据流:全局状态管理
在上一章,我们构建了 menuService,它为我们提供了 getMenuList() 这个获取菜单数据的能力。现在的问题是:获取到的数据,应该存放在哪里?
13.7.1. 思考:为什么需要全局状态管理?
如果我们将菜单数据只存在导航栏组件的本地状态(useState)中,那么应用的其它部分(如页面顶部的面包屑)将无法访问这份数据,除非重新请求或通过复杂的 props 传递。这既浪费网络资源,也使组件间产生耦合。
因此,我们需要一个独立于任何组件的“中央仓库”来存储菜单数据,让整个应用都能方便、高效地共享。这就是 全局状态管理。
本项目选择 Zustand,它以其极简的 API 和出色的性能而著称。
13.7.2. (编码) 第一步:安装依赖
我们将安装 zustand 用于全局状态管理,以及 @tanstack/react-query 用于处理异步数据请求。react-query 能极大地简化数据获取、缓存和状态管理的逻辑。
1 | pnpm add zustand @tanstack/react-query |
13.7.3. (编码) 第二步:创建 Menu Store
与用户认证不同,菜单数据通常是相对独立的。因此,我们将为它创建一个专门的 store,而不是将其混入 userStore。
首先,创建 store 目录和 menuStore.ts 文件。
1 | # 创建 store 目录 |
打开 src/store/menuStore.ts,引入所需的模块。
1 | // src/store/menuStore.ts |
13.7.4. (编码) 第三步:定义 Store 的类型与实例
我们来定义 menuStore 的“形状”,并创建它的实例。
1 | // src/store/menuStore.ts (续) |
13.7.5. (编码) 第四步:创建 Selector Hooks
为了实现性能更优的“按需订阅”,我们为 store 的不同部分创建专门的 selector hooks。
1 | // src/store/menuStore.ts (续) |
代码解析:
useMenuTree: UI 组件将使用这个 Hook 来获取菜单数据。只有当menuTreestate 发生变化时,使用此 Hook 的组件才会重渲染。useMenuActions: 当组件只需要调用 action 而不关心数据变化时,可以使用这个 Hook,以避免不必要的重渲染。
13.8 使用 React Query 管理菜单数据
在前面的章节中,我们已经完成了数据准备的全部工作:定义了类型契约、搭建了 MSW 模拟 API、创建了 API 服务层、构建了 Zustand 状态管理。现在,是时候将这些独立的 “零件” 串联起来,让数据真正流动。
本节的核心任务是:使用 React Query 优雅地管理服务端状态,并与 Zustand 配合实现全局数据共享。
13.8.1. 思考:为什么选择 React Query?
在上一章中,我们创建了 menuStore 来存储菜单数据。你可能会问:既然已经有了 Zustand,为什么还要引入 React Query?
让我们对比两种方案:
方案一:纯 Zustand + useEffect
1 | // 在每个需要数据的组件中 |
存在的问题:
- 需要手动管理 loading、error 状态
- 没有缓存机制,每次重新挂载都会请求
- 缺少数据过期和重新验证策略
- 多个组件同时请求会导致重复调用
方案二:React Query + Zustand
1 | // 封装在自定义 Hook 中 |
带来的优势:
- 自动缓存:同一数据不会重复请求
- 状态管理:自动处理 isLoading、isError、data
- 后台更新:数据过期后自动重新验证
- 请求去重:多个组件同时请求只发一次
React Query 专注于 服务端状态,Zustand 专注于 客户端状态,两者结合是现代 React 应用的最佳实践。
13.8.2. (编码) 配置 QueryClientProvider
React Query 需要在应用顶层提供一个 QueryClient 实例,以便在整个组件树中共享数据缓存和请求状态。为了保持良好的代码组织结构,我们将 QueryClient 的配置逻辑抽离到一个专门的模块中。
1. 创建 query-client.ts
首先,在 src/ 目录下创建一个 lib 文件夹(如果它尚不存在),并在其中新建 query-client.ts 文件。这个文件将专门用于创建和配置 QueryClient 实例。
1 | . 📂 prorise-admin |
接着,打开 src/lib/query-client.ts 文件,添加以下代码:
1 | // src/lib/query-client.ts |
配置解析:
staleTime: 1000 * 60 * 5:数据在 5 分钟内被视为新鲜。在此期间,组件的重新挂载不会触发新的网络请求,而是直接使用缓存。gcTime: 1000 * 60 * 10:当一个查询不再有任何活跃的订阅时,其缓存数据会在 10 分钟后被垃圾回收,以释放内存。retry: 1:当请求失败时,React Query 将自动重试 1 次,以应对临时的网络问题。refetchOnWindowFocus: false:默认情况下,当用户切换回浏览器窗口时,React Query 会自动重新获取数据。我们将其禁用,以避免不必要的 API 调用,从而提升用户体验。
2. 在应用入口提供 QueryClient
queryClient 实例创建完毕后,我们需要在应用的根组件使用 QueryClientProvider 将它提供给整个应用。
打开 src/main.tsx,从我们刚刚创建的模块中导入 queryClient 实例,并用 QueryClientProvider 包裹根路由组件 <RouterProvider />。
1 | // ===== React 内置模块 ===== |
至此,我们已经完成了 React Query 的全局配置。通过将 queryClient 抽离到单独的文件中,我们使得 main.tsx 的职责更加单一,同时也提高了数据层配置的可维护性。
13.8.3. (编码) 统一管理 Query Keys
在深入业务逻辑之前,我们首先要建立一个可扩展的 Query Keys 管理体系。将所有与数据请求相关的键(Keys)集中管理,可以有效避免拼写错误,并为后续的缓存失效、乐观更新等高级操作提供坚实的基础。
我们将在 src 目录下创建一个 queries 文件夹,专门用于存放所有 React Query 相关的 key 管理文件。
1. 创建 Query Keys 文件
1 | mkdir -p src/queries |
2. 编写 menuKeys.ts
打开 src/queries/menuKeys.ts,我们为“菜单”这个功能模块定义一套结构化的 key:
1 | // src/queries/menuKeys.ts |
13.8.4. (编码) 创建菜单业务 Hook
遵循功能模块化的原则,我们将为“菜单”功能创建专属的业务 Hook。这种组织方式使得代码职责更清晰,便于按功能查找和维护。
1. 创建业务 Hook 文件
我们将业务 Hook 按模块存放在 src/hooks 目录下:
1 | mkdir -p src/hooks/menu |
2. 编写 useMenuQuery.ts
打开 src/hooks/menu/useMenuQuery.ts,编写封装数据获取与状态同步的逻辑:
1 | // src/hooks/menu/useMenuQuery.ts |
代码解析:职责分离的最佳实践
这个 Hook 精妙地连接了两个强大的状态管理库:
- React Query (服务端状态):作为数据的“请求与缓存层”。它全权负责数据获取、缓存、重新验证以及
loading/error等异步状态的管理。 - Zustand (客户端状态):作为数据的“全局共享层”。它不关心数据从何而来,只负责提供一个简洁、高效的接口,让应用内的任何组件都能轻松访问和订阅菜单数据。
这种分工让每个工具只做自己最擅长的事,是现代 React 应用中处理复杂状态的黄金法则。
13.8.5. (编码) 在布局中使用 Hook
现在,我们可以在 DashboardLayout 中消费这个刚刚创建的业务 Hook,以极简的代码驱动整个布局的渲染逻辑。
打开 src/layouts/dashboard/index.tsx:
1 | // src/layouts/dashboard/index.tsx |
代码之美:声明式数据获取
对比需要手动管理多个 useState 和 useEffect 的传统方案,React Query 将数据获取的复杂过程简化为一行 Hook 调用。我们不再需要编写命令式的流程代码,只需声明“我需要菜单数据”,剩下的交给 useMenuQuery 即可。
- 传统方案:需要 15+ 行代码来手动处理
loading、error、data状态和副作用。 - React Query 方案:1 行 Hook 调用,返回所有需要的状态,代码量减少 80% 以上。
13.8.6. 数据流与架构回顾
至此,我们建立了一条清晰、健壮且可扩展的数据流管道。
数据流完整回顾:
1 | [用户访问 Dashboard] |
任务小结:现代化的分层架构
本节我们不仅完成了数据获取,更重要的是建立了一套现代化的前端数据管理架构。
核心优势:
- 职责清晰:
queries层管 Key,hooks层管业务,api层管请求,store层管状态。 - 代码简洁:将复杂的异步逻辑封装在 Hook 中,UI 层只需关心渲染。
- 可扩展性强:新增功能模块时,只需按照
queries->hooks的模式进行扩展即可,对现有代码无侵入。 - 自动化状态管理:
loading、error、缓存等状态由 React Query 自动处理,极大提升了开发效率和应用稳定性。
架构分层:
1 | UI 层(DashboardLayout) |
现在,菜单数据已经优雅地流入了我们的全局状态中。在下一节,我们将利用这份数据,构建动态的导航菜单。
13.9 数据转换层:从后端到前端的桥梁
在上一章中,我们成功地将菜单数据从 API 获取并存入了 Zustand store。现在,useMenuTree() 可以随时访问到类似这样的数据:
1 | [ |
然而,这份数据是为后端数据库设计的,并不适合直接用于 UI 渲染。本章我们将创建一个转换层,将后端数据转换为 Nav 组件期待的格式。
13.9.1. 思考:为什么需要数据转换?
在开始编码前,让我们先理解一个核心问题:为什么不能直接使用后端数据?
对比两种数据结构:
后端数据(MenuEntity)
1 | { |
Nav 组件需要的数据格式
1 | { |
关键差异:
- 结构不同:后端是扁平树状,前端需要分组结构
- 字段命名不同:
name→title - 图标类型不同:
string→React.ReactNode - 冗余字段:后端有
id、parentId、type、component等 UI 不需要的字段
为什么保持两种格式?
- 后端数据(Zustand store):保持原始、完整的数据,便于后续权限计算、路由生成等
- 前端数据(UI 组件):只包含渲染所需的字段,简洁高效
这种分离遵循了单一职责原则,每一层只关注自己的任务。
13.9.2. 架构设计:转换层的位置
在创建转换工具前,我们需要明确它在架构中的位置:
1 | ┌─────────────────────────────────────┐ |
转换层是连接"数据存储"和"UI 渲染"的桥梁,它的职责非常明确:格式转换,不做其他任何事情。
13.9.3. (编码) 创建数据转换工具
现在创建核心的转换函数。在 src/utils 目录下创建转换工具:
1 | touch src/utils/nav-converter.tsx |
注意使用 .tsx 扩展名,因为我们要在其中使用 JSX(创建 <Icon /> 元素)。
打开文件并编写转换逻辑:
1 | // src/utils/nav-converter.tsx |
代码深度解析:
1. 简洁的类型定义
这里定义了一个内部类型 NavItem:
- 不导出,仅在转换工具内部使用
- 递归类型:
children?: NavItem[]支持无限层级 - 类型安全:避免使用
any
2. 为什么分两个函数?
1 | convertMenuToNavData() // 处理顶层:遍历菜单树 |
这种分层设计使代码更清晰:
- 顶层函数:处理根节点,提取
name和children - 递归函数:转换子菜单项,支持无限嵌套
3. 图标转换的关键
1 | icon: child.icon ? <Icon icon={child.icon} width={18} height={18} /> : undefined |
这一行代码实现了从字符串到 React 元素的转换:
1 | 输入:"solar:widget-bold-duotone" |
为什么这样设计?
- UI 组件可以直接渲染
icon属性,无需再次转换 - 保持了数据的"即用性"
4. 递归处理的精妙之处
1 | children: convertChildren(child.children) |
这行代码实现了无限层级的菜单支持:
1 | 第1层 根节点 |
只要后端数据有 children,这个函数就能处理,无论多少层。
13.9.4. (编码) 创建自定义 Hook
现在我们有了转换工具,需要在合适的地方使用它。我们将创建一个自定义 Hook,提供转换后的数据。
创建文件:
1 | mkdir -p src/hooks/menu |
编写 Hook:
1 | // src/hooks/menu/useNavData.ts |
Hook 设计要点:
1. 为什么要用 useMemo?
1 | const navData = useMemo(() => { |
useMemo 的作用是缓存计算结果。只有当 menuTree 变化时,才会重新执行转换函数。这带来两个好处:
- 性能优化:避免每次组件重渲染都执行转换(转换是递归操作,有性能开销)
- 引用稳定:返回的
navData对象引用保持稳定,避免子组件不必要的重渲染
2. 单一职责
1 | useNavData 的唯一职责:提供转换后的数据 |
这种设计让每个部分都专注于自己的任务,易于测试和维护。
3. 简洁的实现
这个 Hook 非常简单:
- 从 store 获取数据
- 转换数据
- 缓存结果
- 返回
没有复杂的逻辑,没有副作用,职责明确。
13.9.5. 数据流完整回顾
至此,我们建立了一条完整的数据转换管道:
1 | [MSW Handler] |
转换示例:
1 | // 输入(MenuEntity[]) |
关键转换:
- name 保留 - 顶层的
name直接传递给导航分组 - name → title - 子菜单项的
name转换为title - icon 字符串 → Icon 组件 - 字符串转换为可渲染的 React 元素
- children 递归处理 - 无限层级支持
13.10 本章小结与代码入库
第十三章是 Prorise-Admin 架构的“大动脉”。我们完成了一项极其复杂的工程:从零开始,构建了一条完整的、企业级的数据流管道。
回顾本章,我们取得的成就远超“获取菜单”本身:
- 契约先行 (Types): 我们定义了
AppRouteObject、MenuEntity和Result等核心类型,建立了项目数据通信的“唯一事实来源”。 - API 模拟 (MSW): 我们搭建了 MSW 环境,模拟了真实的
GET /api/menu端点,并学会了如何组织handlers和assets。 - 分层 API (Services): 我们构建了
apiClient拦截器和menuService,实现了业务代码与网络请求的完美解耦。 - 现代状态管理 (RQ + Zustand): 我们建立了 React Query (
useMenuQuery) 管理服务端状态、Zustand (menuStore) 管理客户端状态的最佳实践。 - 数据转换层 (Converter): 我们通过
nav-converter.tsx和useNavDataHook,优雅地将“后端数据”转换为“前端 UI 专用数据”。
我们现在的数据流已经完全打通,useNavData Hook 准备就绪
让我们将本章的全部成果提交到仓库。
1 | git add . |













