
如果你维护过一个超过两年的后台管理系统大概能理解我打开代码仓库时的心情页面里塞满了手写的 DOM 拼接每个新增字段都像在雷区里挪动。项目代号 rea我习惯把它读成 Rapid Enterprise Admin这是一次面向企业后台的前端工程化重构实践。rea 想解决的问题很直接让一套历史包袱很重的管理系统重新变成可维护、可扩展、能交给新同学快速上手的前端项目。rea 不是一个正式产品更像是一个中型后台的标准化样板核心围绕 React、TypeScript、Vite 这三件事展开把路由、状态、权限、请求层这些后台开发的共性部分都沉淀成可复用的方案。如果你正准备搭新的管理后台或者正在犹豫要不要重写老系统这篇记录里的思路和踩坑过程应该能帮你少走不少弯路。1. 项目重构思路与技术选型1.1 为什么我坚持重写而不是继续缝缝补补很多团队遇到老系统第一反应是渐进式改造保留主体把新页面用新技术栈嵌进去美其名曰减少风险。但 rea 这个项目我最终选择整体重写核心原因不是视觉太旧而是老系统的数据流已经没办法继续维护了。举个例子一个订单列表页面筛选条件、选中项、分页信息全部塞在同一个全局变量里A 页面忘记清理B 页面就会出现串数据。接口请求散落在各个 onclick 事件里页面加载顺序稍有不同结果就完全不同。这种状态下继续加新功能等于在豆腐渣地基上不断加盖楼层每一层都要靠运气撑住。渐进式改造看起来改动小实际排查成本反而更高。所以重写之前我没有直接开敲代码而是先做了两件事梳理模块边界对齐接口协议。我花了一周时间把所有页面按业务域分成几个模块列出每个模块的输入输出和依赖关系同时和后端约定统一的返回结构。这一步不产生任何可见界面但价值非常大后面设计状态管理、权限模型和请求层时全靠这份清单兜底。1.2 技术栈选型React 18 TypeScript Vite 的取舍选型这件事很容易陷入“什么新用什么”的陷阱。rea 选择 React 而不是 Vue不是因为 Vue 不好而是团队最熟悉的生态就是 React。技术栈选型必须看团队维护能力再好的框架如果没人能接住三个月后就变成新的历史包袱。React 18 给我最大的感受不是并发渲染这个听起来很高端的特性而是useTransition这类 API 真的能解决后台搜索筛选的卡顿问题。用户往筛选框里输入字符时列表数据量大过滤逻辑会抢占主线程导致输入框掉帧。把过滤更新标记为 transition 后React 会优先保证输入响应列表结果稍晚一点出现但用户感知反而顺畅很多。TypeScript 的价值在后台场景尤其明显。后台接口字段多、版本变动频繁如果所有数据都是隐形的any改一个字段名可能要在十几个页面里翻找。给接口返回定义明确的类型之后字段一旦变化编译期直接报错比运行时才发现问题提前了几个量级。Vite 取代 Webpack 是我这次比较坚定的决定。老后台用 Webpack 启动一次要四十多秒改一行代码热更新也要等好几秒开发体验非常压抑。Vite 冷启动基本秒开热更新也是毫秒级反馈对调试复杂权限逻辑的帮助非常直接。你可能担心 Vite 生态不如 Webpack 全但 rea 的依赖并不复杂这种收益远大于迁移成本。关于组件库我们没有从零手写全部组件而是选了一套成熟组件库做基础再封装成内部组件包。后台页面信息密度高表格、表单、弹窗的交互规范必须统一纯手写工作量太大直接用成熟组件库又很难处理业务定制需求。折中方案是布局、权限组件、页面容器自研基础控件交给组件库。2. 后台核心模块设计与代码落地2.1 目录结构让新同学十分钟找到入口后台项目的目录结构往往比业务代码更能体现一个团队的水准。rea 的目录设计不追求花哨只要求一件事任何人拿到项目后能在十分钟内定位一个需求要改哪里。rea/ ├── src/ │ ├── api/ # 接口定义和请求封装 │ ├── assets/ # 静态资源 │ ├── components/ # 通用业务组件 │ ├── layouts/ # 整体布局框架 │ ├── pages/ # 页面 │ ├── router/ # 路由配置 │ ├── store/ # 全局状态 │ ├── types/ # 全局类型定义 │ ├── utils/ # 工具函数 │ └── main.tsx # 入口文件 ├── public/ ├── index.html └── vite.config.ts有几个细节值得展开说一下。api和types是放在一起配合使用的每个接口文件对应一个业务域同时导出这个域相关的类型定义避免到处散落interface。pages目录按路由层级维护父路由是目录子路由是子目录和路由表结构一一对应。components里只放通过多个页面抽象出来的通用组件而不是把每个页面拆出来的局部组件都塞进去不然这个目录很快就会失控。layouts单独拿出来是因为后台的侧边栏、顶栏、多标签逻辑往往比业务页面还复杂。把布局和页面彻底分离页面代码里就不用再关心菜单展开、面包屑渲染、页面标题同步这些事。新同学进来后我一般让他先看路由配置那是整个系统的页面地图再看 store 目录理解全局数据从哪来、怎么变最后看 api基本就能定位一个需求要动哪些文件。2.2 路由配置与菜单生成一份配置驱动全站后台项目最常见的维护痛点是菜单、路由、面包屑三套配置各写各的。菜单在组件里写死路由在路由表里写一遍面包屑又在页面里手动拼改一个页面路径要全局搜索替换。rea 用一份配置同时驱动这三样从根源上消除不一致。// src/router/routes.tsx import { lazy } from react; export interface AppRoute { path: string; component?: React.LazyExoticComponentReact.ComponentType; children?: AppRoute[]; meta: { title: string; icon?: string; permission?: string; hidden?: boolean; }; } export const routes: AppRoute[] [ { path: /dashboard, component: lazy(() import(/pages/Dashboard)), meta: { title: 工作台, icon: dashboard, permission: dashboard:view }, }, { path: /order, meta: { title: 订单管理, icon: order }, children: [ { path: list, component: lazy(() import(/pages/Order/List)), meta: { title: 订单列表, permission: order:list }, }, { path: detail/:id, component: lazy(() import(/pages/Order/Detail)), meta: { title: 订单详情, permission: order:detail, hidden: true }, }, ], }, ];这份配置里的path是路由路径title是菜单和面包屑的文案icon是侧边栏图标permission是权限码hidden用来标记那些需要登录但不需要出现在菜单里的页面比如详情页。菜单组件遍历这份配置生成侧边栏路由表也用它生成面包屑根据当前路径匹配meta.title。以后要加一个新页面只需要在这份数组里加一项不用再动三个地方。值得注意的是这里用的是 React Router v6 的useRoutes来消费这份配置而不是直接写死一堆Route。因为权限过滤时需要在渲染路由前先过滤掉用户没有权限的项普通数组对象比 JSX 结构更容易做递归处理也更适合做动态权限。2.3 请求层封装把接口错误做成标准化响应后台项目的请求层是最容易写出重复代码的地方每个页面都 try catch 一遍每个接口都要判断状态码最后代码里全是相互矛盾的提示逻辑。rea 的做法是把请求层收敛到一个文件里统一处理 token 注入、业务错误、HTTP 错误。// src/api/request.ts import axios from axios; const request axios.create({ baseURL: /api, timeout: 15000 }); request.interceptors.request.use((config) { const token localStorage.getItem(rea_token); if (token) config.headers.Authorization Bearer ${token}; return config; }); request.interceptors.response.use( (response) { const { code, data, message } response.data; if (code 0) return data; throw new Error(message || 请求失败); }, (error) { if (error.response?.status 401) { // 在后面的常见问题部分展开 token 刷新逻辑 } return Promise.reject(error); } ); export function getT(url: string, params?: object): PromiseT { return request.get(url, { params }) as PromiseT; }这里的核心约定是接口无论成功失败HTTP 状态都是 200真正的业务状态放在业务码code里code 0表示成功。这样页面层永远只关心PromiseT的返回值不用再写response.data.data这种套娃结构。万一后端返回结构不统一也不要让前端页面去兼容而是推动后端统一协议这是降低长期维护成本的关键一步。请求层还有一个容易被忽略的设计不要把整个AxiosResponse暴露给业务页面页面只需要业务数据。不然每个接口调用点都要知道 axios 的响应结构请求层一旦调整页面全部要改。用getT这类函数包一层页面拿到的一定是干净的数据对象类型还自动推导体验会好很多。3. 权限设计与动态路由的落地细节3.1 权限模型菜单权限、按钮权限、数据权限后台系统最复杂、也最容易写崩的部分就是权限。rea 采用经典的 RBAC 模型用户绑定角色角色绑定权限后端登录后返回当前用户拥有的权限码数组前端根据权限码做展示控制。权限码设计成字符串而不是数字编号比如order:list、order:export、dashboard:view。字符串的好处是可读性强后端配置权限时能看懂含义前端过滤时也不会出现“数字 3 到底代表什么”这种谜题。权限码由后端统一定义前端不要自己发明新的码双方以接口文档为准。前端权限通常分三个层级第一层是菜单权限控制侧边栏显示哪些入口第二层是路由权限用户即使直接输入 URL也能访问到不属于自己的页面第三层是按钮权限控制页面里的导出、删除、审核等操作是否可见或可用。三者必须配合使用只做菜单隐藏不够直接改 URL 就能绕过只做路由拦截也不够菜单里一堆入口点进去才发现没权限体验很差。数据权限是另一个维度。同一个订单列表普通用户只能看自己的订单部门主管能看整个部门的订单管理员能看全部。这种粒度通常由后端根据当前用户角色在查询时自动过滤前端只需要根据后端返回的 scope 字段决定是否展示某些筛选条件和操作按钮。前端的权限控制是体验和安全的第一道闸门真正的数据安全必须由后端兜底。3.2 动态路由与页面刷新不掉线动态路由的完整流程说复杂也复杂说简单也简单。登录后前端拿到权限码然后从完整路由表里过滤出当前用户可见的路由再渲染到页面。rea 的实现思路如下。第一步登录接口成功后把 token 和 permissions 分别存到 localStorage 和全局 store 中。第二步根据 permissions 递归过滤routes配置保留用户有权限访问的路由项。第三步在根组件里用useRoutes消费过滤后的路由表并包一层Suspense做路由懒加载的 loading 状态。// src/App.tsx import { Suspense } from react; import { useRoutes } from react-router-dom; import { usePermissionRoutes } from /hooks/usePermissionRoutes; import { PageLoading } from /components/PageLoading; export function App() { const permissionRoutes usePermissionRoutes(); const element useRoutes(permissionRoutes); return Suspense fallback{PageLoading /}{element}/Suspense; }usePermissionRoutes内部只需要做一件事递归遍历routes过滤掉meta.permission不在 permissions 列表中的项。这样菜单、路由、面包屑都基于同一份过滤后的数据源权限控制的逻辑集中在一个 hook 里排查问题非常方便。刷新不掉线的关键在于刷新后要能同步恢复权限数据。rea 的做法是在应用初始化时先读取 localStorage 里缓存的 permissions再异步请求当前用户信息做校验校验期间不渲染路由避免出现“页面已经渲染、权限还没回来”的白屏闪烁。如果后端校验失败说明 token 已经失效直接跳登录页。这里要提醒一个很容易踩的坑不要把完整路由表和权限过滤逻辑混在一起。完整路由表是静态常量权限过滤是运行时计算两者分开维护才能保证完整路由表不被用户权限污染下次登录时还能重新过滤。3.3 按钮级权限的三种实现方式菜单和路由权限做完之后按钮权限是后台系统最后一块拼图。如果页面上所有按钮都对所有用户可见只是接口返回 403体验就很粗糙。rea 里统一用AuthGate组件配合usePermissionhook 实现按钮级控制。// src/components/AuthGate.tsx import { usePermission } from /hooks/usePermission; export function AuthGate({ permission, children }: { permission: string; children: React.ReactNode }) { const allowed usePermission(permission); return allowed ? {children}/ : null; }用法很直观需要权限的操作按钮用AuthGate包一层传入对应权限码。AuthGate permissionorder:export Button onClick{exportOrders}导出订单/Button /AuthGate除了组件方式还有一种更灵活的 hook 方式。当按钮的禁用状态依赖多个权限或者需要在事件回调里做判断时直接调用usePermission(order:export)拿到布尔值自己控制逻辑。第三种方式是给无权限的按钮显示一个“无权限”提示而不是直接消失。很多用户看到按钮消失会以为功能坏了直接提工单过来。rea 的做法是默认用禁用加 Tooltip 提示既保留了操作入口又让用户明白是没有权限而不是系统故障。三种方式的取舍并不复杂纯展示用组件复杂逻辑用 hook需要引导用户时用禁用提示。4. 构建配置与性能优化实录4.1 Vite 配置别名、代理、构建分包Vite 的配置本身不复杂但有几个点如果一开始没处理好后面会很痛苦。rea 的vite.config.ts里集中了三个关键配置路径别名、开发代理、构建分包。// vite.config.ts import { defineConfig } from vite; import react from vitejs/plugin-react; import { fileURLToPath, URL } from node:url; export default defineConfig({ plugins: [react()], resolve: { alias: { : fileURLToPath(new URL(./src, import.meta.url)), }, }, server: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true, }, }, }, build: { rollupOptions: { output: { manualChunks: { react: [react, react-dom, react-router-dom], antd: [antd], axios: [axios], }, }, }, }, });别名指向src这是团队约定避免出现../../../../components这种灾难级路径。开发代理把/api转发到后端服务这样前端代码里可以统一写相对路径不需要区分开发环境和生产环境。manualChunks是构建分包的核心。默认情况下Vite 可能把所有第三方依赖打进一个巨大的index.js首屏要下载几百 KB 甚至几 MB还无法充分利用浏览器缓存。手动把react、antd、axios拆成独立 chunk 后只要这些依赖的版本不变用户升级页面时可以直接命中缓存不需要重复下载。路径别名有一个细节要注意在 Windows 上不要直接用path.resolve(__dirname, ./src)因为 Windows 的盘符和分隔符处理起来容易出问题。用fileURLToPath(new URL(./src, import.meta.url))更稳妥这也是 Vite 官方推荐的做法。4.2 首屏优化路由懒加载、静态资源压缩、Chunk 拆分后台系统的页面数量通常不少如果所有页面都打包进一个入口文件首屏加载会非常慢。rea 从第一天就坚持所有页面组件都用lazy加载配合Suspense的 fallback 做 loading 状态。React Router 的配置里所有页面组件都写成lazy(() import(/pages/xxx))而不是直接import。这样浏览器第一次打开时只加载当前路由对应的 JS 文件其他页面等用户真正访问时才加载。实测下来仅这一个改变首屏 JS 体积就能下降百分之六十以上。组件库按需加载也值得重视。很多组件库默认引入全部组件和全部样式一个antd全量包可能接近 1000 KB。通过按需引入只打包实际用到的组件再配合样式按需加载体积能压缩得很明显。如果团队用的组件库不支持按需加载至少要用manualChunks把它单独拆出来配合 Gzip 也能接受。静态资源压缩在后台项目里容易被忽略因为后台图片不多。但字体、SVG 图标、CSS 文件依然有不小优化空间。rea 实际配置了生产环境的 Gzip 压缩通过部署层开启JS 和 CSS 体积在传输时还能再压缩百分之六七十。注意这一步不是前端代码能完全替代的Nginx 或网关层必须同时开启对应的gzip_types。首屏优化的最后一块是构建产物的可视化分析。使用构建分析插件能直观看到每个 chunk 的大小哪些依赖异常膨胀一目了然。rea 的一次分析里发现某个工具库被打包进来了三次只因为三个文件用不同的路径引用了它统一模块路径后体积立刻降了一截。4.3 运行时性能memo、useMemo、不可变数据首屏快不代表用起来流畅后台系统的运行时性能问题通常出在列表页和数据联动上。列表组件渲染几百行数据用户调整筛选条件时又要重新渲染如果每次渲染都无差别执行所有子组件卡顿会非常明显。rea 处理列表性能的第一原则是用useMemo缓存派生数据。筛选条件变化后先计算出过滤后的数据数组再把数组传给表格组件避免组件每次 render 都重新执行过滤逻辑。这个优化看上去很基础实战中却能解决大部分列表卡顿问题。第二原则是用useCallback包裹传给子组件的回调函数。很多同学知道memo可以阻止子组件重复渲染但如果传给子组件的onClick{() ...}每次 render 都是新函数memo也会失效。所以正确组合是memo加useCallback缺一不可。函数组件内部定义的静态数组也是一样的道理最好提到组件外部或包一层useMemo避免每次 render 都创建新的引用。不过我要泼一盆冷水memo 和 useMemo 不是万能的也不应该到处乱用。像登录页、设置页这种简单的静态页面加上 memo 反而增加代码复杂度。性能优化的核心是找到真正导致卡顿的热点路径而不是对每个组件无脑 memo。React DevTools 的 Profiler 能按组件显示渲染耗时先看数据再动手比凭感觉优化靠谱得多。5. 常见问题与排查技巧实录5.1 路由懒加载导致刷新 404这个问题几乎每个使用 history 路由模式的后台项目都会遇到。本地开发一切正常一部署到服务器用户点击菜单进入二级页面后刷新直接 404。原因很简单生产环境的 Nginx 不知道前端是单页应用它以为/order/list是一个真实存在的文件路径找不到就返回 404。解决方法是让 Nginx 把所有非文件请求都回退到index.htmllocation / { try_files $uri $uri/ /index.html; }这一步配置之后刷新/order/list时Nginx 找不到对应静态文件就会返回前端入口页面由 React Router 接管路由并渲染对应的页面组件。需要注意的是/api的代理 location 必须放在这个规则之前否则接口请求也会被错误地回退到index.html导致接口全部 404。如果项目用的是 hash 路由就不会有这个问题因为#后面的部分不会发给服务器。但 hash 路由的 URL 比较丑而且不利于页面分享。后台系统一般不需要 SEO所以很多人会选择 hash 来省事。我的建议是如果部署环境可控尽量用 history 路由加 Nginx 回退体验更干净。5.2 Token 过期后接口和路由同时失效后台用户经常把页面开一整天token 过期后第一次点击操作触发接口 401。如果页面上同时有几个请求都返回 401就会出现多个登录弹窗、多次跳转登录页甚至互相覆盖的混乱状态。rea 的处理方案是在 axios 响应拦截器里维护一个刷新队列。思路是这样的第一个 401 触发刷新 token 的请求其他并发 401 先不处理进入等待队列刷新 token 成功后重放队列里的原始请求如果刷新失败清空队列并跳转登录页。这样既避免了重复刷新 token也保证用户原本想执行的操作不会丢失。let isRefreshing false; let waitingQueue: Array() void []; async function handleUnauthorized(error) { if (!isRefreshing) { isRefreshing true; try { const newToken await refreshToken(); // 更新存储后重放队列 waitingQueue.forEach((cb) cb()); waitingQueue []; return request(error.config); } finally { isRefreshing false; } } else { return new Promise((resolve) { waitingQueue.push(() resolve(request(error.config))); }); } }这个机制里有几个细节必须注意。refreshToken 接口本身如果也返回 401说明刷新令牌已经彻底失效这时候要直接跳登录页否则会在刷新逻辑里死循环。队列里的请求重放时要保留原来的参数和 headers不能因为 token 更新造成业务数据丢失。还有一点token 存储位置要前后端统一不要前端改一处、后端又默认从 cookie 里取两边对不上才是真正的大坑。5.3 权限码过多导致过滤卡顿有些后台系统的权限码能达到上千个后端返回的可能是一棵权限树也可能是一个扁平数组。前端每次判断权限时如果都调用array.includes遇到几十个按钮的页面就会产生几十次数组遍历虽然不至于让页面完全卡死但性能确实不好看。rea 的做法是在初始化权限数据时把权限码转换成一个Set后续所有权限判断都基于set.has(permission)。Set.has的时间复杂度是 O(1)而数组includes是 O(n)权限码越多差距越明显。如果后端返回的是树形结构也需要先扁平化成Set这一步同样用useMemo缓存起来。还有一个排查时容易忽视的问题权限码的大小写和前后缀必须完全一致。后端配了order:export前端判断时写成order:Export看起来只是大小写差异实际永远匹配不上。rea 在权限码设计阶段就统一约定为小写字母、冒号分隔同时前端加了一个开发环境下的校验函数把所有用到的权限码和接口返回的权限码做一次对比不一致就抛出警告尽早暴露低级错误。5.4 排查工具与定位思路后台项目出问题最忌讳的是在代码里加一堆 console.log一遍遍刷新试。我自己的习惯是先用 Chrome DevTools 的 Network 面板看接口链路确认是数据没返回、返回错了还是前端渲染错了再看 Console 有没有红色报错最后才用 React DevTools 的 Profiler 定位组件渲染性能问题。Network 面板还有一个作用是看每个 chunk 的加载情况。如果某个页面进入时加载了一个巨大的 JS 文件多半是路由懒加载没有生效或者有公共依赖被错误地打进了业务包。通过构建分析插件配合 Network 面板能快速定位体积异常的来源。React DevTools 的 Profiler 是运行时性能排查的利器。它能把每次交互的组件渲染耗时按时间轴展示出来哪条链路最耗时一目了然。比如列表页筛选卡顿Profiler 会显示是父组件的 state 更新导致了整个表格重渲染还是筛选逻辑本身太慢针对性地修改比靠猜靠谱得多。还有一个小技巧在排查权限问题时先把权限过滤的 hook 单独拉到控制台跑一遍看过滤结果是否正确。不要每次都从点击按钮开始追那样要经过登录、菜单渲染、路由匹配、按钮判断等多个环节链路太长。把权限数据源和过滤函数独立测试问题往往立刻现出原形。项目跑到现在我最后悔的一件事是刚开始把太多东西设计成了“动态”动态路由、动态菜单、动态权限、动态主题恨不得所有配置都从后端拿。结果后台百分之八十的页面是给固定角色用的权限配置的核心价值是防止误操作而不是做千人千面的营销页面。动态逻辑越多排查链路越长一个菜单不显示往往要从后端配置、权限表、前端缓存三段同时去查。如果重新做一次 rea我会把“静态路由 权限过滤”作为默认方案只有真正遇到多租户隔离、需要按用户实时生成菜单的场景才引入动态路由。技术上的炫技对后台系统来说不重要重要的是能用最直白的方式解决业务问题同时让下一个接手的人不用对着配置文件和权限码翻来覆去猜半天。希望这篇记录能让你在重构或新写后台系统时少踩几个我踩过的坑。