这篇是《使用vitereacttsAnt Design开发后台管理项目》系列文章的第4篇。前三篇我们把项目初始化、目录结构、布局菜单、路由拆解、状态管理这些基础工作做完了项目已经能跑起来页面也能正常跳转。但这会儿往往是最慌的时候登录之后的权限怎么做接口怎么统一封装页面要上图表、要适配大屏怎么办最后怎么区分测试环境打包这些都属于“项目能跑”和“项目能上线”之间的硬骨头也是本篇要解决的问题。我默认你已经有一份 Vite React TypeScript Ant Design 5 的可用项目如果是从零开始建议先把前三篇的基础搭完再来读这篇。这篇的内容更偏实战我会直接把方案、代码和踩过的坑一起放出来方便直接抄作业。1. 权限控制先拆掉“假权限”再做动态路由1.1 先把权限模型想清楚再写代码后台管理系统的权限表面上看起来是“登录之后显示哪些菜单”实际上包含三层第一层是路由能不能访问第二层是菜单能不能看到第三层是按钮点不了。这三层如果全在前端写死那叫假权限。碰上稍微讲究一点的项目光登录后返回一个用户角色根本不够用。我比较推荐的做法是基于 RBAC 的简化版用户登录后后端返回当前用户的权限码列表比如[system:user:list, system:role:add]前端拿这串权限码去匹配路由表和菜单表。路由表里能找到且有权限的才挂载到路由实例上菜单只渲染有权限的路由按钮通过组件或自定义 Hook 判断权限码是否存在。为什么不用角色直接判断因为项目一旦变大角色和菜单的对应关系会非常容易被改乱。权限码是原子化的一个接口对应一个权限点不管角色怎么变前端只需要问“你有没有这个权限码”逻辑简单清晰。你可以把权限码理解成钥匙角色只是一个装钥匙的钥匙串真正开门时看的是钥匙不是钥匙串。这里需要特别注意类型约束。权限码最好用 TypeScript 的字符串字面量联合类型维护而不是散落的魔法字符串export type PermissionCode | dashboard:view | system:user:list | system:user:add | system:role:list | system:role:update; export interface AuthUser { id: string; name: string; permissions: PermissionCode[]; }这样写的好处是后续写路由 meta、写按钮组件时都有类型提示权限码拼错直接编译报错不用等上线后才发现某个按钮显示错了。这也是 TypeScript 在后台项目里最大的价值之一。1.2 动态路由生成与刷新白屏静态路由好做直接在 router 里createBrowserRouter配完就行。问题出在动态路由不同用户登录进去看到的菜单不同这意味着路由表不能一次性全部注册必须登录后根据权限动态挂载。我的实现思路分三步。第一步把所有需要权限的路由放在一个单独的asyncRoutes数组里每个路由的 meta 上标记需要的权限码。第二步登录后请求用户信息拿到权限码列表过滤出用户能访问的路由。第三步用router.addRoute逐个挂载。这里有个特别容易踩的坑Vite 动态导入文件名不能纯变量否则打包出来的 chunk 会被拆得乱七八糟严重时会变成每个文件单独一个 chunk。建议用import.meta.glob统一读取页面模块const modules import.meta.glob(/pages/**/*.tsx);然后通过路径匹配到对应的加载函数比如modules[/pages${route.component}.tsx]。这样既能保证路由组件被正确分包懒加载也不会出现开发环境正常、生产环境白屏的问题。刷新白屏是另一个高频问题。原因很好理解页面刷新后pinia里的用户信息被清空了路由守卫一进来发现没有权限码直接把用户踢回登录页或者因为路由还没挂载完成导致找不到路径。解决办法是把用户信息和权限码持久化到 localStorage刷新时先恢复再进路由。或者更稳妥一点在路由守卫里判断当前路由是否已经在动态路由表中不在就重新生成一次动态路由router.beforeEach(async (to, _from, next) { const userStore useUserStore(); if (!userStore.token) { if (to.path /login) return next(); return next(/login?redirect${encodeURIComponent(to.fullPath)}); } if (userStore.routesLoaded) return next(); try { const routes await userStore.generateRoutes(); routes.forEach((route) router.addRoute(route)); return next({ ...to, replace: true }); } catch (error) { userStore.reset(); return next(/login); } });注意next({ ...to, replace: true })这一段。如果不写路由刚添加完就立即next()有可能因为路由匹配已经结束了导致跳转目标仍然是原来的空白页。加一次重定向让 Vue Router 重新匹配一次新挂载的路由刷新白屏问题基本就没了。1.3 按钮级权限React 里别用指令思维从若依这类 Vue 后台管理系统转过来的同学容易习惯性地想做类似v-permission的自定义指令。React 没有指令的概念硬要做也能搞但更自然的方案是封装成组件。import type { PermissionCode } from /types/auth; interface AuthProps { permission: PermissionCode; children: React.ReactNode; } export default function Auth({ permission, children }: AuthProps) { const hasPermission useAuthPermission(permission); if (!hasPermission) return null; return {children}/; }使用的时候很简单Auth permissionsystem:user:add Button typeprimary新增用户/Button /Auth再往外延伸一点可以封装一个useAuthPermissionHook判断逻辑集中在里面。这样临时写着玩的小页面可以用 Hook正式页面用组件整个权限判断链路在代码里一眼就能看清。还要提醒一句前端按钮禁用不等于后端接口安全真正重要的是后端接口做权限校验。前端隐藏按钮只是提升体验别把安全希望寄托在这层。2. 请求层封装 axios 时把类型和安全一起解决2.1 axios 实例与拦截器设计后台管理系统里接口调用频率很高如果每个页面都单独写axios.post的完整配置项目后期改 API 域名或者加公共参数时就非常痛苦。我的习惯是工程项目启动后第一件事就是把请求层封装好。axios 实例基础配置如下import axios, { type AxiosInstance, type AxiosRequestConfig } from axios; const service: AxiosInstance axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL || /api, timeout: 15000, });请求拦截器里通常只做两件事带 token、带必要的公共参数。响应拦截器里做的事情多一点统一处理 HTTP 错误码、处理业务 Code、处理 token 过期。业务 Code 的处理是重点我见过很多项目把业务失败和网络失败混在一起处理页面里每个接口都要写if (res.code ! 200)这是灾难。我这边的约定是HTTP 状态 200 时再看业务code非 2xx 时统一弹出错误提示只在特定场景下放开。核心代码如下service.interceptors.response.use( (response) { const res response.data; if (res.code ! 0) { if (res.code 401) { // 登录过期清理本地状态并跳转登录页 return Promise.reject(new Error(登录已过期)); } message.error(res.message || 请求失败); return Promise.reject(new Error(res.message || 请求失败)); } return res; }, (error) { message.error(error.message || 网络异常); return Promise.reject(error); } );这里我把业务成功的code定成了0不同项目可能用200或000000关键是全公司统一。如果后端接口还没规范好前端可以在拦截器层面做一层兼容避免页面代码被后端的小变动反复改。2.2 用泛型和自定义 Hook 让请求有类型、有状态axios 自带泛型支持但它默认的response.data类型是any这会让 TypeScript 在接口层形同虚设。我一般会封装一个带泛型的请求方法把响应体结构和业务数据类型彻底分开export interface ApiResponseT unknown { code: number; message: string; data: T; } export function requestT(config: AxiosRequestConfig): PromiseT { return service.requestApiResponseT(config).then((res) res.data.data); }调用时直接指定业务数据类型interface UserItem { id: string; name: string; email: string; } const userList await requestUserItem[]({ url: /user/list, method: get, });此时userList的类型是UserItem[]所有字段都有提示再往前端页面传参时不容易写错。这就是 ts 泛型的实际价值不是用来炫技的是为了让接口数据在代码里流动时类型不丢失。时间久了你会发现页面组件里大量重复的 loading、error、data 状态管理非常枯燥而且很容易忘记在请求结束时恢复 loading。我会抽一个useRequestHook把请求状态统一管理起来function useRequestT(fetcher: () PromiseT, deps: unknown[] []) { const [data, setData] useStateT | null(null); const [loading, setLoading] useState(false); const [error, setError] useStateError | null(null); const run useCallback(async () { setLoading(true); setError(null); try { const result await fetcher(); setData(result); } catch (e) { setError(e as Error); } finally { setLoading(false); } }, deps); useEffect(() { run(); }, [run]); return { data, loading, error, refresh: run }; }这里要注意一个很隐蔽的问题组件卸载后请求才返回此时再setState会触发 React 的警告严重的还会造成数据竞争。处理方式是在 Hook 内部加一个mounted标志位或者用AbortController在卸载时取消未完成的请求。最简单可靠的方案是加一个 mounted 判断useEffect(() { let isMounted true; // 请求完成后判断 if (isMounted) setData(result); return () { isMounted false; }; }, []);2.3 Mock 环境让后端还没写好接口也能开工很多团队开发后台管理时前后端并行前端依赖的接口还没写老办法是写死数据等后端接口出来再一个个替换效率极低。现在的主流做法是本地 Mock。Vite 生态里我用得比较多的是vite-plugin-mock它支持在本地开发环境里拦截请求按 Mock 文件直接返回模拟数据而且和真实请求的代码写法一致后端接口开发完成后只需要关掉 Mock前端页面代码一行不用改。配置非常简单import { viteMockServe } from vite-plugin-mock; export default defineConfig({ plugins: [ viteMockServe({ mockPath: mock, enable: true, }), ], });Mock 文件就放在mock目录下比如mock/user.tsexport default [ { url: /api/user/list, method: get, response: () ({ code: 0, data: [ { id: 1, name: 张三, email: zhangsanexample.com }, { id: 2, name: 李四, email: lisiexample.com }, ], message: ok, }), }, ];这里有个坑需要提醒Mock 是以请求路径匹配的如果 axios 的baseURL配置了/api那么 Mock 的 url 也要写/api/user/list否则匹配不上。很多同学从 Vue3 的 Mock 教程迁移过来时报错十有八九是路径前缀对不上。另外Mock 只用于开发环境生产构建时一定不要开启最好用环境变量控制enable不要图省事直接写死true。3. 图表与大屏后台管理系统的高级感往往在这里3.1 ECharts 按需引入与组件封装后台管理系统里图表几乎是刚需。数据看板、报表分析、销售趋势这些页面一上 ECharts整体质感立刻不一样。但 ECharts 全量引入的体积太大了首屏加载会白白多出 1MB 以上的 JS。正确的做法是按需注册。ECharts 5 以后推荐用echarts/core方式引入import * as echarts from echarts/core; import { BarChart, LineChart, PieChart } from echarts/charts; import { GridComponent, TooltipComponent, LegendComponent, DataZoomComponent, } from echarts/components; import { CanvasRenderer } from echarts/renderers; echarts.use([ BarChart, LineChart, PieChart, GridComponent, TooltipComponent, LegendComponent, DataZoomComponent, CanvasRenderer, ]);具体项目用了哪些图表类型就在use里注册哪些。不要一次性全注册那和全量引入没区别。SVG 渲染器和 Canvas 渲染器也需要选一个后台大数据量图表用 Canvas普通交互图表用 SVG 更轻。React 里封装一个通用 Chart 组件比每个页面自己init、setOption要干净得多interface ChartProps { option: echarts.EChartsCoreOption; height?: number; } function Chart({ option, height 400 }: ChartProps) { const containerRef useRefHTMLDivElement(null); const chartRef useRefecharts.EChartsType | null(null); useEffect(() { if (!containerRef.current) return; chartRef.current echarts.init(containerRef.current); const observer new ResizeObserver(() { chartRef.current?.resize(); }); observer.observe(containerRef.current); return () { observer.disconnect(); chartRef.current?.dispose(); }; }, []); useEffect(() { chartRef.current?.setOption(option); }, [option]); return div ref{containerRef} style{{ width: 100%, height }} /; }这个组件解决了一个很常见的 UI 问题图表容器在 Tab 切换、抽屉打开、列表展开时宽度从 0 变成有值图表如果不 resize 就会画得很奇怪。通过ResizeObserver监听容器尺寸变化就能自动触发chart.resize()不用每次切换都手动调用。需要注意dispose必须写在清理函数里否则组件卸载后 ECharts 实例还挂在内存中页面频繁切换会出现卡顿甚至崩溃。这是 React 图表组件最容易忽略的性能问题。3.2 大屏适配vw/vh 换算比 rem 更省心大屏项目是后台管理系统的一个常见变体数据监控大屏、指挥调度大屏用的还是 React ECharts 这一套。大屏适配最烦人不同分辨率的屏幕显示效果天差地别。目前主流的适配方案有这么几种我直接对比一下适配方案优点缺点适用场景vw/vh 换算计算简单性能好所见即所得字体和图表文字需要单独处理后台嵌入式大屏transform scale 缩放整个容器等比缩放字体图表一起缩放页面边缘容易留空白交互坐标可能偏移投屏类大屏rem 动态根字体兼容性好字体自然适配需要动态设置根字体存在字号跳动问题偏移动端 H5 的大屏我个人的项目里用得最多的是 vw/vh 方案。设计稿一般是 1920 x 1080开发时把设计稿里的像素值直接换算成 vw。比如设计稿上一个模块宽度是 480px那对应480 / 1920 * 100 25vw。手动换算太累我直接配postcss-px-to-viewport插件写代码时继续用 px// postcss.config.js export default { plugins: { postcss-px-to-viewport: { viewportWidth: 1920, viewportHeight: 1080, unitPrecision: 3, viewportUnit: vw, fontViewportUnit: vw, selectorBlackList: [.ignore-], }, }, };这样写width: 480px编译后会自动变成width: 25vw。注意viewportHeight只对 vh 单位生效默认情况下宽度方向的 vw 才是主力。遇到不想被换算的样式加一个.ignore-前缀就行。有个坑必须提醒postcss 插件只处理 CSS 文件里的 pxECharts option 里的数字大小比如fontSize: 12是不会被自动换算的。大屏上的图表文字要想跟上缩放需要在配置 option 时传入一个换算函数或者在 window resize 时重新计算字号并setOption。所以大屏页面我通常把图表初始化和适配逻辑单独封装避免在页面组件里堆一大堆重复代码。3.3 图表的尺寸陷阱与 resize 处理图表还有一个非常典型的坑容器初始化时是隐藏的比如在 Tabs 的第二个页签里切过来再渲染ECharts 拿到的容器宽度是 0画出来就是一团糊。这个问题不仅在后台管理系统里常见做 react 大屏时也容易出现。解决办法之一是不要在容器隐藏时初始化等 Tab 激活后再渲染图表但这样每次切换都要重新初始化体验一般。更好的办法是用ResizeObserver容器从隐藏变为显示时尺寸变化会触发回调这时调用chart.resize()重新计算。如果容器始终保持display: noneResizeObserver 可能不会触发这时候只能再补一招在 Tab 切换后手动调用一次resize。所以我把 Chart 组件里的 chart 实例暴露出来方便外面调用useImperativeHandle(ref, () ({ resize: () chartRef.current?.resize(), }));另外图表频繁 resize 会引发性能问题尤其是数据量大、动画效果多的图表。ResizeObserver 回调里加个防抖会稳妥一些我一般用setTimeout到 200ms 再执行 resize屏幕拖动的时候不会密集触发。这个细节很少出现在教程里但实际大屏项目中非常管用。4. 多环境构建与上线优化从 npm run dev 到部署不慌4.1 环境变量与 vite build --mode test 到底怎么用后台管理系统开发到后期一定会遇到环境问题本地开发环境用测试接口测试环境用测试接口但日志要全开生产环境用正式接口还要关掉日志。如果每次上线前手动改代码里的接口地址太容易出事故。Vite 的方案是环境变量文件加 mode。Vite 启动时默认读取.env、.env.development、.env.production等文件文件名里的后缀就对应 mode。你可以额外定义一个测试环境的 mode比如# .env.test VITE_APP_TITLE后台管理系统-测试环境 VITE_API_BASE_URL/api然后在package.json里加一条命令{ scripts: { dev: vite, build: vite build, build:test: vite build --mode test } }执行npm run build:test时Vite 会加载.env.test文件同时import.meta.env.MODE的值就是test。使用方式const baseURL import.meta.env.VITE_API_BASE_URL; const isTest import.meta.env.DEV; const isProd import.meta.env.PROD;这里有一个容易踩的坑很多人会把process.env.NODE_ENV的习惯带进来在 Vite 项目里写process.env.VITE_XXX结果取到 undefined。Vite 项目里读环境变量要用import.meta.env带VITE_前缀的变量会直接暴露给前端代码。没有前缀的变量不会自动暴露要注意。还有vite build --mode test加载的是.env.test但import.meta.env.DEV和import.meta.env.PROD是根据NODE_ENV判断的build 时NODE_ENV是production所以即使 mode 是 testimport.meta.env.PROD仍然是true。这个细节在判断是否加日志、是否开 Mock 时特别重要不要以为 mode 是 test 就代表非生产。4.2 拆包、Gzip 和缓存策略项目上线后打开控制台看 Network如果首屏最多的那个 JS 文件有 2MB 以上说明默认打包配置没有优化。Vite 基于 Rollup手动拆包并不复杂。一个后台管理项目最大头通常是三块React 全家桶、Ant Design 组件库、ECharts 图表库。这三块基本不会频繁变可以把它们单独拆出来让浏览器缓存得更久业务代码频繁改只缓存一小块就够了。build: { rollupOptions: { output: { manualChunks: { react: [react, react-dom, react-router-dom], antd: [antd, ant-design/icons], echarts: [echarts, echarts/core, echarts/charts, echarts/components], }, }, }, },打包之后这些第三方库会形成独立的 chunk配合上 gzip 压缩体积能再降一大截。Vite 压缩 gzip 我用vite-plugin-compressionimport viteCompression from vite-plugin-compression; plugins: [ viteCompression({ algorithm: gzip, ext: .gz, threshold: 10240, }), ],这里threshold: 10240表示只有超过 10KB 的文件才压缩避免一堆小文件压缩后反而更占空间。如果服务端配置了 br 压缩也可以生成.br文件权衡优先级是 br gzip但浏览器兼容性更好的是 gzip我一般两个都开让服务端根据Accept-Encoding自己选。静态资源缓存方面Vite 默认会给带 hash 的静态资源设置immutable缓存文件名一变 hash 就变不会出现改代码不生效的问题。真正要防的是index.html被缓存那样无论怎么重新打包用户拿到的还是旧页面。上线时index.html要设置为no-cache这是一个容易忽略但影响很大的细节。4.3 上线前必须检查的五个点我在给团队做内部培训时常说上线前的检查不是靠感觉而是靠清单。下面这五个点是我踩过坑之后整理出来的分享出来供你参考。第一路由模式。如果用的是BrowserRouterhistory 模式部署在 Nginx 时必须配置try_files否则用户直接访问某个子路由或者刷新子路由时会出现 404。Nginx 里只需要加一句try_files $uri $uri/ /index.html;。如果部署在子路径下还要设置 Vite 的base和路由的basename不然静态资源路径会全部错乱。第二环境变量。确认VITE_API_BASE_URL在当前构建环境里指向了正确的接口地址尤其是打包测试环境给客户演示时接口地址串了会很尴尬。构建后可以打开dist里的 JS 文件搜一下接口域名确认是预期环境。第三sourcemap。默认情况下 Vite build 不生成 sourcemap但如果有人改过配置把build.sourcemap开成了 true那打包出来的源码是会泄密的。生产构建一定要关掉或者只在排障时临时打开。排查接口报错需要定位源码时用错误日志里的堆栈信息对应到源码版本就足够了。第四console 日志。开发时到处console.log很爽但上线后这些日志既影响性能也显得不专业。可以借助vite-plugin-remove-console之类的插件在生产构建时统一移除注意不要在生产环境继续打印敏感信息。第五接口错误提示。点开一个页面如果接口报错弹出一堆重复的 message那用户体验很糟糕。检查响应拦截器是否对相同错误做了去重或限流处理登录过期时是否只跳转一次登录页别让用户点一次按钮弹八个“网络异常”。最后分享一点个人体会从 Vite React TS Ant Design 这套技术栈做后台管理系统最大的优势不是单个库有多强而是组合之后开发体验非常顺滑。TypeScript 把数据流里的类型问题提前暴露在编译期Vite 把开发启动速度拉满Ant Design 把常用组件补齐ECharts 把可视化兜住。技术选型不是越新越好而是要能解决实际项目里反复出现的痛点。我做了这么多后台项目最大的感受是框架本身其实不复杂复杂的是如何把权限、请求、构建这些“工程问题”在项目初期就设计好。很多项目前期跑得飞快后面越改越乱往往是因为第一版只写了功能没有留出工程化设计的位置。第四篇先把这几块硬骨头啃掉后续加新页面、接新接口就会轻松很多。