1. 为什么我要用五种技术栈写同一套 Admin
先说结论:这套东西不是炫技,是我在真实项目里被逼出来的。过去两年我陆续接手过几个后台管理系统的活儿,有从零起步的新项目,也有接手别人写到一半的“祖传代码”。每次技术选型会上,前端用 React 还是 Vue、后端用 NestJS 还是别的,都能吵上半天。吵到最后往往不是选“最好的”,而是选“团队最熟的”。但问题在于,团队会换、人会走,半年后接手的人可能完全不熟这套栈。
所以我就想干一件事:把一套功能完全相同的 Admin 系统,用五种不同的技术栈各实现一遍。功能清单固定死——登录鉴权、动态路由菜单、用户/角色/权限管理、数据表格增删改查、图表看板、文件上传、WebSocket 消息推送。然后分别用 React + NestJS、Vue3 + NestJS、React + Express、Vue3 + Spring Boot、以及一个纯前端 Mock 版本落地。这样做的价值很直接:以后新项目选型,我可以直接把这五套代码甩到会上,让团队看着真实代码投票,而不是对着 PPT 空想。
这套东西适合谁看?如果你正在纠结 Admin 系统选 React 还是 Vue,或者纠结后端要不要上 NestJS,那这篇就是写给你的。如果你只是想抄一套能跑的后台模板,也能从里面挑一套直接拿走。我尽量把每个技术决策背后的“为什么”讲清楚,而不是只贴代码。
2. 五种技术栈的组合逻辑与选型考量
2.1 为什么是这五种组合,而不是随便凑数
五种组合不是拍脑袋定的,每一组都对应一类真实场景。我先把组合列出来,再逐个说理由。
| 编号 | 前端 | 后端 | 对应场景 |
|---|---|---|---|
| 方案A | React 18 + TypeScript | NestJS | 中大型团队,强类型、模块化要求高 |
| 方案B | Vue3 + TypeScript | NestJS | 前端偏 Vue 生态,后端要结构化 |
| 方案C | React 18 + TypeScript | Express | 小团队快速起步,后端逻辑简单 |
| 方案D | Vue3 + TypeScript | Spring Boot | 企业内网,后端已有 Java 体系 |
| 方案E | React 18 + TypeScript | 无(Mock) | 纯前端演示、组件库开发、离线环境 |
方案A和方案B共用同一套 NestJS 后端,这是故意的。我想验证一个假设:同一套后端 API,React 和 Vue 前端接入的成本差异到底有多大。实测下来,差异主要集中在状态管理和路由守卫的写法上,接口调用层几乎可以完全复用同一套 TypeScript 类型定义。
方案C用 Express,是因为很多小项目根本不需要 NestJS 那套依赖注入和模块系统。Express 写起来快,但代价是项目一大就容易乱。我在方案C里刻意没有做分层,就是想看看“不分层能撑到多大”。结论是:超过 15 个接口之后,路由文件会变得很难维护。
方案D用 Spring Boot,是因为我接触过的企业客户里,后端十有八九是 Java。前端用 Vue3,是因为国内企业内网项目 Vue 的接受度确实更高。这一组是最“接地气”的组合。
方案E是纯 Mock,用 MSW(Mock Service Worker)拦截请求。它的用途很明确:前端同事可以在后端接口没写好之前就开工,或者在没有网络的环境下演示。
2.2 前端选 React 还是 Vue,我的真实判断标准
网上关于 React 和 Vue 的对比文章太多了,但大部分都在比语法。我实际用下来,选型时真正该看的是这三条:
第一,团队里有多少人能写出高质量的 Hook 或 Composition API。React 的 Hooks 写不好,依赖数组漏一个就是无限重渲染;Vue 的响应式虽然更“自动”,但 ref 和 reactive 混用也会出问题。如果团队里大部分人只会写选项式 API,那硬上 React 就是给自己找麻烦。
第二,需不需要跨端。如果未来可能要上 React Native,那前端选 React 能省很多事。Vue 这边虽然有 uni-app,但生态和 React Native 比还是两回事。
第三,组件库的匹配度。React 这边 Ant Design 的 Admin 模板成熟度很高,Vue 这边 Element Plus 和 Ant Design Vue 也够用。但如果你要的是一些偏门的图表或编辑器组件,React 生态通常更全。
我在方案A和方案B里用了完全相同的页面结构,就是为了让这个对比有据可依。实测下来,同一个表格页面,React 版本我写了 180 行,Vue 版本写了 150 行。Vue 少的那 30 行主要在状态声明和计算属性上,React 需要手动 useMemo 的地方,Vue 的 computed 自动就做了。
2.3 后端为什么 NestJS 值得单独拎出来说
NestJS 在这五种方案里出现了两次,不是因为我偏爱它,而是因为它解决了一个 Express 解决不了的问题:当接口数量超过 30 个之后,怎么保证代码结构不崩。
NestJS 的模块系统、守卫、拦截器、管道,本质上是在用一套约定把后端代码“框”住。比如权限校验,Express 里你可能在每个路由里写一遍中间件,NestJS 里写一个 Guard 就能全局挂载。再比如参数校验,NestJS 配合 class-validator,一个 DTO 类就能把请求体的类型和校验规则全定义清楚。
但 NestJS 也有代价。它的学习曲线比 Express 陡,装饰器和依赖注入对新手不友好。我在方案C里用 Express 就是想说:如果你的项目就是十几个接口,别上 NestJS,Express 加几个中间件就够了。
3. 核心功能模块的拆解与实现要点
3.1 登录鉴权:五种方案里最容易踩坑的地方
登录鉴权看起来简单,但我在五种方案里踩的坑最多。核心问题就一个:Token 存哪里、怎么刷新、路由守卫怎么拦。
先说 Token 存储。我试过 localStorage、sessionStorage、内存变量、HttpOnly Cookie 四种方式。最后在五种方案里统一用了“内存变量 + HttpOnly Cookie 刷新”的方案。原因是 localStorage 容易被 XSS 读取,而纯内存变量刷新页面就丢。具体做法是:登录成功后,后端把 accessToken 放在响应体里,前端存内存;同时把 refreshToken 写进 HttpOnly Cookie。accessToken 过期后,前端用 Cookie 里的 refreshToken 去换新的 accessToken。
这个方案在 React 和 Vue 里的实现差异不大,但有一个细节要注意:React 的 StrictMode 在开发环境会双调用 effect,导致刷新 Token 的请求发两次。我一开始没注意,后端日志里全是重复的刷新请求。解决办法是在刷新逻辑里加一个 pending 标志,或者干脆在开发环境关掉 StrictMode。
路由守卫这块,React 没有官方方案,我用的是 React Router 的 loader 函数配合一个 AuthProvider。Vue 这边用全局前置守卫 beforeEach。两者逻辑一样,但 Vue 的写法更集中,React 的写法更分散。如果你团队里新手多,Vue 的路由守卫更容易讲清楚。
注意:不管用哪种方案,路由守卫只能防“页面跳转”,不能防“接口调用”。真正的权限校验必须在后端每个接口上做。我见过太多项目只在前端拦路由,结果接口直接被 Postman 调穿。
3.2 动态路由菜单:后端返回菜单树,前端怎么渲染
动态路由是 Admin 系统的核心。我的做法是:后端返回一个菜单树,每个节点包含 path、component、meta(标题、图标、权限码)。前端拿到之后,递归转换成路由配置。
React 这边,我用的是useRoutes配合一个递归函数,把菜单树转成 RouteObject 数组。Vue 这边,用router.addRoute动态添加。两者的关键差异在于:React 的路由配置是“声明式”的,转换完直接渲染;Vue 是“命令式”的,需要主动调用 addRoute。
这里有一个坑我踩了两次:菜单树里的 component 字段是字符串,前端需要用一个映射表把它转成真实的组件。如果映射表里漏了一个组件,页面就会白屏。我的解决办法是写一个import.meta.glob(Vite)或require.context(Webpack)来自动收集 views 目录下的所有组件,这样新增页面不用手动改映射表。
在 NestJS 后端里,菜单树的生成逻辑我放在了一个 MenuService 里。它根据当前用户的角色,从数据库查出有权限的菜单,然后组装成树。组装的时候要注意排序和层级,我用的是递归加 sort。如果菜单数据量大,建议在数据库里存一个 path 字段,用 LIKE 查询直接拿子树,比递归快很多。
3.3 数据表格与增删改查:怎么少写重复代码
Admin 系统里 70% 的页面都是表格加表单。如果每个页面都手写一遍,代码量会爆炸。我在五种方案里都做了一层封装。
React 这边,我封装了一个useTableHook,接收 API 函数和查询参数,返回 data、loading、pagination、refresh 等。表单用 Ant Design 的 Form 组件,配合一个useFormModalHook 管理弹窗状态。Vue 这边,封装了一个useTable组合式函数,逻辑几乎一样,只是响应式写法不同。
这里有一个经验:不要过度封装。我一开始想把表格、表单、弹窗全塞进一个组件里,结果配置项多到没人愿意用。后来改成“表格一个 Hook、表单一个 Hook、弹窗一个 Hook”,每个 Hook 只干一件事,组合起来用,反而更灵活。
后端这边,NestJS 里我用了一个 BaseController 泛型类,把 list、detail、create、update、delete 五个接口的通用逻辑抽出来。子类只需要继承并传入实体和 DTO 就行。Express 方案里我没做这层抽象,结果每个路由文件都要写一遍分页逻辑,写到第五个模块的时候我就后悔了。
| 功能 | React 实现方式 | Vue 实现方式 |
|---|---|---|
| 表格数据获取 | useTable Hook + useEffect | useTable 组合式函数 + watch |
| 表单状态 | Ant Design Form + useForm | Element Plus Form + reactive |
| 弹窗控制 | useState + Modal | ref + Dialog |
| 分页 | 受控组件 + useMemo | 受控组件 + computed |
3.4 图表看板:React 和 Vue 的图表库选择
看板页面我用的是 ECharts,因为它在 React 和 Vue 里都有成熟封装。React 用 echarts-for-react,Vue 用 vue-echarts。两者的核心逻辑一样:把 option 对象传给组件。
但有一个性能问题要注意:如果看板上有多个图表,且数据会定时刷新,一定要用notMerge模式更新,否则 ECharts 会残留旧数据。我在 React 版本里一开始没加这个参数,结果切换时间范围后,旧的数据系列还留在图上。
另外,图表的 resize 处理也容易漏。React 里我用 ResizeObserver 监听容器变化,Vue 里用自定义指令。如果侧边栏可以折叠,不处理 resize 的话图表会变形。
3.5 WebSocket 消息推送:五种方案里的统一实现
消息推送我用的原生 WebSocket,没有上 Socket.IO。原因是 Socket.IO 虽然兼容性好,但会增加后端依赖,而且五种方案里都要配一遍太麻烦。
前端这边,我封装了一个useWebSocketHook(React)和useWebSocket组合式函数(Vue),负责连接、重连、心跳、消息分发。重连用的是指数退避,第一次 1 秒,第二次 2 秒,最多 30 秒。心跳是每 30 秒发一个 ping,如果 60 秒没收到 pong 就重连。
后端 NestJS 里用@WebSocketGateway装饰器,Express 里用ws库。Spring Boot 里用@ServerEndpoint。三者的写法差异很大,但协议层是一样的。这里要注意:WebSocket 的连接鉴权不能只靠 URL 参数传 Token,因为 URL 会被日志记录。我的做法是连接建立后,客户端先发一条 auth 消息,服务端校验通过后才允许订阅。
4. 实操过程与关键环节记录
4.1 项目初始化:五种方案怎么统一目录结构
为了让五种方案能对比,我把目录结构统一成了这样:
admin-{stack}/ ├── client/ # 前端 │ ├── src/ │ │ ├── api/ # 接口定义 │ │ ├── components/ │ │ ├── hooks/ # React 用 │ │ ├── composables/ # Vue 用 │ │ ├── router/ │ │ ├── store/ │ │ ├── views/ │ │ └── utils/ │ └── package.json ├── server/ # 后端 │ ├── src/ │ │ ├── modules/ │ │ ├── common/ │ │ └── main.ts │ └── package.json └── README.md初始化命令我列一下,方便你直接抄:
# React + NestJS npm create vite@latest client -- --template react-ts npx @nestjs/cli new server # Vue3 + NestJS npm create vite@latest client -- --template vue-ts npx @nestjs/cli new server # React + Express npm create vite@latest client -- --template react-ts mkdir server && cd server && npm init -y && npm i express # Vue3 + Spring Boot npm create vite@latest client -- --template vue-ts # Spring Boot 用 start.spring.io 生成,选 Web + Security # React + Mock npm create vite@latest client -- --template react-ts npm i -D msw这里有一个细节:Vite 创建 Vue 项目时,如果选 vue-ts 模板,默认是组合式 API 写法。如果你团队习惯选项式 API,可以在 vite.config 里配,但我不建议混用。我在方案B里试过组合式和选项式混写,结果一个组件里两种风格并存,维护起来很痛苦。
4.2 接口层设计:五种方案共用一套 API 契约
这是我觉得最值得说的一点。五种方案的前端虽然框架不同,但接口契约是完全一样的。我定义了一套 TypeScript 类型放在shared/types里,五种方案都引用它。
// shared/types/api.ts export interface ApiResponse<T> { code: number; message: string; data: T; } export interface PageResult<T> { list: T[]; total: number; page: number; pageSize: number; } export interface LoginParams { username: string; password: string; } export interface LoginResult { accessToken: string; userInfo: UserInfo; }前端请求层,React 和 Vue 我都用 Axios,封装了一个 request 实例,统一处理 Token 注入、错误提示、401 跳转。差异只在于错误提示用哪个组件库的 Message。
后端这边,NestJS 用 class-validator 定义 DTO,Express 用 zod,Spring Boot 用 Jakarta Validation。虽然写法不同,但校验规则是一致的。这样前端拿到的错误信息格式统一,不用为每个后端单独适配。
4.3 权限控制:从按钮级到接口级的完整链路
权限我分了三层:菜单权限、按钮权限、接口权限。
菜单权限前面说了,后端返回菜单树。按钮权限我用的是自定义指令。React 里封装了一个usePermissionHook,返回一个hasPermission函数,在 JSX 里用条件渲染。Vue 里写了一个v-permission指令,没有权限的按钮直接不渲染。
接口权限在后端做。NestJS 里用 Guard 配合@RequirePermission装饰器,Express 里用中间件,Spring Boot 里用@PreAuthorize。核心逻辑都是:从 Token 里解析出用户角色,查角色对应的权限码,比对接口要求的权限码。
这里有一个坑:权限码的命名要统一。我一开始用了user:add、user:edit这种格式,后来发现前端按钮和后端接口对不上。后来改成system:user:create、system:user:update这种三段式,前后端都按这个规则来,问题就少了。
提示:权限码不要硬编码在代码里,建议存数据库,用的时候查缓存。我见过一个项目把权限码写死在枚举里,结果加一个权限要改代码重新发版。
4.4 构建与部署:五种方案的打包差异
五种方案的构建命令大同小异,但部署方式有区别。
React 和 Vue 的前端都是npm run build出静态文件,扔到 Nginx 或 CDN 就行。NestJS 和 Express 后端用npm run build出 JS,用 PM2 或 Docker 跑。Spring Boot 用mvn package出 jar,java -jar跑。
Docker 部署我写了一个通用的 Dockerfile 模板:
# 前端 FROM node:20-alpine AS builder WORKDIR /app COPY package*.json ./ RUN npm ci COPY . . RUN npm run build FROM nginx:alpine COPY --from=builder /app/dist /usr/share/nginx/html COPY nginx.conf /etc/nginx/conf.d/default.confNginx 配置里要注意一点:如果前端用的是 history 路由,需要加try_files $uri $uri/ /index.html。这个我踩过坑,不加的话刷新页面直接 404。
后端 Dockerfile 里,NestJS 和 Express 都是 Node 镜像,Spring Boot 用 eclipse-temurin 镜像。环境变量通过 docker-compose 注入,数据库连接、JWT 密钥这些都走环境变量,不要写死在代码里。
5. 常见问题与排查技巧实录
5.1 跨域问题:五种方案里的表现和解决
跨域是 Admin 系统开发里最常见的问题。五种方案里,React 和 Vue 的前端开发服务器都要配代理。
Vite 的代理配置:
// vite.config.ts export default defineConfig({ server: { proxy: { '/api': { target: 'http://localhost:3000', changeOrigin: true, }, }, }, });NestJS 后端要开 CORS:
app.enableCors({ origin: 'http://localhost:5173', credentials: true, });Express 用 cors 中间件,Spring Boot 用@CrossOrigin或全局配置。
这里有一个坑:如果前端配了代理,后端又开了 CORS,有时候会出现“重复跨域头”的错误。解决办法是二选一,开发环境用代理,生产环境用 Nginx 反向代理,后端只在必要时开 CORS。
5.2 Token 过期后的并发请求处理
这个问题我在 React 和 Vue 版本里都遇到了。用户 Token 过期后,如果页面上同时有 5 个请求在飞,会触发 5 次刷新 Token 的请求。我的解决办法是在请求层加一个刷新队列:第一个 401 触发刷新,后续 401 先挂起,等刷新完成后再重试。
React 里我用了一个模块级的变量存刷新 Promise,Vue 里用了一个 ref。核心逻辑一样:
let refreshPromise: Promise<string> | null = null; async function handle401() { if (!refreshPromise) { refreshPromise = refreshToken().finally(() => { refreshPromise = null; }); } return refreshPromise; }这个逻辑看起来简单,但如果不加,后端日志里会全是重复的刷新请求,而且可能导致 refreshToken 被多次使用而失效。
5.3 动态路由刷新后白屏
这个问题在 Vue 里特别常见。原因是动态路由是登录后添加的,刷新页面时路由还没添加,直接访问当前路径就匹配不到组件,白屏。
解决办法是在路由守卫里判断:如果已登录但路由未添加,先拉取菜单、添加路由,再用next({ ...to, replace: true })重新导航。React 这边因为路由是声明式的,刷新后重新渲染时会重新计算,问题少一些,但也要注意 loader 的执行顺序。
5.4 表格分页与筛选的状态同步
Admin 系统里,用户经常在表格里筛选完、翻到第 3 页,然后点进详情,返回后希望还在第 3 页。这个状态同步如果做不好,体验很差。
我的做法是把分页和筛选参数同步到 URL query 里。React 用useSearchParams,Vue 用useRoute和useRouter。这样刷新、前进后退都能保持状态。但要注意:URL 参数要序列化和反序列化,数组类型的筛选条件要特殊处理。
| 问题 | 常见原因 | 解决方式 |
|---|---|---|
| 刷新白屏 | 动态路由未添加 | 守卫里先添加路由再放行 |
| 重复刷新 Token | 并发 401 | 刷新队列 + 单例 Promise |
| 跨域失败 | 代理和 CORS 冲突 | 开发用代理,生产用 Nginx |
| 图表残留 | 未用 notMerge | 更新时传 notMerge: true |
| 按钮权限失效 | 权限码不统一 | 三段式命名 + 后端校验 |
5.5 我踩过的三个印象最深的坑
第一个坑:NestJS 的 Guard 执行顺序。我一开始把权限 Guard 和登录 Guard 的顺序搞反了,结果未登录的请求先被权限 Guard 拦截,报的是“无权限”而不是“未登录”,前端跳转逻辑就乱了。后来查文档才知道,Guard 是按绑定顺序执行的,全局 Guard 先于控制器 Guard。
第二个坑:Vue 的 reactive 解构丢失响应式。我在一个组件里把 reactive 对象解构了,结果修改解构后的变量,视图不更新。后来改用toRefs或者直接不解构。这个坑在 Vue3 新手里面特别常见。
第三个坑:React 的 useEffect 依赖数组。我在一个表格页面里,把 fetchData 函数直接放在 useEffect 里调用,但依赖数组里没写 fetchData,导致筛选条件变了数据不刷新。后来把 fetchData 用 useCallback 包起来,依赖数组写全,问题解决。这个坑的本质是闭包捕获了旧的状态。
6. 五种方案的横向对比与选型建议
6.1 开发效率对比:我实际花的时间
我把五种方案的开发时间记录了一下,功能完全一样,都是登录、菜单、用户管理、角色管理、看板、消息推送六个模块。
| 方案 | 前端耗时 | 后端耗时 | 总耗时 |
|---|---|---|---|
| React + NestJS | 3 天 | 2 天 | 5 天 |
| Vue3 + NestJS | 2.5 天 | 2 天 | 4.5 天 |
| React + Express | 3 天 | 1 天 | 4 天 |
| Vue3 + Spring Boot | 2.5 天 | 3 天 | 5.5 天 |
| React + Mock | 2 天 | 0 | 2 天 |
这个时间只是我个人的,不代表团队。但能看出几个趋势:Vue 在前端确实快一点,主要是状态管理和路由守卫的代码量少。Express 后端最快,因为不用写模块和 DTO。Spring Boot 最慢,因为配置多,但它的类型安全和生态是优势。
6.2 维护成本对比:半年后还能不能看懂
开发快不代表维护好。我让一个没参与开发的同事看了这五套代码,让他评估“如果让你接手,哪套最容易看懂”。
他的反馈是:Vue3 + NestJS 最容易,因为 Vue 的单文件组件把模板、逻辑、样式放在一起,NestJS 的模块结构清晰。React + Express 最难,因为 React 的 Hook 逻辑分散,Express 的路由文件又长又乱。Spring Boot 居中,结构清晰但配置多。
这个反馈印证了我的判断:如果团队人员流动大,选 Vue + NestJS 这种“约定强”的组合,接手成本更低。如果团队稳定且追求灵活,React + Express 也能用,但要做好代码规范。
6.3 我的选型建议:按场景对号入座
最后给一个直接的选型建议,不绕弯子:
- 新项目、团队 5 人以上、需要长期维护:React + NestJS 或 Vue3 + NestJS。NestJS 的模块化在项目变大后优势明显。
- 小项目、快速验证、团队 2-3 人:React + Express 或 Vue3 + Express。别上 NestJS,杀鸡用牛刀。
- 企业内网、后端已有 Java 体系:Vue3 + Spring Boot。前端用 Vue 是因为国内企业内网 Vue 的接受度高,后端跟着现有体系走。
- 纯前端演示、组件库开发:React + Mock。MSW 拦截请求,不依赖后端,开发体验很好。
我个人在实际操作中的体会是:技术栈没有绝对的好坏,关键是和团队匹配。我见过用 React 写得一团糟的团队,也见过用 Vue 写出高质量代码的团队。选型时多问问“谁来维护”,比问“哪个技术新”有用得多。
最后再分享一个小技巧:如果你也在做多技术栈对比,建议把接口契约和类型定义抽出来单独维护。这样不管前端换什么框架,后端换什么语言,契约层不用动。我在这个项目里就是这么做的,五种方案共用一套类型定义,改一个字段五套代码同时生效,省了很多同步的功夫。