React+TS+Vite后台系统:动态路由与权限控制实战拆解 简介基于 React、TypeScript 与 Vite 构建的后台管理系统是一份面向初中级前端开发者的完整工程源码适合用来快速搭建中后台项目或系统学习现代前端工程化实践。压缩包共76个文件核心代码由30个tsx、16个ts和9个less组成分别承载组件、逻辑与样式另有json、html、svg、css等配置及静态资源整体仅544KB轻量而结构清晰。工程在src目录下划分了api、store、router、hook、layout、views、components等模块分工明确配置层面包含development、stage、production多环境文件以及eslint、prettier、editorconfig、gitignore、README、LICENSE等工程化配套类型声明和类型定义文件也有专门维护。已有186人学习下载。读者既可以将其作为后台项目基底直接二次开发也可以通过源码快速掌握ReactTypeScriptVite环境的组织方式、常用封装思路和可维护的目录规划对进阶提升很有帮助。1. 从零搭一套 ReactTS 后台框架选型先于业务布局后台管理系统大概是前端领域里「代码量最大、含金量最低」的一类项目权限路由、菜单、请求封装、CRUD 页面每个项目都在重复。我这次拆的这份资源就是一个用 React 18 TypeScript Vite 组合搭建的完整后台管理系统不是那种只能看看的静态模板而是把动态路由、登录鉴权、按钮级权限、Axios 请求封装都落到了代码里拿到了能直接跑业务。这套系统的选型思路很典型Vite 解决开发启动速度和构建效率TypeScript 给接口返回、路由配置、全局状态做类型约束React 负责组件层的灵活组合。适合三类人准备从 CRA 迁移到 Vite 的 React 开发者、需要一个干净底座来做二次开发的后台项目负责人、以及想搞懂「权限控制到底怎么落地」的前端新人。下面我从工程结构开始一路拆到具体的权限实现和二次开发步骤。2. 技术选型与目录结构Vite 构建链路和模块化分层2.1 为什么是 Vite 而不是 CRA 或 Webpack这套项目把构建工具定为 Vite最直接的原因还是「快」。开发模式下 Vite 基于原生 ES Module 按需编译改一个文件只重新转译那个文件不会像 Webpack 那样从入口递归重建依赖图。实际体验里一个 30 个页面左右的后台项目Vite 冷启动基本在 300ms 上下热更新几乎是即时的相比 CRA 动辄 3 到 5 秒的启动速度和 1 秒以上的热更新时间体感差距非常明显。生产构建方面Vite 4 默认使用 Rollup 做打包支持手动分包和动态 import 拆包。后台系统最常见的首屏优化手段——路由懒加载在 Vite 里只需要配合 React.lazy 和 Suspense 使用动态 import 即可不需要额外配置 chunk 文件名规则Vite 会按照动态 import 的来源自动生成 chunk。不过 Vite 也不是没有代价。生态上它比 Webpack 年轻老牌插件比如某些 Mock 平台集成、旧的 Webpack Loader 链可能不支持。这套项目对此的应对方式是插件放在vite.config.ts里统一维护第三方集成需求优先走 API 层做适配不依赖构建插件这样即使某个 Vite 插件不可用也不会卡住整个系统。2.2 目录结构按业务域拆模块不是按文件类型堆文件项目源码目录按职责域划分每个一级目录承担一个明确的工程职责src/ ├── api/ # 接口请求定义一个业务域一个文件 │ ├── auth.ts │ ├── user.ts │ └── dashboard.ts ├── assets/ # 静态资源图片、svg 等 ├── components/ # 全局通用组件 │ ├── Icon/ │ └── FormItem/ ├── hooks/ # 公共 hooksuseTable、useModal、useAuth ├── layouts/ # 主布局侧边栏、顶栏、内容区 │ └── AdminLayout/ ├── router/ # 路由配置 动态路由生成逻辑 │ ├── index.tsx │ ├── routes.ts │ └── guard.ts ├── store/ # Zustand 状态管理 │ ├── authStore.ts │ └── settingStore.ts ├── types/ # 全局类型定义 │ ├── api.d.ts │ └── route.d.ts ├── utils/ # 工具函数request、storage、auth ├── views/ # 页面组件按模块建目录 │ ├── login/ │ ├── dashboard/ │ └── system/ │ ├── user/ │ ├── role/ │ └── menu/ └── main.tsx # 应用入口这个结构的关键点有两个。第一api/目录独立于views/接口请求定义不散落在页面组件里页面只负责调用api.login(data)不直接写axios.post(/api/login)后续做 Mock、做接口替换、做统一错误处理都只动api/和utils/request.ts两个地方。第二router/不是静态配置它配合views/的模块划分根据用户权限动态生成路由表这也是第 3 章要重点拆的部分。types/目录要注意一个 TypeScript 工程细节接口类型单独抽成.d.ts文件并在tsconfig.json里配置include: [src/types]。这样做的好处是所有 API 返回类型和路由类型定义集中在类型层其他模块通过import type引入不会产生运行时依赖。2.3 环境变量与代理配置开发联调的关键一环Vite 项目环境变量的处理是一个容易踩坑的点。这套项目里.env.development和.env.production分别维护不同环境的变量注意 Vite 只会暴露以VITE_开头的变量到客户端代码# .env.development VITE_APP_API_BASE/api VITE_APP_TITLE后台管理系统 VITE_APP_MOCKtrue// vite.config.ts import { defineConfig } from vite; import react from vitejs/plugin-react; import path from path; export default defineConfig({ plugins: [react()], resolve: { alias: { : path.resolve(__dirname, src), }, }, server: { port: 5173, proxy: { /api: { target: http://localhost:8080, changeOrigin: true, rewrite: (url) url.replace(/^\/api/, ), }, }, }, build: { chunkSizeWarningLimit: 1024, rollupOptions: { output: { manualChunks: { react: [react, react-dom, react-router-dom], antd: [antd], }, }, }, }, });这里解释两个关键配置。resolve.alias把指向src目录配合 TS 侧tsconfig.json里的paths: { /*: [src/*] }组件里写/api/auth就能在 Vite 和 TypeScript 两边同时解析。server.proxy解决开发环境跨域前端请求/api/login代理层把/api前缀去掉后转发到http://localhost:8080/login这也是后端接口通常不带/api前缀时最常见的 rewrite 写法。build.rollupOptions.output.manualChunks是生产构建性能优化项把 react 相关和 antd 拆成独立 chunk。后台管理系统里第三方库体积占比最大做一次手工分包首屏加载时浏览器可以并行去拉这几个 chunk而不是等一个巨大的 bundle 下载完。如果项目体积继续膨胀还可以把echarts这类按需不彻底的重型库也拆出来。3. 核心实现拆解动态路由、权限控制与请求层封装3.1 动态路由从静态配置到权限过滤后台管理系统的路由不能像官网那样全量注册因为不同角色看到的菜单和页面不一样。这套项目的router/routes.ts拆成两部分基础路由登录页、404 页、首页和需要权限的业务路由表。业务路由表统一声明在后端返回的菜单数据里前端只维护一份「全部路由映射表」再根据当前用户权限动态过滤。动态路由的核心逻辑不复杂但需要三个角色协作后端返回的权限标识、前端的路由映射表、以及路由守卫函数。权限标识通常长这样[ { path: /system/user, component: system/user/index, meta: { title: 用户管理, icon: user } }, { path: /system/role, component: system/role/index, meta: { title: 角色管理, icon: team } } ]前端拿到这份数据后通过 import.meta.glob 批量加载视图组件// router/generate.ts import type { RouteRecordRaw } from vue-router; const modules import.meta.glob(/src/views/**/*.tsx); /** * 将后端返回的菜单数据转换为 RouteRecordRaw[] * param menus 后端接口返回的菜单数组 */ export function buildRoutes(menus: AppMenu[]): RouteRecordRaw[] { return menus .map((menu) { const component modules[/src/views/${menu.component}.tsx]; if (!component) return null; const route: RouteRecordRaw { path: menu.path, name: menu.name, component, meta: { title: menu.meta.title, icon: menu.meta.icon, permissions: menu.meta.permissions || [], }, }; if (menu.children?.length) { route.children buildRoutes(menu.children); } return route; }) .filter(Boolean) as RouteRecordRaw[]; }这里的关键点在import.meta.glob(/src/views/**/*.tsx)。Vite 在编译时会扫描src/views下的全部.tsx文件生成一个路径映射表并且默认按懒加载方式处理——每个组件单独拆 chunk只有访问到对应路由时才加载。如果不用 import.meta.glob 而是手写 import 语句动态路由就没法做到懒加载后端加个菜单前端还得同步改代码。路由守卫在主入口统一处理。React Router v6 里的useRoutes搭配beforeEach这个概念React 生态没有现成的全局守卫需要用一层路由拦截组件来实现// router/guard.tsx import { useLocation, Navigate } from react-router-dom; import { useAuthStore } from /store/authStore; export default function AppGuard({ children }: { children: React.ReactNode }) { const { token, userInfo } useAuthStore(); const location useLocation(); if (!token) { return Navigate to/login replace state{{ from: location.pathname }} /; } // 已登录但刷新后丢失动态路由时重新生成 if (!userInfo?.menus?.length location.pathname ! /login) { // 触发重新拉取用户信息和动态路由 } return {children}/; }AppGuard 包裹在所有路由的外层作用有两个拦截未登录访问、处理刷新后的路由恢复。刷新页面时动态路由会丢失这时需要根据状态重新拉取菜单数据并重建路由这是后台系统最常见的「翻车点」之一第 5 章细说。3.2 请求层封装一个 Axios 实例管好所有接口请求API 层统一走utils/request.ts维护的 Axios 实例不额外引入 react-query 或 swr原因是后台系统的数据请求模式比较固定封装好错误处理、Loading 状态和业务码判断直接调用更直观。// utils/request.ts import axios from axios; import { message } from antd; import { useAuthStore } from /store/authStore; const request axios.create({ baseURL: import.meta.env.VITE_APP_API_BASE, timeout: 15000, }); // 请求拦截器附加 token request.interceptors.request.use((config) { const token useAuthStore.getState().token; if (token) { config.headers.Authorization Bearer ${token}; } return config; }); // 响应拦截器统一处理业务码和错误 request.interceptors.response.use( (response) { const res response.data; // 业务码非 0 视为错误 if (res.code ! 0) { message.error(res.message || 请求失败); return Promise.reject(new Error(res.message)); } return res.data; }, (error) { if (error.response?.status 401) { useAuthStore.getState().logout(); window.location.href /login; } else { message.error(error.message || 网络异常); } return Promise.reject(error); } );注意这里传参的约定:对应 POST 请求最常用的方式是request.post(url, data)参数在请求体GET 请求用request.get(url, { params })。为了减少页面里的重复拼参在api/auth.ts里做了一层接口函数集中管理:// api/auth.ts import request from /utils/request; export interface LoginParams { username: string; password: string; } export interface LoginResult { token: string; refreshToken: string; } export function loginApi(data: LoginParams): PromiseLoginResult { return request.post(/auth/login, data); } export function getUserInfoApi(): Promise{ userId: string; nickname: string; roles: string[] } { return request.get(/auth/userinfo); }这样设计最大的收益是类型安全。所有接口的入参和返回类型都在 api 层定义页面组件里调用loginApi({ username, password })时TS 会做完整的参数类型校验。如果后端改了字段名编译阶段就能发现不用等接口联调时在浏览器控制台里排查半天。3.3 状态管理Zustand 替代 Redux Toolkit状态管理选了 Zustand 而不是 Redux Toolkit主要还是考虑模板代码量。Redux Toolkit 虽然已经简化了 createSlice但依然需要配置 store、编写 reducer、处理 Provider 嵌套。Zustand 的核心 API 就是一个 create 方法直接在组件外维护 store用 hooks 的方式在组件内获取状态和更新方法// store/authStore.ts import { create } from zustand; import { persist } from zustand/middleware; import { loginApi, getUserInfoApi } from /api/auth; interface AuthState { token: string; userInfo: { nickname: string; roles: string[] } | null; login: (payload: { username: string; password: string }) Promisevoid; logout: () void; fetchUserInfo: () Promisevoid; } export const useAuthStore createAuthState()( persist( (set, get) ({ token: , userInfo: null, login: async (payload) { const { token } await loginApi(payload); set({ token }); }, logout: () { set({ token: , userInfo: null }); }, fetchUserInfo: async () { const data await getUserInfoApi(); set({ userInfo: data }); }, }), { name: auth-storage } ) );Zustand 的 persist 中间件会把 store 内容同步到 localStorage刷新页面后状态自动恢复这也是动态路由恢复的基础。注意一个细节登录接口返回 token 后只把 token 写入 store用户信息等拿到fetchUserInfo后再填充。这样登录和拉取用户信息分开处理权限菜单的数据来源更清晰。4. 从登录到菜单Auth 流程和权限体系的落地过程4.1 登录流程与 Token 存储的细节登录页的逻辑整体不复杂但有几个细节决定成败。用户输入账号密码后前端拿到 token 写入 store然后立刻调用fetchUserInfo拉取用户信息拿到后跳转首页。这里有一个顺序问题必须注意// views/login/index.tsx const login async (values: { username: string; password: string }) { setLoading(true); try { await authStore.login(values); await authStore.fetchUserInfo(); const { from } location.state || { from: / }; navigate(from, { replace: true }); } finally { setLoading(false); } };先用 store 里的 login 拿 token再用 fetchUserInfo 拿用户信息和菜单数据。不能把 fetchUserInfo 放到路由跳转之后去做否则目标页面渲染时 UserInfo 还是 null依赖它的动态路由和菜单就没法同步展示。跳转目标使用location.state?.from而不是固定写死首页这样用户被拦截到登录页之后登录成功会回到原来想访问的页面而不是每次都踢回首页。Token 的存储选择了 Zustand persist 中间件默认的 localStorage。从安全角度讲XSS 攻击可以拿到 localStorage 里的 token,更严格的做法是放在内存中配合刷新后重新换取。不过后台类系统通常在可信内网环境同时刷新页面不丢登录态的需求优先级更高用 persist 持久化是务实的选择。如果后续对安全性有更高要求可以换成 httpOnly Cookie 方案但需要后端配合。4.2 动态菜单渲染权限数据到侧边栏的完整链路动态菜单的渲染链路是后端返回菜单树 → 递归转换为路由表 → 侧边栏组件读取同一份菜单树 → 点击菜单跳转到对应路由。菜单树不只用于路由也是侧边栏的数据源两份数据共用,保证「菜单显示哪些页面路由就能访问哪些页面」。// layouts/AdminLayout/components/SiderMenu.tsx import type { MenuProps } from antd; import { Menu } from antd; import { useNavigate } from react-router-dom; import { useAuthStore } from /store/authStore; export default function SiderMenu() { const menus useAuthStore((state) state.userInfo?.menus || []); const navigate useNavigate(); const items useMemo(() convertMenusToMenuItems(menus), [menus]); const onClick: MenuProps[onClick] ({ key }) { navigate(key); }; return Menu modeinline items{items} onClick{onClick} /; }这个组件极简但内部做了一件事把后端返回的菜单结构转换成 antd Menu 需要的 items 结构。转换函数递归处理 children核心是给每一条菜单数据补上 React Router 需要的路径映射。这里不做任何权限过滤因为数据源本身就是后端根据当前用户过滤过的。4.3 按钮级权限接口返回和前端指令的混合控制菜单权限控制完后更细的一层是按钮权限。比如用户管理页的「新增」「删除」按钮不同角色可能只能看到其中一部分。这套项目按钮权限点和路由权限点共用同一份数据来源即后端返回的权限标识数组。前端实现一个权限判断 hook// hooks/usePermission.ts import { useAuthStore } from /store/authStore; export function usePermission() { const { userInfo } useAuthStore(); const hasPermission (code: string) { return userInfo?.permissions?.includes(code) ?? false; }; return { hasPermission }; }页面里的用法// views/system/user/index.tsx const { hasPermission } usePermission(); return ( div {hasPermission(system:user:add) Button typeprimary新增用户/Button} {hasPermission(system:user:delete) Button danger删除/Button} /div );按钮级权限的判据是「权限标识集合里有没有这个 code」而不是「角色名是不是 admin」。这两者有本质区别权限标识是原子的、可组合的角色名是粗粒度的。硬编码「管理员才能看这个按钮」看起来省事一旦出现一个新角色需要部分管理权限就得回去改代码。后端返回的 permissions 数组通常和菜单数据合并在一份接口里前端拿到后同时用于路由生成和按钮判断这样不做两次网络请求。权限标识的命名规范建议模块:子模块:操作比如system:user:add、system:role:delete这种约定在多人协作时不容易撞名。5. 避坑记录Vite React TS 开发中的 5 个高频问题与排查思路5.1 Vite 代理失效接口 404 但页面正常现象登录页能打开但点击登录后接口返回 404浏览器 Network 里能看到请求地址是http://localhost:5173/api/login后端服务明明在跑。原因vite.config.ts里server.proxy配置正确但后端接口路径是http://localhost:8080/auth/login而不是http://localhost:8080/api/auth/login,代理转发时没做 rewrite/api前缀带到了后端。解决prox 配置里加rewrite: (url) url.replace(/^\/api/, )。这是 Vite 代理最常见的翻车点我一般排查顺序先看 Network 里的请求 URL 和后端日志确认请求到底到没到后端再检查代理配置和 rewrite 规则。5.2 刷新后菜单消失回到登录页现象登录后一切正常按 F5 刷新页面菜单全部消失甚至直接跳回登录页。原因动态路由存在内存里刷新后 JS 重新执行动态路由自然没了。更隐蔽的是 store 里持久化了 token但 menus 没有持久化——或者持久化了但组件还没拿到数据时路由守卫看到「token 存在但 menus 为空」就认为未登录。解决在 AppGuard 里加一道「restore 检查」token 存在但 menus 为空时先执行 fetchUserInfo 再渲染。同时 Zustand persist 只持久化 token 和 userInfo不要把临时路由数据也塞进去否则刷新时拿到过期菜单权限变更是个隐患。5.3 import.meta.glob 的路径必须是字面量不能用变量现象动态路由生成时代码不报错但点击菜单后白屏控制台提示找不到模块。原因import.meta.glob(/src/views/**/*.tsx)依赖 Vite 编译期的静态分析路径必须是字符串字面量。如果写import.meta.glob(/src${dir}/**.tsx)这种变量拼接Vite 无法扫描出实际文件。解决glob 的路径始终写全量字面量使用变量只能影响匹配后的拼接逻辑不影响扫描路径。另外注意 glob 默认不包含index.tsx这类入口文件以外的文件需要按实际文件结构调整通配符。5.4 组件库样式在生成环境丢失现象开发环境样式正常npm run build后部署按钮、表格样式全部丢失只剩裸 HTML。原因项目用 less 或 css modules 时组件库的样式文件被 extract 到独立 css 文件但生产环境的 static 资源路径配置错误。具体到 Vitebuild.assetsDir或base配置影响了资源加载路径。解决检查vite.config.ts里base是否设置为/,如果部署在子路径下base 要改成相对路径或子路径名称。我用一个简单原则开发环境永远正常部署就出问题时先看 html 里引用的 css/js 路径是不是正确再排查服务端静态资源映射。5.5 刷新页面路由 404需要后端配合现象点击页面内跳转一切正常但浏览器直接访问http://xxx.com/system/user时 404。原因应用是 SPA前端路由由 React Router 控制但服务器收到请求后会按物理目录查找文件/system/user没有对应文件自然 404。这和 Vite 无关但凡是 history 模式路由都必须处理。解决开发环境 Vite 自带 history fallback 不用管生产环境需要服务端把所有非静态文件请求 rewire 到index.html。常见做法是 Nginx 配置try_files $uri $uri/ /index.html。如果项目已经用 hash 路由则没有这个问题但 URL 会带/#/不够美观。6. 二次开发的新模块接入从接口定义到菜单注册的完整落地流程前面把核心机制拆完最后落地一个具体场景给这套后台加一个「订单管理」模块包含订单列表和订单详情页并配置好菜单权限。如果你的团队接了这套项目后续所有新模块都可以按这个流程走完全不用改框架层代码。第一步定义类型和 API。types/api.d.ts里补充订单相关的接口类型,api/order.ts新建接口函数:export interface OrderListParams { page: number; pageSize: number; status?: string; } export interface OrderInfo { id: string; orderNo: string; amount: number; status: pending | paid | cancelled; createTime: string; } export function getOrderList(data: OrderListParams): Promise{ list: OrderInfo[]; total: number } { return request.post(/order/list, data); }第二步创建页面组件。在views/order/index.tsx写列表页内部用getOrderList请求数据用 antd Table 渲染。这里不建议引入重型表格封装后台项目维护时间一长过度封装的表格组件反而会成为包袱基础组件搭配几个通用 hook 更灵活。第三步配置路由和菜单。路由表是后端返回的前端需要同步维护「全部路由映射表」菜单项正常走后端菜单管理新增即可前端的映射表负责把后端路径映射到真实组件文件。这一步要特别小心路径对不上问题后端存的component: order/index前端就必须在views/order/index.tsx有对应文件否则 glob 匹配失败路由静默失效。第四步验证权限。给角色分配订单菜单后用无权账号登录确认侧边栏不出现订单入口直接访问/order会 404——这是动态路由过滤后的正常表现。如果希望无权访问时提示「403 无权限」而不是 404需要额外加一个兜底路由加判断视觉上更友好但实现多一层守卫。开发这套后台的过程中我最大的感受是后台项目真正费精力的不是 CRUD 页面本身而是围绕权限、路由、请求层这些基建。它们相互耦合——路由依赖用户信息用户信息依赖请求层请求层依赖状态管理任何一个环节设计得不顺手后面每个新模块都会跟着难受。所以从那以后我每接一个新后台项目都强制先花半天把路由生成、权限判断、请求封装这三层链路理清楚再开始写业务页面这套「先把地基打牢再做上层」的流程确实能省掉后面大量的返工。希望这篇拆解对你跑通和改造这套项目有帮助。本文还有配套的精品资源点击获取