ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

React+Express+Ant Design:从零搭建轻量级后台管理系统脚手架

React+Express+Ant Design:从零搭建轻量级后台管理系统脚手架

先说个挺有意思的事。项目名叫 rea,其实一开始只是我随手敲的一个临时目录名——当时想写一个给团队内部用的后台管理模板,懒得想名字,就顺手把三个技术栈的首字母拼在一起:React、Express、Ant Design。谁也没想到,这个名字一用就是两年,从最初几十行的 Demo 长成了一个能支撑多个业务系统快速交付的脚手架。

这篇文章就围绕 rea 这个项目,聊聊它在设计思路、技术取舍、核心功能实现和实际部署过程中那些值得记录的细节,也会把我在这个项目上踩过的坑一并交代出来。如果你正打算从零搭一套中后台管理系统,或者想在前后端分离架构里找一套轻量可靠的参考方案,那这篇内容应该能给你不少可以直接抄作业的东西。

1. rea 是什么:一次“随手命名”背后的真实需求

先别急着看代码,我得先说说这项目为什么会长成现在这样。很多时候,一个项目的形态不是设计出来的,是被需求逼出来的。

1.1 名字的由来与技术栈定位

rea 的定位很明确:一套基于 React + Express + Ant Design 的轻量级后台管理系统基础工程。前端负责页面交互和状态管理,后端提供 RESTful 接口和身份鉴权,Ant Design 负责把界面规范统一起来。这种组合在今天看来没什么稀奇,但在当时,团队里同时存在好几套风格完全不同的后台项目:有的是 jQuery 老项目,有的是 Vue 写的,有的干脆用服务端模板直接渲染页面。每次接新业务,都要在新老技术栈之间反复横跳,维护成本很高。

之所以选 React 而不是 Vue,主要是考虑到团队里已有的组件积累和社区生态。Ant Design 的 React 版本在后台管理领域已经非常成熟,表格、表单、弹窗、上传这些高频组件都能直接拿来用,不用重复造轮子。后端选 Express 也不是因为它功能多强,而是它足够轻、足够简单,中间件机制灵活,能快速把接口层搭起来。对于一个以“快速交付业务页面”为目标的脚手架来说,这就够了。

1.2 它解决了什么问题

如果你维护过三个以上的后台项目,就会理解我当时的痛苦:每个项目都有自己的登录逻辑、权限判断、菜单配置、请求封装、错误处理,代码风格还完全不一样。A 项目改完的 bug,在 B 项目里再犯一次;C 项目好不容易把请求超时逻辑写对,D 项目又用了一套全新的写法。

rea 要解决的,就是把这一类“每个后台系统都长得很像”的部分全部收敛成基建能力。具体来说,它要帮团队干几件固定的事:

  • 提供统一的登录、登出、会话保持机制
  • 把路由权限和菜单渲染做成配置驱动,业务开发不需要关心权限逻辑本身
  • 封装统一的 axios 请求实例,把 token 注入、错误提示、超时处理、接口取消收口到一个文件里
  • 沉淀一套 Ant Design 主题配置,让各业务线的界面风格基本一致
  • 提供一个可扩展的 Express 服务端骨架,新业务只需要往里加路由和业务表

前面三点是大多数后台项目都绕不开的核心诉求,也是 rea 这个工程里我最看重的部分。后面我会把实现细节拆开来讲。

1.3 适合谁来参考

这项目不是一个大而全的企业级框架,它更像是一个“刚刚好”的中间态:比手写所有东西要省事,又比引入一套重型微前端体系要简单得多。所以它适合这么几类人:

  • 需要快速交付内部运营后台、管理端页面的前端开发
  • 想了解前后端权限闭环怎么设计、但不想一上来就看几千页复杂框架文档的初中级开发者
  • 团队里技术栈比较分散,想收拢统一规范但暂时没有精力上重型方案的团队

如果你已经在用某套成熟框架,那对 rea 的定位可以换个角度理解:它不是一个需要替代谁的东西,而是一份展示“基础工程最少需要做什么”的参考答案。

2. 整体架构与工程结构设计

一个项目好不好用,看目录结构就能猜个大概。rea 的目录结构是在踩了两三次坑之后才稳定下来的,前后经历过三版调整。我直接把最后一版的结构和设计思路讲清楚。

2.1 前后端分离还是同仓库部署

rea 最开始是纯前端项目,后端接口是找别的服务要的。后来因为接口联调太频繁,干脆把 Express 服务直接放进同一个仓库,前端在后端 serve 的静态目录下构建。这样做的好处是显而易见的:

  1. 联调阶段不需要同时维护两个仓库两个端口,启动一个服务就能打通整个链路
  2. 部署逻辑简单,一份产物包、一个进程
  3. 后端代码和前端页面天然在一起,接手的人看代码能更快建立完整的上下文

代价也很明显:前后端耦合在同一份代码库里,如果团队规模变大、需要分别独立发布,就会比较被动。所以我做了个折中——在目录上严格分层,前端和后端的核心代码完全独立,只是共用同一个 package.json 的启动脚本。真到要拆分的时候,把两个目录拎出来各自加个 CI 流程就行,不需要改业务代码。

2.2 目录结构设计

直接看目录:

rea ├── client # 前端代码 │ ├── src │ │ ├── api # 接口定义,按业务模块拆分 │ │ ├── assets # 静态资源 │ │ ├── components # 公共业务组件 │ │ ├── layouts # 后台整体布局 │ │ ├── pages # 页面组件 │ │ ├── router # 路由配置与权限守卫 │ │ ├── store # 全局状态 │ │ ├── utils # 请求实例、鉴权工具等 │ │ ├── App.jsx │ │ └── main.jsx │ ├── index.html │ └── vite.config.js ├── server # 后端代码 │ ├── controllers # 控制器:参数校验、调用 service │ ├── routes # 路由表 │ ├── services # 业务逻辑层 │ ├── models # 数据模型定义 │ ├── middleware # 鉴权、日志、错误处理中间件 │ ├── app.js # Express 应用入口 │ └── index.js # 服务启动入口 ├── package.json └── README.md

这个结构里有两个点是我特别坚持的。第一,前端必须有独立的 api 目录——所有接口调用不能散落在页面里,而是以函数形式集中定义。这样后端接口一改,前端只用改一个文件。第二,后端必须拆出 service 层——早期我图省事把业务逻辑全写在 controller 里,后来发现一旦逻辑复杂起来,controller 又膨胀又没法测。如果你是从零写 Express,我建议从一开始就养成 controller 只做参数校验、service 管业务逻辑的习惯,后面会少很多重构的破事。

2.3 数据流与状态管理

后台管理系统里,真正需要全局共享的状态其实不多:用户信息、登录态、菜单权限、一些全局配置。rea 早期用的是原生 Context,后来业务多了之后发现 Context 在组件更新上容易带来不必要的渲染,而且异步更新的写法很别扭。

项目里我最终用的是 zustand,而不是更大的状态库。选型理由很简单:

  • 不需要写一堆 boilerplate 代码
  • 不需要额外引入装饰器或类型体操
  • API 极简,一个 create 就能定义一个 store
  • 性能上按 selector 订阅,不会引起整棵组件树重新渲染

数据流方向我规定得很死:页面发起请求 → 请求函数从 store 读取 token → 接口返回 → 更新 store 或本地 state。对于后台页面,单向数据流能覆盖绝大多数场景,状态多了以后也容易定位问题。

3. 核心功能实现拆解

这一部分是全文最硬核的干货。rea 看起来是个普通后台脚手架,但它真正的难点和亮点都在几个“看不见”的地方:登录怎么做、权限怎么控制、菜单怎么动态生成。

3.1 登录与鉴权链路

后台系统的鉴权,常见方案是 Token。rea 用的是JWT + Authorization Header的方式,简单说流程是这样的:

  1. 用户提交用户名密码,后端校验通过后签发一个 JWT
  2. 前端把 JWT 存在 localStorage 里,后续请求通过 axios 拦截器自动加到 header 上
  3. 后端在全局中间件里校验 JWT,验证通过放行,否则返回 401
  4. 前端拿到 401 后,清除本地登录态,跳回登录页

这个链路本身不复杂,但有一个细节很容易被忽略:token 过期后的处理。rea 的做法是后端在返回 401 时带上一个特定标记头,前端 axios 拦截器里对“登录失效”和“普通接口错误”进行区分,只对前者做强制登出逻辑。这么做的好处是不会因为某个普通接口报错就把用户无缘无故踢下线。

后端关键代码是这个中间件:

const jwt = require('jsonwebtoken'); function authMiddleware(req, res, next) { const authHeader = req.headers.authorization || ''; const token = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : ''; if (!token) { res.setHeader('X-Login-Expired', 'true'); return res.status(401).json({ code: 401, message: '未登录或登录已过期' }); } try { const decoded = jwt.verify(token, process.env.JWT_SECRET); req.user = decoded; next(); } catch (err) { res.setHeader('X-Login-Expired', 'true'); return res.status(401).json({ code: 401, message: '登录状态无效' }); } }

提示:process.env.JWT_SECRET一定要通过环境变量或配置文件注入,不要把密钥硬编码在源码里。这个 key 一旦泄露,任何人都能伪造合法 token。

3.2 路由级与按钮级权限控制

权限控制是后台系统最容易被做烂的部分。很多项目做权限的思路是:前端路由加载的时候把所有页面都注册进去,然后根据用户角色判断“能不能显示”。这个思路在页面少的时候还行,页面一多,代码里就全是if (hasPermission('xxx')),又难看又难维护。

rea 换了一种方式:用配置驱动的权限模型。每个用户登录后,后端返回的不是简单的角色名字符串,而是一份权限标识数组,例如['dashboard:view', 'order:edit', 'user:delete']。前端根据这份数组动态生成路由和菜单。

路由配置长这样:

const routeConfig = [ { path: '/dashboard', component: () => import('@/pages/Dashboard'), meta: { title: '工作台', icon: 'DashboardOutlined' }, permission: 'dashboard:view' }, { path: '/orders', component: () => import('@/pages/Orders'), meta: { title: '订单管理', icon: 'ShoppingOutlined' }, permission: 'order:view', children: [ { path: '/orders/detail', component: () => import('@/pages/Orders/Detail'), meta: { title: '订单详情', hidden: true }, permission: 'order:detail:view' } ] } ];

路由守卫在每次跳转前检查目标路由的meta.permission是否存在于用户权限数组中。如果不存在,直接重定向到 403 页面。这样页面路由的可见性和权限完全绑定,不会再出现“菜单里没这个入口,但用户手动输入 URL 还能打开页面”的漏洞。

按钮级权限用了一个封装好的AuthWrapper组件:

function AuthWrapper({ permission, children }) { const hasPermission = usePermission(permission); if (!hasPermission) return null; return children; }

用法就是:

<AuthWrapper permission="order:edit"> <Button type="primary">修改订单</Button> </AuthWrapper>

实际用下来,这种方式比自定义指令(比如 Vue 系习惯用的v-permission)更直观,组件的显隐逻辑一眼能看懂,也方便在遇到特殊情况时再包一层业务判断。

3.3 动态菜单与接口权限对齐

菜单也是根据权限数组动态生成的,不用在代码里写死导航栏。这里有个容易翻车的地方:菜单的排序和分组。如果一个菜单项不在权限数组里,前端不能简单粗暴地把它过滤后就完事,还要考虑它下面的子菜单是否全部不可见。如果所有子菜单都被过滤掉,父菜单也要一起隐藏。这个遗漏会导致菜单里出现一个点了没有反应的空分组,用户会以为系统坏了。

rea 的处理逻辑是这样:先递归过滤菜单树,再把过滤后仍然有可见子菜单的父级保留。这层逻辑放在一个纯函数里,方便单测覆盖。

function filterMenuByPermissions(menuTree, permissions) { return menuTree .map((item) => { if (item.children) { const filteredChildren = filterMenuByPermissions(item.children, permissions); if (filteredChildren.length === 0) return null; return { ...item, children: filteredChildren }; } if (!item.permission) return item; return permissions.includes(item.permission) ? item : null; }) .filter(Boolean); }

注意:权限数组的可信源头在后端。前端根据权限数组去渲染菜单只是体验层面的处理,真正拦截越权请求必须依靠后端每个接口的权限校验。两边的权限标识要一致,所以我把权限码统一定义在一份常量表里,前后端共享这份表来对齐。

4. 实操记录:从初始化到联调上线

前面讲的是设计和实现原理,现在我把一次从零初始化 rea 的过程完整走一遍。整个流程我实测过很多次,跟着做基本不会卡壳。

4.1 初始化工程

前端我用 Vite 做构建工具,npm 执行:

npm create vite@latest client -- --template react

这里要说明一下为什么是 Vite 而不是 webpack:Vite 的冷启动速度在开发阶段优势太大了,尤其在项目体量变大之后,webpack 重启一次可能要等几十秒,Vite 能做到毫秒级的热更新。传统后台项目对构建工具的依赖主要体现在开发体验上,Vite 在这方面没有对手。

后端目录手动创建,root 下的package.json里配置一次性启动脚本:

{ "scripts": { "dev": "concurrently \"npm run dev:server\" \"npm run dev:client\"", "dev:server": "nodemon server/index.js", "dev:client": "vite --config client/vite.config.js", "build": "vite build --config client/vite.config.js" } }

安装依赖时有个小坑:React Router 的版本差异很大,一定要锁定版本号。rea 用的是路由库 v6 版本,API 和 v5 完全不兼容。如果直接npm install react-router-dom装到最新版本,很多老教程的写法会直接报错。

4.2 后端接口与数据库设计

后端我用 Express + Sequelize + SQLite 的组合作为默认实现。SQLite 不是生产环境的最终选择,但它有一个任何数据库都不具备的优势:零配置、单文件、随项目走。开发阶段拿它来跑通全流程,等要部署的时候再切换到 MySQL 或者 PostgreSQL,只需要改 Sequelize 的连接配置,模型层代码不用动。

数据库表设计围绕着权限模型展开,一共四张表:

users 用户表 roles 角色表 permissions 权限表 user_roles 用户-角色中间表 role_permissions 角色-权限中间表

用户登录成功之后,后端从这几张表里查出该用户所有角色对应的权限码集合,统一放在 JWT 的一个字段里返回。

这里我得提醒一句:权限码集合放进 JWT 会导致 token 变长。权限特别多的时候,token 可能会超过 4KB,有些代理服务器对 header 大小有限制。rea 的做法是把权限码用逗号拼接后压缩存储,项目规模中等的情况下实测没有触发限制。真要到了几千个权限点,就得考虑改成登录后请求一次/me/permissions接口拿权限列表,不做进 token。

业务接口开发遵循一个模板:

  1. 在models里定义数据模型
  2. 在routes里挂载路由
  3. 在controllers里做参数校验和调用 service
  4. 在services里写具体的增删改查逻辑

举一个典型的订单接口:

// routes/order.js const express = require('express'); const router = express.Router(); const orderController = require('../controllers/orderController'); const permissionMiddleware = require('../middleware/permission'); router.get( '/list', permissionMiddleware('order:view'), orderController.list ); router.post( '/update', permissionMiddleware('order:edit'), orderController.update ); module.exports = router;

4.3 前端页面与接口对接

前端页面拿数据统一走api目录下的函数。比如订单列表页,接口函数放在src/api/order.js:

import request from '@/utils/request'; export function fetchOrderList(params) { return request.get('/api/order/list', { params }); } export function updateOrder(data) { return request.post('/api/order/update', data); }

request 实例里做三件事:token 注入、错误统一处理、超时设置:

import axios from 'axios'; import { getToken, clearAuth } from './auth'; const request = axios.create({ baseURL: '/api', timeout: 10000 }); request.interceptors.request.use((config) => { const token = getToken(); if (token) { config.headers.Authorization = `Bearer ${token}`; } return config; }); request.interceptors.response.use( (response) => response.data, (error) => { if (error.response?.status === 401) { clearAuth(); window.location.href = '/login'; } return Promise.reject(error); } ); export default request;

页面里调用的时候配合表格组件,把接口函数作为数据源,整个页面的代码量会压缩得很低。

4.4 构建与部署

rea 的部署形态我前面说过是同仓库部署。生产环境构建流程是:

npm install npm run build node server/index.js

前端构建产物在client/dist目录下,Express 里通过静态资源中间件把它 serve 出来:

const express = require('express'); const path = require('path'); const app = express(); app.use(express.static(path.join(__dirname, '../client/dist'))); app.get('*', (req, res) => { res.sendFile(path.join(__dirname, '../client/dist/index.html')); });

这里有个很容易被忽略的点:app.get('*', ...)的兜底路由必须放在所有 API 路由之后注册,否则会拦截掉 API 请求。我用了一招:所有 API 路由强制带上/api前缀,静态资源和 API 完全隔离,从根路径上消除这类冲突。

部署环境我用的是 PM2 管理进程,日志输出到统一文件。在server/index.js里监听端口前先读环境变量,本地开发默认 3000,生产环境由平台注入:

const PORT = process.env.PORT || 3000; app.listen(PORT, () => { console.log(`Server running at http://localhost:${PORT}`); });

5. 常见问题与排障经验实录

接下来这部分,是全文我觉得最有价值的地方。下面每一个问题我都真实遇到过,有的一次排查花了大半天。我把问题和排查思路整理成速查表,你以后遇到了可以直接对照着看。

5.1 跨域与 Cookie 问题

因为 rea 前后端同源部署,跨域问题本来不存在,但开发环境下前端跑在 Vite 的 5173 端口,后端跑在 3000 端口,就产生了开发环境独有的跨域。

我最初的解决方案是在 Vite 配置文件里加代理:

// client/vite.config.js export default { server: { proxy: { '/api': { target: 'http://localhost:3000', changeOrigin: true } } } };

代理配置好之后,前端代码里的请求地址不用写全路径,直接写/api/xxx,浏览器看到的请求是同源的,自然不存在跨域问题。

这里要说一个容易误判的排查方向:如果你看到浏览器报CORS error,先看看是不是开发环境代理没生效,而不是急着在后端代码里加cors()中间件。两者混用反而会导致重复的跨域响应头。有的场景后端需要允许其他来源访问时,再用专门的跨域处理方案也不迟。

5.2 刷新后菜单丢失

这个问题的现象是:登录之后一切正常,但手动刷新页面后,菜单变成空白,或者跳回登录页。

排查思路要先区分情况:

  • 如果刷新后直接跳登录页:多半是 token 在刷新过程中被清掉了,或者 token 本身过期了
  • 如果刷新后还在系统里,但菜单空白:说明权限数据在前端刷新后没有被重新获取,store 里的数据被清空了

rea 的解决方案是:路由守卫里加一道“权限预热”逻辑。刷新页面时,如果发现本地有 token 但 store 里没有权限数据,就先请求一次/me/permissions把权限拉回来,再放行路由。这个步骤在刷新场景下几乎是必需的,因为任何全局状态都在页面刷新后归零,必须有一个可靠的恢复机制。

router.beforeEach(async (to, from, next) => { const token = getToken(); if (!token && to.path !== '/login') { next('/login'); return; } if (token && store.permissions.length === 0) { await store.fetchUserAndPermissions(); } next(); });

5.3 权限数据被篡改

rea 的权限码虽然来自于后端接口,但如果前端把权限数组存进了 localStorage,有一定技术基础的用户是可以手工篡改的。我收到过一次反馈,说系统里的某个按钮他明明没有权限,但改了一个本地存储值之后就显示出来了。

这种问题的根子在于:前端权限控制只是体验优化,不是安全边界。真正要防越权,必须要在后端每个接口上鉴权。改造方法我在 3.3 节已经提到过,权限码可信源头在后端,前端只是“展示层过滤”。如果你在前端把权限数组仅仅当做一个“UI 显示开关”,而不去管后端接口的权限校验,那这样的系统等于把大门钥匙挂在了门把手上。

5.4 页面加载性能问题与构建体积控制

后台系统页面一多,首屏加载速度就会变慢。rea 在优化过程中做了三件事:

  1. 路由懒加载:每个页面组件都用动态import()引入,只加载当前路由需要的内容
  2. 按需引入 Ant Design 组件:不要全量引入组件库,而是单独引入用到的组件和图标
  3. 依赖分包:Vite 构建时把第三方库单独拆出来,配上长效缓存,第二次加载会快很多

Vite 的配置大致是这样的:

build: { rollupOptions: { output: { manualChunks: { react: ['react', 'react-dom', 'react-router-dom'], antd: ['antd'], axios: ['axios'] } } } }

实测下来,首屏 gzip 体积从最初将近 1MB 降到了 450KB 以内,整体体感是能接受的。不过不要过于追求首屏体积的绝对最小值——后台系统本来就是登录后才能使用,用户不关心登录页多快,更关心登录进去之后操作流不流畅。

5.5 开发模式下的经典疑难盘点

我再列几个高频问题,方便你排查时快速定位:

现象可能的根因解决思路
请求返回 404,但接口明明写了路由挂载顺序问题确认兜底路由在 API 路由后面注册
登录后刷新立即跳回登录页store 未做权限预热在路由守卫里补权限恢复逻辑
页面能打开但接口 401token 未正确注入 header检查 request 拦截器和 token 读取逻辑
后端改了接口前端不生效开发环境代理缓存重启 Vite 开发服务或者清缓存
生产环境接口正常但页面刷新 404静态资源服务未配置兜底加app.get('*')发送 index.html

排查这类问题的通用思路,我建议按“链路追踪”的方式走:从页面的请求发出开始,看 header 带没带 token,看后端中间件校验是否通过,看路由是否被拦截,逐步缩小范围。不要一上来就乱猜原因,更不要凭感觉改代码。

最后再说几句实在话

rea 这个项目走到现在,给我最大的感触不是某个技术用得多巧妙,而是基础工程里那些不起眼的决定,会在长期维护中被无限放大。登录、权限、路由、请求封装,看起来好像每个人都会做,但细节上的取舍直接决定了后面业务开发是如鱼得水还是寸步难行。

如果你现在也正准备搭一套类似的系统,我的建议是:别再纠结技术栈的新旧了,先把权限链路和请求层这两条最核心的脉络理清楚,把这部分真正做到位,就已经赢过了市面上大部分后台模板。剩下的页面开发,其实只是往已经铺好的轨道上填砖。

以后 rea 这个项目我可能还会继续扩展,比如接入更完善的多级缓存策略、增加一些常用的业务组件库沉淀,但骨架不会再大改了。因为它已经足够简单,简单到每个接手的人都能在半天之内看懂全貌,这对我来说就是最理想的状态。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进