
先说结论rea 是我最近一直在打磨的一套基于 React 的中后台应用脚手架方案。它不是框架也不是 UI 库而是一套把项目初始化、路由权限、状态管理、请求封装、构建优化全部整合到一起的工程化模板。团队里如果新起一个项目直接拿它当起点能省掉大概两到三天的环境搭建和规范对齐时间。当初折腾这套东西的动机很简单我经历过太多次“从零搭项目”的重复劳动。每次都是同样的操作——初始化一个空壳、配别名、配路由、封装请求、定目录结构结果不同项目之间的代码风格还不一致A 项目这么写B 项目那么写人员一流动维护成本立刻上来。所以我就想干脆沉淀一套“开箱即用但不过度封装”的模板把那些高频的、重复的、容易踩坑的部分全部处理好新人进来照着约定写业务就行。如果你正准备做后台管理系统、中台应用、或者任何基于 React 的 Web 项目这篇文章写的就是我实际落地的思路和踩过的坑你可以直接参考或抄作业。里面涉及的工具链、代码片段、目录设计都是我反复调整后觉得比较顺手的版本。1. rea 的整体设计与思路拆解1.1 为什么需要这么一套模板中后台项目有个很典型的特点页面多、权限逻辑重、状态管理需求明确但业务本身往往并不复杂。这里的矛盾在于工程化配置往往比业务代码更消耗时间。比如初始化一个 Vite 项目很简单但要把路由、鉴权、请求层、状态持久化、环境变量、代码规范全部串起来没有一两天做不完而且做出来的质量还不一定稳定。rea 的核心思路就是把这些“公共部分”从业务代码里彻底剥离出来。它还做了一个很重要的决定这个模板不强绑任何重型状态管理库也没有引入过度设计的微前端架构而是保持“足够的规范和有限的约定”。我见过不少团队把脚手架做得极其庞大生成出来一堆代码业务还没写就要先理解抽象概念这种模板反而拖慢开发节奏。所以 rea 的设计原则只有三条第一常用的功能必须内置且开箱即用第二业务代码必须能看懂、能改、能删第三任何通用抽象都要控制在“一个文件能讲清楚”的复杂度内。有了这些原则后面所有的选型就都有依据了。1.2 技术选型背后的思考技术选型这件事最忌跟风。我在 rea 里做的每个选择都有明确理由不是因为它新而是因为它适合中后台业务。构建工具Vite。中后台项目大多命中“开发期频繁保存刷新”的场景Vite 的 ESBuild 预构建和 HMR 速度比传统的 Webpack 方案快一个量级。我实测同一个项目冷启动从十几秒降到两秒以内这个体感差异对日常开发影响非常大。框架与语言React TypeScript。React 生态成熟、组件化模型适合中后台的页面组装TypeScript 则能有效减少跨页面、跨模块的数据结构错乱问题。路由方案React Router。功能足够、社区活跃v6 版本的数据路由 API 对路由表和组件解耦的支持很好。状态管理Zustand。它不像 Redux 那样需要写一堆 action 和 reducer 模板代码写法接近一个普通的 hook同时又支持持久化中间件和组件外访问。中后台常见的用户信息、权限数据、全局设置用 Zustand 管理非常顺手。请求层Axios 二次封装。Axios 的拦截器机制适合统一处理 token 注入、错误提示、请求取消这些横切逻辑。这些选型组合起来的效果是模板本身轻、依赖数量合理、升级路径清晰。我特意没有引入类似 Dva 或 Umi 这类一体化框架因为 rea 的目标是“可移植的参考实现”而不是把团队绑死在某套约定上。1.3 目录结构与分层思想目录结构是脚手架的骨架我在 rea 里花了最多时间定制的也是这一层。一个好的目录结构应该做到新加入的代码能找到固定的位置重命名模块时不会牵连无关文件。src/ ├── api/ # 接口定义按业务域切分 ├── assets/ # 静态资源 ├── components/ # 通用业务组件 ├── layouts/ # 布局组件 ├── pages/ # 页面级组件 ├── router/ # 路由配置与权限逻辑 ├── store/ # 全局状态 ├── styles/ # 全局样式 ├── types/ # 全局类型定义 ├── utils/ # 工具函数 ├── hooks/ # 通用自定义 hook ├── main.tsx # 入口文件 └── App.tsx # 根组件这个分层的核心逻辑是依赖方向自上而下不推荐下层反向依赖上层。比如pages可以依赖components、store、api但components不应该依赖具体的pages。types作为最底层任何模块都能引用但里面的类型要尽量是“纯数据结构”不要掺杂业务实现。分层清晰之后后面无论是做权限控制、按需加载还是单元测试都会顺很多。很多项目会越写越乱往往不是因为代码写得差而是从一开始就没人规定“什么东西应该放哪里”。2. 核心细节解析与实操要点2.1 项目初始化与依赖清单初始化这一步我推荐直接用 Vite 官方的react-ts模板省去手动配置 TypeScript 和 React 插件的时间。执行npm create vitelatest rea-demo -- --template react-ts cd rea-demo npm install装完基础依赖后再手动安装项目运行所需的其它依赖npm install react-router-dom6 zustand axios npm install -D sass types/node这里有两个小细节值得注意。第一个是sass的安装如果你打算用 scss 写样式Vite 本身并不内置预处理器装完之后直接就能用.scss文件不需要额外配置。第二个是types/node因为后面配置路径别名时需要用到node:path相关的类型声明提前装好能避免一堆红波浪线。依赖版本这件事我要单独提一句这几个库的版本如果没锁好很容易出现“安装时最新版但代码用的是旧版 API”的情况。我的建议是安装完立刻把package-lock.json或pnpm-lock.yaml提交到仓库确保团队里所有人拉下来的依赖版本一致。2.2 路径别名的配置路径别名是每个工程化项目都该配的东西它可以避免出现../../../components/Table这种噩梦式相对路径。在 Vite 中配置别名需要同时改两个文件vite.config.tsimport { defineConfig } from vite import react from vitejs/plugin-react import path from node:path export default defineConfig({ plugins: [react()], resolve: { alias: { : path.resolve(__dirname, src), }, }, })tsconfig.json里同样需要声明别名否则 TypeScript 不认识/xxx这个路径{ compilerOptions: { baseUrl: ., paths: { /*: [src/*] } } }改完之后项目里就可以用import Button from /components/Button这种写法了。这里有一个经常被忽略的点如果你用的是tsconfig.app.json这种由 Vite 模板生成的子配置一定要把paths写到那个文件里而不是根级的tsconfig.json否则编辑器会提示找不到模块。我一开始就踩过这个坑排查了半天。2.3 路由方案与权限路由设计路由是 rea 里最核心的部分之一因为它不只是简单的“路径到组件”的映射还关系到权限控制。我先给出基本的目录规划src/router/ ├── index.tsx # 路由入口 ├── routes.tsx # 完整路由表 ├── guard.tsx # 路由守卫组件 └── lazy.ts # 懒加载工具我使用 React Router v6 的useRoutesuseNavigate组合。useRoutes可以直接接受一个路由配置数组用起来很像 Vue Router 的路由表方式适合数据驱动。一个典型的路由表长这样import { lazy } from ./lazy import { Layout } from /layouts const routes [ { path: /login, element: lazy(() import(/pages/Login)), }, { path: /, element: Layout /, children: [ { index: true, element: lazy(() import(/pages/Dashboard)), }, { path: users, element: lazy(() import(/pages/Users)), }, { path: settings, element: lazy(() import(/pages/Settings)), }, ], }, ]这里的懒加载工具实现很简单import { lazy as reactLazy, Suspense } from react export const lazy (importFn: () Promise{ default: any }) { const Component reactLazy(importFn) return ( Suspense fallback{PageLoading /} Component / /Suspense ) }用Suspense包裹的好处是用户切到某个页面时如果它的代码块还没加载完成会先看到一个页面级的 loading而不是白屏。这个体验细节在后台系统里很重要尤其是在低版本浏览器或弱网环境下。权限路由的基本原则是先判断用户是否登录没登录就跳登录页登录了再判断当前用户角色是否有权限访问这个路由没有就跳 403 页面。我把这个逻辑封装成了guard.tsx组件它接收路由的meta信息来做判断。2.4 状态管理为什么选 ZustandZustand 是我尝试过 Redux Toolkit、MobX、Valtio 之后最终留下的选择。选它的核心原因只有一个中后台的状态管理需求其实很轻不需要仪式感那么强的数据流。一个典型的用户信息 store 写起来非常直接import { create } from zustand import { persist } from zustand/middleware import { getUserInfo } from /api/user interface UserState { token: string userInfo: null | UserInfo login: (token: string) void fetchUserInfo: () Promisevoid logout: () void } export const useUserStore createUserState()( persist( (set) ({ token: , userInfo: null, login: (token) set({ token }), fetchUserInfo: async () { const res await getUserInfo() set({ userInfo: res.data }) }, logout: () set({ token: , userInfo: null }), }), { name: rea-user-store, partialize: (state) ({ token: state.token }), } ) )这段代码解决了几个实际问题。第一通过persist中间件自动把 token 存到 localStorage刷新页面后登录态还在不用自己写序列化和读取逻辑。第二partialize指定只持久化 token用户信息这种可能会变的敏感数据就不落盘每次进系统重新请求保证数据新鲜。第三logout时清空 store简单可靠。我见过不少团队在项目里坚持用 Redux Toolkit然后配套了十几个文件但实际每个页面里真正需要共享的状态可能只有三五个。这不是说 Redux 不好而是对于中后台这种场景Zustand 的心智负担和代码量都要小得多。需要的时候 Zustand 也能写得很规范比如抽离 action、使用 hooks 式访问。2.5 请求层的封装思路axios 封装是几乎所有 React 中后台项目都绕不开的活rea 在这方面做了三层设计底层是 axios 实例中间是拦截器上层是接口函数。一个经过验证的 axios 实例封装import axios from axios import { message } from antd import { useUserStore } from /store/user const service axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL, timeout: 10000, }) // 请求拦截器 service.interceptors.request.use( (config) { const token useUserStore.getState().token if (token) { config.headers.Authorization Bearer ${token} } return config }, (error) Promise.reject(error) ) // 响应拦截器 service.interceptors.response.use( (response) { const res response.data // 这里是你和后端约定的业务状态码 if (res.code ! 0) { message.error(res.message || 请求失败) return Promise.reject(new Error(res.message)) } return res }, (error) { if (error.response?.status 401) { useUserStore.getState().logout() window.location.href /login } message.error(error.response?.data?.message || 网络异常) return Promise.reject(error) } ) export default service这套拦截器方案里最有价值的是那个useUserStore.getState()的调用方式。Zustand 允许在 React 组件外访问全局状态所以在拦截器里可以很方便地拿到 token不需要把 token 挂在 localStorage 上反复读。请求层还有一个大家容易忽视的点错误处理要分层。拦截器处理的是全局统一错误比如网络异常、登录失效业务代码里则要处理具体的业务错误比如提交失败时要不要回滚表单、要不要跳转到某个错误页面。如果把所有错误都抛给拦截器业务代码就没法做差异化处理。3. 实操过程与核心环节实现3.1 从零搭建完整项目骨架前面讲了很多设计和原理这一节带你走一遍完整的实操流程。我的目标是按这个流程走完你能得到一个干净的、可以马上写业务的项目。第一步初始化项目并安装依赖。第二步删除 Vite 模板自带的App.css、assets/react.svg这些无用的示例文件。第三步按前面 1.3 节的结构创建src下的所有目录。第四步配置路径别名和基础tsconfig。第五步安装并配置基础依赖。这些做完之后我来验证一下配置是否正确。我习惯先把入口文件main.tsx改成一个最简版本然后运行npm run dev看到页面正常渲染说明基础链路通了。import React from react import ReactDOM from react-dom/client import { RouterProvider } from react-router-dom import { router } from /router import /styles/global.scss ReactDOM.createRoot(document.getElementById(root)!).render( React.StrictMode RouterProvider router{router} / /React.StrictMode )这一步看起来简单但有一个隐藏的收益通过RouterProvider这种数据路由的方式路由配置组件和入口逻辑做到了彻底分离后续扩展权限守卫、懒加载、错误边界都会更轻松。3.2 实战配置路由与权限控制这里重点讲权限控制如何落地。大多数中后台项目的权限模型本质上就是“角色-路由”的映射关系。我推荐的方案是路由配置里加meta字段存roles数组然后在守卫组件里判断当前用户角色是否落在允许的范围内。路由表里加meta{ path: users, element: lazy(() import(/pages/Users)), meta: { roles: [admin] }, }路由守卫组件import { Navigate, Outlet, useLocation } from react-router-dom import { useUserStore } from /store/user export function AuthGuard() { const location useLocation() const token useUserStore((state) state.token) const userInfo useUserStore((state) state.userInfo) if (!token) { return Navigate to/login state{{ from: location }} replace / } const route findRouteByPath(location.pathname) if (route?.meta?.roles !route.meta.roles.includes(userInfo?.role)) { return Navigate to/403 replace / } return Outlet / }这里有个关键点token存在并不代表用户信息一定已加载。有些项目在刷新页面后token还在 localStorage 里但userInfo是空的这会导致权限判断失效。我的处理方式是在全局布局组件挂载时如果检测到token存在但userInfo为空就调用一次fetchUserInfo()拿到最新的用户信息和角色。这一步必须做否则刷新路由后和用户相关的所有判断都是错的。3.3 实战状态管理中的异步操作与持久化状态管理的实操重点在于异步操作怎么处理。我以“用户登录 → 拉取用户信息 → 写入 store”这个链条为例讲清楚它的时序。登录页里提交表单后调用 login然后立刻调fetchUserInfo都成功才跳转首页const login async (values: LoginParams) { setLoading(true) try { const res await loginApi(values) useUserStore.getState().login(res.data.token) await useUserStore.getState().fetchUserInfo() navigate(/, { replace: true }) } catch (error) { // 错误提示已经在拦截器里统一弹过了 } finally { setLoading(false) } }看起来很简单但这里隐藏了一个容易出错的时序问题fetchUserInfo可能失败比如 token 过期。如果失败整个登录流程应该中止不能跳转到首页。所以代码里用了await把两步串起来任何一步抛错都会进入catch。持久化方面刚才在 2.4 里已经用 Zustand 的 persist 中间件解决了。实操时还有一个并发问题如果用户在多个浏览器标签页打开同一个应用某一处退出登录后另一个标签页的 store 还停留在旧状态。我现在的经验是先不强求解决这个因为中后台项目内部管理系统很少会多标签同时操作如果真遇到了可以配合storage事件做同步但那个优先级不高。3.4 实战请求层封装就位与接口对接请求层封装完之后接口对接就有了统一底座。这里写一个标准的接口函数作为示例import service from /utils/service import type { UserInfo } from /types/user export function getUserInfo() { return service.get{ code: number; data: UserInfo }(/user/info) } export function updateUserInfo(data: PartialUserInfo) { return service.post(/user/update, data) }这里有一个实践中很重要的小约定把接口函数和类型定义放到同一个业务域文件里。比如src/api/user.ts里既定义了getUserInfo也 import 了UserInfo类型。这样改接口时类型和请求函数同步修改不容易遗漏。还有一个细节所有接口函数统一返回 response 的data字段还是返回整个 body我的建议是返回整个 body包含code和message。因为有些业务场景需要判断code来走不同分支如果把data直接返回给调用方一旦要判断业务状态码就只能在拦截器里处理灵活性会很差。上面拦截器在响应成功时return res指的就是返回整个 body。3.5 实战构建优化与环境变量管理开发环境和生产环境的差异在工程化项目里是绕不开的。rea 使用 Vite 的import.meta.env机制管理环境变量默认提供了.env.development和.env.production两个文件。.env.development示例VITE_API_BASE_URL/api VITE_ENVdevelopment.env.production示例VITE_API_BASE_URLhttps://api.example.com VITE_ENVproduction请求实例里通过import.meta.env.VITE_API_BASE_URL读取这就是不同环境的 baseURL 分而治之的方案。构建优化这一步我在 rea 里主要做了三件事。第一手动分包。把体积较大的第三方依赖拆分出来避免单个 chunk 过大build: { rollupOptions: { output: { manualChunks: { react: [react, react-dom, react-router-dom], vendor: [axios], }, }, }, },第二启用 gzip 压缩。安装vite-plugin-compression插件构建时自动生成.gz文件配合 Nginx 的gzip_static on;直接使用预压缩文件能显著减少传输体积。第三关闭 console 和 debugger。在vite.config.ts的 esbuild 配置里esbuild: { drop: [console, debugger], },这个开关只应在生产构建时开启。我这里直接用vite的mode做了条件判断开发模式还是保留 console方便调试。实际效果是生产包体积能减少一部分更重要的是避免大量业务日志暴露在用户浏览器的控制台里。4. 常见问题与排查技巧实录4.1 问题速查表我把这段时间使用 rea 踩过的坑和团队反馈的问题整理成了一张表按出现频率排序问题现象根本原因解决方案刷新页面后一直跳登录页token 恢复成功但没有重新拉取用户信息权限判断用空数据在全局布局挂载时补一次fetchUserInfo并增加 loading 态生产环境接口 404baseURL 是相对路径/api但生产环境没有配置反向代理生产环境使用绝对地址或让运维配好 Nginx 代理页面切换白屏懒加载组件抛错没有错误边界捕获给Suspense外层包一个ErrorBoundary修改vite.config.ts不生效Vite 没有自动重启 Dev Server依赖配置修改后手动重启项目ESLint 报 import 路径错误配置了别名但 ESLint 插件不认识/安装并配置 eslint-import-resolver-alias构建时提示 “Cannot find module”路径别名没有同时配置在tsconfig和 Vite两处配置必须同步详见 2.2 节这张表里的问题基本都是我在项目中真实遇到过的每一条都对应具体的配置或代码逻辑。表格最后一条最容易犯因为大部分人配了 Vite 别名就以为完事了结果 CI 构建时 TypeScript 检查直接挂掉。4.2 三个让我印象深刻的排查实录第一个是 Zusstand 的 persist 中间件和版本兼容问题。某次升级依赖zustand从 v4 升到某个新版本后persist的写入方式有变化导致旧项目里存到 localStorage 的数据读取不了用户在升级后全部掉登录。这个问题的教训是持久化数据结构是有版本的升级状态管理库前先检查有没有迁移方案。如果担心迁移成本可以把version字段明确写出来并在persist配置里指定migrate函数。第二个是 axios 请求取消引发的“静默失败”。我曾在请求层加了一个防重复提交逻辑相同 URL 在短时间内重复请求时直接取消前一个。结果有次在搜索页面里用户快速输入时前面的请求被取消响应拦截器捕获到CanceledError后弹了个“请求失败”的错误提示把用户吓到了。后来我单独在拦截器里判断error.code ERR_CANCELED静默处理才算解决。取消请求确实有用但错误处理必须跟上否则会制造新的烦躁。第三个是关于生产环境构建后的路由刷新 404。这个其实是前端单页应用的老问题部署到 Nginx 后用户在https://xxx.com/users页面按 F5Nginx 找不到/users对应的文件返回 404。解决方法是 Nginx 配置try_files $uri $uri/ /index.html;。这个和 rea 本身无关但每个用 rea 部署时都会遇到我干脆写进了 README 里。4.3 实操过程中的避坑心得关于版本锁定企业级项目里依赖升级一定要克制。rea 模板会把package.json里的关键依赖版本写成精确版本号而不是^1.0.0这种范围版本。原因很简单你根本不知道几个月后某个库的 minor 版本会不会引入 breaking change。团队协作时所有人 lockfile 一致还不够精确版本号能避免有人手动npm install xxxlatest把依赖升到无法预期的版本。关于页面加载体验中后台项目有一个指标叫“首屏可交互时间”。rea 的做法是把登录页做成一个不需要权限、不需要布局、尽量少依赖外部 UI 的独立页面。这样登录首屏加载就非常快。登录之后的主页面虽然依赖多一些但通过路由懒加载按需拆包不会把整个系统的代码全塞进第一个请求里。关于约定大于配置这套模板里我刻意没有引入“自动生成代码”的能力比如没有做路由文件自动扫描。自动生成虽然省事但黑盒属性太强一旦团队里没人知道它是怎么运行的出问题就非常难排查。rea 的手动路由表反而更透明改动一处就能看到结果。5. 最后再分享一点个人体会这个模板前前后后改了很多次每次都是被真实业务问题推着走的。最明显的感受是做脚手架不是把功能堆上去就完事而是要在“方便”和“透明”之间找平衡。方便是说新项目启动快、写业务流畅透明是说遇到问题能低成本地定位到根源。rea 现在的状态就是我在这两者之间反复权衡后的结果。最后一个小技巧我自己一直在用的给模板加一个npm run check脚本把 TypeScript 类型检查、ESLint、构建三条命令串在一起。提交代码前手动跑一次能在本地提前暴露 90% 以上的低级错误比在 CI 里等结果快得多。用 rea 起项目的团队我建议每个人都把这条命令养成习惯它能帮你省下很多跟构建报错死磕的时间。