先说结论: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 vite@latest rea-demo -- --template react-ts cd rea-demo npm install装完基础依赖后,再手动安装项目运行所需的其它依赖:
npm install react-router-dom@6 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.ts:
import { 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 的useRoutes+useNavigate组合。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 状态管理:为什么选 Zustand
Zustand 是我尝试过 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: () => Promise<void> logout: () => void } export const useUserStore = create<UserState>()( 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: Partial<UserInfo>) { 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_ENV=development.env.production示例:
VITE_API_BASE_URL=https://api.example.com VITE_ENV=production请求实例里通过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 态 |
| 生产环境接口 404 | baseURL 是相对路径/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页面按 F5,Nginx 找不到/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 xxx@latest把依赖升到无法预期的版本。
关于页面加载体验:中后台项目有一个指标叫“首屏可交互时间”。rea 的做法是把登录页做成一个不需要权限、不需要布局、尽量少依赖外部 UI 的独立页面。这样登录首屏加载就非常快。登录之后的主页面虽然依赖多一些,但通过路由懒加载按需拆包,不会把整个系统的代码全塞进第一个请求里。
关于约定大于配置:这套模板里我刻意没有引入“自动生成代码”的能力,比如没有做路由文件自动扫描。自动生成虽然省事,但黑盒属性太强,一旦团队里没人知道它是怎么运行的,出问题就非常难排查。rea 的手动路由表反而更透明,改动一处就能看到结果。
5. 最后再分享一点个人体会
这个模板前前后后改了很多次,每次都是被真实业务问题推着走的。最明显的感受是:做脚手架不是把功能堆上去就完事,而是要在“方便”和“透明”之间找平衡。方便是说新项目启动快、写业务流畅;透明是说遇到问题能低成本地定位到根源。rea 现在的状态,就是我在这两者之间反复权衡后的结果。
最后一个小技巧,我自己一直在用的:给模板加一个npm run check脚本,把 TypeScript 类型检查、ESLint、构建三条命令串在一起。提交代码前手动跑一次,能在本地提前暴露 90% 以上的低级错误,比在 CI 里等结果快得多。用 rea 起项目的团队,我建议每个人都把这条命令养成习惯,它能帮你省下很多跟构建报错死磕的时间。