接手这类“开箱即用”的Vue + Express前后端分离项目也快三年了,我见过太多人从GitHub上拉下代码,结果跑起来各种报错、环境对不上、接口联不通,最后又灰溜溜回去自己从头搭。与其被半成品折磨,不如自己动手把一套完整可用的架子吃透,再交付给别人使用。
这套项目我称之为“功能完善的前后端分离样板间”,前端是Vue 3 + Vite + Pinia + Vue Router,后端是Express + Prisma + MySQL,中间夹了一层JWT认证和基于RBAC的权限控制。它不是教学Demo那种只能跑通登录的玩具,而是带上真实业务逻辑、可扩展模块、统一响应结构、前端路由守卫、后端中间件体系的工程化项目。这篇文章我会把设计和落地过程完整拆开,配合实操步骤、关键代码和排查经验,让你拿到构造思路后能改造成自己的底座,而不是只知道按F5跑起来。
1. 项目整体设计与技术选型
架构这事儿,最忌讳“为了用而用”。很多初学者一上来就上一堆微服务、K8s,压根没想清楚自己要解决什么。这套项目的定位很明确:给中小型业务快速搭一套能上线的前后端分离底座,同时保持结构清晰、依赖可控、能随时替换第三方服务。
1.1 为什么选Vue 3 + Vite而不是Vue 2 + Webpack
选型逻辑一句话解释:Vue 3的组合式API(Composition API)让逻辑复用和代码组织更符合现代工程习惯,而Vite的冷启动速度和HMR(热更新)体验能直接把开发效率拉起来。
我之前手里的老项目是Vue 2 + Webpack,每次启动要等十几秒,改个样式热更新也要两秒。换到Vite之后,启动基本秒开,改代码几乎即时刷新。这在日常迭代频繁的中后台项目里感受特别明显。更重要的是Vite基于ESBuild预构建依赖,本地开发不再需要打包整个项目,而生产构建时它会回退到Rollup,保证产物体积和优化策略可控。
组合式API的价值则体现在功能模块的聚合上。以前Options API写一个用户管理页,data、computed、methods、watch散落在不同块里,代码一多就要上下反复横跳。Composition API可以把某个业务功能的变量和操作函数放在同一个setup作用域下,比如用户列表的查询条件、分页状态、加载逻辑、刷新方法全都可以独立抽成一个useUserList函数,哪块需要就直接调用。
1.2 后端为什么用Express而不是Koa或NestJS
Express是一个很“务实”的选择。它的中间件模型简单直接,生态庞大,几乎所有你需要的功能都能找到现成方案。虽然Koa的洋葱模型更优雅,NestJS的依赖注入和模块化更工程化,但对一个“开箱即用”的项目模板来说,Express的低心智负担和高兼容性是最稳的。
举个例子:写一个统一的响应包装中间件,Express里就是app.use((req, res, next) => { ... }),逻辑清晰,调试也容易。而NestJS这类重框架虽然提供了装饰器和模块系统,但光理解Module、Controller、Service、Provider这套概念就要花不少时间,容易把注意力从业务逻辑转移到框架本身。
选Express还有一层考虑:肩并肩降级成本。如果后面团队要迁移到NestJS,Express写的控制器逻辑基本上可以原样复制过去,NestJS底层本来就是Express,接口层改动很小。
1.3 数据库层选用Prisma还是直接写SQL
这套项目用的是Prisma ORM。在Express生态里写SQL查询固然灵活,但维护成本高,尤其是表结构频繁变动时,手写迁移脚本极其痛苦。Prisma提供了一套声明式的Schema定义,改表结构之后直接跑一条prisma migrate dev就能生成迁移文件并同步数据库,效率很高。
Prisma的client还自带类型推导,配合TypeScript使用,查询结果就是强类型的,不用担心字段名写错在运行时才发现。这一点在我实际开发中省了很多排查时间。你要是更习惯写SQL,项目里也保留了$queryRaw的接口,复杂报表可以直接手写SQL,两者互不冲突。
顺手补充一个用户体验层:后端统一返回格式是
{ code: number, data: any, message: string }。这样前端axios拦截器只需要判断code就可以处理绝大多数业务异常,不需要写一堆重复的try/catch。
2. 项目结构与核心功能模块拆解
拿到项目之后,先别急着跑,把整个目录结构读一遍,理清每一层是干什么的,后面改代码才能精准定位。
2.1 前端目录结构与职责划分
我习惯把前端代码按api、assets、components、router、stores、views、utils分类:
frontend/ ├─ public/ ├─ src/ │ ├─ api/ # 所有接口请求封装 │ │ ├─ user.js │ │ ├─ order.js │ │ └─ request.js # axios实例封装 │ ├─ assets/ # 静态资源 │ ├─ components/ # 通用组件 │ ├─ router/ # 路由配置与守卫 │ ├─ stores/ # Pinia状态管理 │ ├─ utils/ # 封装的工具函数 │ ├─ views/ # 页面组件 │ │ ├─ login/ │ │ ├─ dashboard/ │ │ └─ system/ │ ├─ App.vue │ └─ main.jsapi目录单独存在这件事,很多人不重视。我见过把axios请求直接写在组件里的项目,结果接口地址分散在各个页面,一改动就全局搜索,非常痛苦。正确做法是把所有接口集中在api层,页面组件只调用api/user.js暴露的函数,这样后端接口变更时,只需要改一处封装即可。
2.2 后端目录结构与分层原则
后端按功能模块划分目录,而不是按文件类型划分,这是大型项目的基础:
server/ ├─ prisma/ │ ├─ schema.prisma # 数据模型定义 │ └─ migrations/ # 迁移文件 ├─ src/ │ ├─ config/ # 配置项 │ ├─ controllers/ # 控制器:接收请求、调用服务、返回响应 │ ├─ middlewares/ # 自定义中间件 │ ├─ routes/ # 路由定义 │ ├─ services/ # 业务逻辑层 │ ├─ utils/ # 工具函数 │ ├─ app.js # Express应用入口 │ └─ server.js # 服务启动文件 └─ .env # 环境变量核心分层原则是:控制器不写业务逻辑,服务层不直接操作req/res,数据访问通过Prisma的Client完成。这样写的好处是,将来如果要做单元测试,可以直接测服务层的方法,不需要启动HTTP服务。
举个例子,用户注册功能,在controller里只是解析参数、调用authService.register()、处理错误、返回结果。真正的密码加密、用户名重复检查、创建默认角色等逻辑,全部放在service层,便于复用和维护。
2.3 核心功能模块一览
这套项目包含以下核心模块:
| 模块 | 功能说明 | 核心依赖 |
|---|---|---|
| 认证与授权 | JWT登录、刷新令牌、RBAC权限 | jsonwebtoken |
| 用户管理 | 用户CRUD、状态禁用、分配角色 | Prisma |
| 角色管理 | 角色CRUD、菜单权限绑定 | Prisma |
| 菜单管理 | 动态菜单、前端路由映射 | 自定义 |
| 操作日志 | 记录关键操作行为 | 自定义中间件 |
| 文件上传 | 单文件/多文件上传、大小限制 | multer |
| 数据统计 | 用户数量、下单量等聚合统计 | Prisma聚合函数 |
每个模块都是独立Route,独立Controller,互不干扰。要新增模块时,只需要在routes下新建文件,在app.js里挂载一下,就能快速接入。
3. 实操搭建过程与关键代码实现
这一部分是真正的干货区。我会从零开始带你复现整套项目搭建的流程,顺便把关键配置的理由讲清楚。
3.1 前端项目初始化与基础配置
先用Vite脚手架创建一个Vue 3项目:
npm create vite@latest frontend -- --template vue cd frontend npm install装上核心依赖:
npm install vue-router@4 pinia axios然后是Vite的代理配置,这一步非常关键。开发环境前后端分离,最大的坑就是跨域。前后端不在同一个端口,浏览器会根据同源策略拦截所有请求。解决方式不是在后端疯狂加CORS头,而是开发环境用Vite代理把请求转发到后端端口。
在vite.config.js里配置:
import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' export default defineConfig({ plugins: [vue()], server: { port: 5173, proxy: { '/api': { target: 'http://localhost:3000', changeOrigin: true } } } })这样前端请求/api/users时,Vite会自动转发到http://localhost:3000/api/users,绕开了跨域问题。生产环境则由Nginx做同样的反向代理,代码里不需要写死后端地址。
3.2 Axios实例封装与拦截器
request.js是整个前端的网络入口,我建议一次性把逻辑写完整:
import axios from 'axios' import { ElMessage } from 'element-plus' import router from '@/router' import { useUserStore } from '@/stores/user' const request = axios.create({ baseURL: '/api', timeout: 10000 }) // 请求拦截器:附加token request.interceptors.request.use(config => { const userStore = useUserStore() if (userStore.token) { config.headers.Authorization = `Bearer ${userStore.token}` } return config }) // 响应拦截器:统一处理业务错误 request.interceptors.response.use( response => { const res = response.data if (res.code !== 0) { ElMessage.error(res.message || '请求失败') // 401统一跳转登录 if (res.code === 401) { userStore.logout() router.push('/login') } return Promise.reject(new Error(res.message)) } return res.data }, error => { ElMessage.error(error.response?.data?.message || '网络异常') return Promise.reject(error) } ) export default request这里有两个设计细节值得说明。
第一,后端所有业务成功都返回code: 0,而不是HTTP 200。为什么不用HTTP状态码?因为HTTP状态码被浏览器和网络协议占用了,业务上一些语义,比如“验证码错误”“库存不足”,本质上不是HTTP层面的错误,用HTTP状态码表达会很怪。统一业务码处理更清晰。
第二,401统一拦截。用户登录过期后,无论哪个接口返回401,前端都直接清空本地登录态,跳回登录页。这比每个页面自己判断要省事得多,也不会出现“页面还在,点啥都没反应”的尴尬状态。
3.3 路由守卫与动态菜单
前后端分离项目的权限控制重点在前端路由。用户登录后,后端会根据角色返回该用户可访问的菜单和路由配置,前端动态添加路由。
先看基础路由:
const routes = [ { path: '/login', component: () => import('@/views/login/index.vue'), meta: { public: true } }, { path: '/', component: () => import('@/layouts/DefaultLayout.vue'), redirect: '/dashboard', children: [] } ]然后写路由守卫,核心逻辑是三步检查:有没有token、用户信息是否加载过、目标路由是否在权限列表中:
router.beforeEach(async (to, from, next) => { const userStore = useUserStore() if (!userStore.token) { if (to.meta.public) { next() } else { next('/login') } return } if (!userStore.userInfo) { try { await userStore.fetchUserInfo() // 动态注册可访问路由 const accessRoutes = generateRoutes(userStore.menus) accessRoutes.forEach(r => router.addRoute(r)) next({ ...to, replace: true }) } catch (e) { userStore.logout() next('/login') } return } next() })动态路由要用router.addRoute()逐个添加,但有个坑:页面刷新后动态路由会丢失。所以每次刷新后都要重新调一次fetchUserInfo(),把菜单和路由信息重新拉取下来,再重新注册。这就是为什么守卫里要等userStore.userInfo加载完成的原因。
3.4 后端Express项目初始化与中间件体系
后端目录先在根目录下创建server,然后初始化:
mkdir server && cd server npm init -y npm install express prisma @prisma/client jsonwebtoken bcryptjs cors dotenv morgan入口文件app.js的核心中间件加载顺序,必须严格按照下面这个顺序来:
const express = require('express') const cors = require('cors') const morgan = require('morgan') const routes = require('./routes') const { errorHandler } = require('./middlewares/errorHandler') const app = express() app.use(cors()) app.use(express.json()) app.use(express.urlencoded({ extended: true })) app.use(morgan('dev')) // 健康检查 app.get('/health', (req, res) => res.json({ code: 0, data: 'ok' })) // 路由挂载 app.use('/api', routes) // 统一错误处理(必须在路由之后) app.use(errorHandler) module.exports = apperrorHandler中间件是整个后端稳定运行的关键:
const errorHandler = (err, req, res, next) => { console.error('服务器错误:', err) if (err.name === 'ValidationError') { return res.status(400).json({ code: 400, message: err.message }) } if (err.code === 'P2002') { return res.status(409).json({ code: 409, message: '数据已存在,请勿重复提交' }) } res.status(500).json({ code: 500, message: '服务器内部错误' }) }Express的错误处理中间件有固定签名,必须四个参数(err, req, res, next),少一个都不起作用。这一点新手经常踩坑,总觉得自己没写错,结果错误就是走不到这个处理逻辑。
3.5 Prisma Schema定义与数据模型设计
prisma/schema.prisma是数据模型的核心定义。以下这段是用户、角色、菜单的基础模型:
generator client { provider = "prisma-client-js" } datasource db { provider = "mysql" url = env("DATABASE_URL") } model User { id Int @id @default(autoincrement()) username String @unique @db.VarChar(50) password String @db.VarChar(100) nickname String? @db.VarChar(50) avatar String? @db.VarChar(255) status Int @default(1) // 1启用 0禁用 createdAt DateTime @default(now()) updatedAt DateTime @updatedAt roles RoleUser[] } model Role { id Int @id @default(autoincrement()) code String @unique @db.VarChar(50) name String @db.VarChar(50) users RoleUser[] menus RoleMenu[] } model Menu { id Int @id @default(autoincrement()) parentId Int @default(0) title String @db.VarChar(50) path String @db.VarChar(100) icon String? @db.VarChar(50) sort Int @default(0) roles RoleMenu[] }注意Provider = "mysql"这里,如果要切换SQLite做本地快速体验,需要改成sqlite,同时把url换成file:./dev.db。这个项目默认走MySQL,因为生产环境部署MySQL最普及、最稳定。
定义好模型后执行:
npx prisma migrate dev --name init npx prisma generate前者生成迁移SQL并同步到数据库,后者生成TypeScript类型客户端,运行时可以直接用PrismaClient查询。
3.6 后端JWT认证与权限中间件
认证中间件是所有需要登录的接口的保护层:
const jwt = require('jsonwebtoken') const authMiddleware = (req, res, next) => { const authHeader = req.headers.authorization if (!authHeader || !authHeader.startsWith('Bearer ')) { return res.status(401).json({ code: 401, message: '未登录或令牌缺失' }) } const token = authHeader.split(' ')[1] try { const payload = jwt.verify(token, process.env.JWT_SECRET) req.userId = payload.userId next() } catch (err) { return res.status(401).json({ code: 401, message: '登录状态已过期,请重新登录' }) } }权限中间件则基于RBAC思想,判断当前用户是否拥有某个角色或某段操作权限:
const requireRole = (roleCode) => { return async (req, res, next) => { const user = await prisma.user.findUnique({ where: { id: req.userId }, include: { roles: { include: { role: true } } } }) const hasRole = user?.roles.some(item => item.role.code === roleCode) if (!hasRole) { return res.status(403).json({ code: 403, message: '没有操作权限' }) } next() } }路由里这样组合使用:
router.post('/users', authMiddleware, requireRole('admin'), userController.create)从路由层就把权限挡在外面,controller内部不用再区分角色,职责边界清晰。
3.7 统一响应结构封装与业务异常处理
为了让前后端联调时不会出现“返回格式乱糟糟”的问题,我封装了一组工具函数:
const success = (data, message = 'ok') => ({ code: 0, data, message }) const failure = (code, message) => ({ code, data: null, message }) module.exports = { success, failure }Controller里使用起来很简洁:
exports.getUserList = async (req, res, next) => { try { const users = await userService.findList(req.query) res.json(success(users)) } catch (err) { next(err) } }关键点是next(err)。这里不要把错误塞进res.json({ code: 500 }),而是抛给上文的errorHandler统一处理,这样日志集中、返回格式统一,开发时定位问题也快。
3.8 文件上传功能实现
文件上传用multer,但有几个细节要处理到位:
const multer = require('multer') const path = require('path') const fs = require('fs') const uploadDir = path.join(__dirname, '../../uploads') if (!fs.existsSync(uploadDir)) { fs.mkdirSync(uploadDir, { recursive: true }) } const storage = multer.diskStorage({ destination: (req, file, cb) => cb(null, uploadDir), filename: (req, file, cb) => { const ext = path.extname(file.originalname) const basename = path.basename(file.originalname, ext) const uniqueName = `${basename}_${Date.now()}${ext}` cb(null, uniqueName) } }) const upload = multer({ storage, limits: { fileSize: 5 * 1024 * 1024 }, // 单文件限制5MB fileFilter: (req, file, cb) => { const allowedTypes = ['.jpg', '.png', '.gif', '.pdf'] const ext = path.extname(file.originalname).toLowerCase() if (allowedTypes.includes(ext)) { cb(null, true) } else { cb(new Error('不支持的文件类型')) } } })上传接口:
router.post('/upload', authMiddleware, upload.single('file'), (req, res) => { const fileUrl = `/uploads/${req.file.filename}` res.json(success({ url: fileUrl })) })这里必须强调一个隐蔽问题:multer的报错信息会被Express当成普通错误,但又在错误处理中间件之前被抛出。如果没有把upload的调用封装在自定义错误处理里,前端拿到的错误结构很可能就是你定义的code字段为空。为此我加了一段适配:
router.post('/upload', authMiddleware, (req, res, next) => { upload.single('file')(req, res, (err) => { if (err) { return res.status(400).json(failure(400, err.message)) } if (!req.file) { return res.status(400).json(failure(400, '未接收到文件')) } res.json(success({ url: `/uploads/${req.file.filename}` })) }) })3.9 前后端联调与接口文档约定
项目里内置了一份接口文档,按模块用Markdown维护,也可以切换成Apifox导出。我的习惯是把接口约定写进版本管理仓库里,这样新人入职直接看文档,不依赖任何人讲解。
结合这个项目我认为最实用的做法是,每个接口记录五个要素:
- 请求方法
- 路径
- 请求参数示例
- 成功响应示例
- 错误码约定
例如用户登录接口:
POST /api/auth/login 请求体: { "username": "admin", "password": "123456" } 成功响应: { "code": 0, "data": { "token": "eyJhbGciOi...", "userInfo": { "id": 1, "username": "admin" } }, "message": "ok" } 错误码: 401 - 用户名或密码错误 403 - 账号被禁用前后端分离之后,接口是唯一的桥梁,文档这步省不得。
4. 常见问题与排查技巧实录
这套项目我已经在不同环境部署过好几次,每次都会遇到一些大同小异的坑。这里整理几个高频问题,基本覆盖了初次跑项目的所有障碍。
4.1 npm install报错node-gyp和bcrypt编译失败
bcrypt是C++扩展包,本地安装需要编译环境。Windows上经常报错node-gyp、MSBuild相关的编译失败。遇到这种情况不用硬着头皮装Visual Studio全家桶,直接把bcryptjs换成纯JavaScript实现的bcryptjs,功能一致,不需要编译,而且是同步方法,用起来差别不大。
安装命令:
npm install bcryptjs密码比对逻辑依然是:
const bcrypt = require('bcryptjs') const hash = bcrypt.hashSync(password, 10) const isMatch = bcrypt.compareSync(password, user.password)讲白了,非加密场景真的不需要原生模块,可能慢几十毫秒而已,换来的是全平台无痛安装。
4.2 Prisma客户端与数据库连接失败
这个项目默认配置MySQL,但不少人第一次跑用的是本地开发,本地不一定装了MySQL。立即可用的方案是改SQLite。
方法如下:改schema.prisma里的数据源:
datasource db { provider = "sqlite" url = "file:./dev.db" }删掉migrations目录,重新执行:
npx prisma migrate dev --name init npx prisma generateSQLite是零配置文件、零安装的,拉下来直接跑,对演示环境很友好。如果是真实业务,再把provider切回mysql,表结构同步一下即可。
4.3 前端请求接口404,怎么都连不上
404的排查顺序我总结了一套固定流程:
第一,先看浏览器Network面板,确认请求地址是什么。如果显示http://localhost:5173/api/auth/login,说明走了Vite代理,没问题。如果显示http://localhost:5173/auth/login,那就是请求前缀或代理配置的问题。
第二,确认代理规则里的路径是否和接口路径一致。这里有个常见的错误,baseURL写成/api,但Vite代理配置的'/api'没写对,大小写、斜杠都有可能出问题。
第三,直接在后端控制台看有没有请求进来。有请求进来,说明后端逻辑问题;没有请求进来,说明代理没生效。沿着这个思路排查,五分钟内能定位问题。
4.4 生产环境部署静态资源后刷新404
前端构建后,dist目录交给Nginx托管。如果只配置了:
location / { root /var/www/html; }刷新/system/users这种带路由的页面时,Nginx会去找服务器上的这个物理路径,找不到就返回404。解决方式是加一行try_files:
location / { root /var/www/html; try_files $uri $uri/ /index.html; }所有路由请求都回退到index.html,再由前端路由接管。后端部署则用pm2守护进程:
npm run start pm2 start server.js --name server4.5 JWT密钥泄露或默认值引发安全问题
这套项目里的JWT_SECRET在.env文件里给出的是开发模式的默认值,生产环境必须换成随机字符串,否则攻击者可以伪造token实现越权登录。
生成强密钥的方法在终端一行实现:
node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"然后写入.env。
另外一个经常被忽略的点,Token过期时间配置。我的习惯是设置短期访问token加长期refresh token,但考虑到开箱即用项目的复杂度,这个样板里只做了简单的访问token,有效期设置为8小时。真实业务再扩展refresh token机制,其实也就是在登录接口里多签一个token。
4.6 前端新增页面后菜单不同步
动态路由菜单页面是根据后端的菜单表生成的,权限菜单的path字段要跟前端views目录下的文件路径对应。举个真实例子:后端菜单表里path填/system/users,前端路由注册时也必须用这个路径,动态组件加载才能找到@/views/system/users/index.vue。如果你新增了一个页面但菜单不显示,多半是数据库的菜单表没有插入对应的记录,或者前端路由path和后端菜单path不一致。
排查顺序:查菜单表数据 → 查前端views目录下是否存在对应文件路径 → 查动态添加路由时component路径的拼接逻辑 → 检查router.addRoute是否在守卫里重复调用导致路由重复注册。
4.7 大量异步请求导致页面卡顿
中后台页面经常一次性串行请求多个接口,比如进入用户列表页时,同时拉取用户数据、角色下拉数据、字典数据,如果每个都await串行,白白浪费延迟时间。
优化方式很简单,改用Promise.all并行请求:
const [userList, roleList, dictList] = await Promise.all([ fetchUserList(params), fetchRoleList(), fetchDictList() ])多个无依赖的请求并行,响应时间等于最慢的那个请求,而不是所有请求之和。页面加载速度提升非常明显,代码改动量几乎为零。
5. 项目扩展与个性化改造建议
如果只是照着这篇把项目跑起来了,那还不够。下面这几个扩展方向能帮你真正把底座打磨成适合自己的轮子。
5.1 集成Redis做会话管理和验证码
当前项目JWT是无状态认证,客户端直接携带token,后端不存状态。但很多业务需要服务端能主动让某个token失效,比如修改密码后踢掉所有旧登录设备。这时候就需要Redis配合,把token存一份服务端副本,每次校验时先检查Redis里是否还存在。
实现也不复杂,登录成功时把token和userId写入Redis,设置和JWT相同的过期时间。中间件在jwt.verify之后,再查一次Redis,查询不到就视为过期。登出接口调用del删除对应key即可。
验证码方面,后端生成随机字符串,存入Redis并设置5分钟过期,前端加载时带一个captchaId,提交登录时把用户输入和captchaId一起传给后端校验,后端从Redis里取值对比。这样防止暴力破解的效果比单纯前端校验强很多。
5.2 接口层接入Swagger文档
如果你觉得维护Markdown文档还是麻烦,可以直接在Express中集成swagger-jsdoc和swagger-ui-express,在代码里用JSDoc注释写接口文档,启动服务后访问/api-docs就能看到可调试的在线文档。
会有一层学习成本,但是一次配置长期受益。我推荐在项目交付给团队前就把Swagger集成上,因为后端接口一旦多起来,没有可视化文档,前端和后端的沟通成本会拉高。
5.3 前端页面增加水印与操作审计
后台系统有个需求经常被忽略:敏感页面需要加水印,防止信息从截图泄露。实现思路很简单,做一个全局组件,根据用户信息和当前时间生成随机字符串,用Canvas画到页面背景上,每隔几秒重新渲染一次,避免被CSS直接抹掉。
操作审计则是在前端给关键按钮事件挂载统一的全局指令,比如v-permission和v-log,用户点击时自动向后端上报操作类型、操作对象、操作结果。配合后端的操作日志表,基本可以满足等保二级的安全审计需求。
5.4 模块按需加载与打包体积优化
现在main.js里如果全量引入了Element Plus组件库,首屏包会比较大。改成按需加载后,构建体积可以缩减将近一半。
首选用官方推荐的unplugin-vue-components和unplugin-auto-import插件,自动按需导入用到哪些组件就打包哪些组件,不再需要手动注册。同时在路由配置中所有的页面组件都用动态导入() => import('@/views/xxx/index.vue'),让路由级别的代码自动分割成独立chunk,首屏只加载登录页相关代码,其余页面等进入时再加载。
6. 部署上线时最容易忽略的五个配置细节
部署阶段和开发阶段是两码事。下面这几个细节如果不处理,项目大概率上线后会出现各种诡异问题。
6.1 环境变量互相独立
开发环境的.env和生产环境的.env.production必须分开,且保证配置项不交叉。前端构建时会读取VITE_开头的环境变量,后端运行时读取自己项目里的.env。最好设置一个默认值兜底,避免某个变量缺失导致整个服务崩溃。
在Express里,我用dotenv加载环境变量,并提供了必要的运行时校验:
const requiredEnv = ['DATABASE_URL', 'JWT_SECRET', 'PORT'] requiredEnv.forEach(key => { if (!process.env[key]) { console.error(`缺少必要环境变量: ${key}`) process.exit(1) } })提前在启动阶段把关键依赖检查完,好过启动后跑着跑着才报错。
6.2 Nginx代理后端接口路径
前端构建产物放静态目录后,所有/api请求还需要跳到Express服务。Nginx配置如下:
location /api/ { proxy_pass http://127.0.0.1:3000/api/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; }有个细节:proxy_pass结尾的/api/和请求的路径拼接规则容易搞混。如果写proxy_pass http://127.0.0.1:3000;,会把用户请求的完整URI原样发给后端;如果写http://127.0.0.1:3000/api/,则Nginx会把位置匹配里的/api/部分替换掉。多试几次配熟之后,你就能一眼看出问题在哪。
6.3 上传目录的静态映射
项目把上传的文件放在server/uploads目录,生产环境要让访问/uploads/xxx.jpg能看到文件,需要额外在Express里挂载静态目录:
app.use('/uploads', express.static(path.join(__dirname, '../uploads')))这一点非常容易被遗漏。因为开发环境前端通过Vite代理转发,后端路由可以访问到;但生产环境Nginx配置如果没有把/uploads指向后端服务,或者Express里没做静态目录映射,所有图片就都会404。
6.4 PM2进程守护与日志采集
用PM2启动Express服务后,默认日志会写到~/.pm2/logs目录。生产环境建议按天拆分日志,并定时清理,避免日志文件把磁盘撑满。PM2配置:
// ecosystem.config.js module.exports = { apps: [{ name: 'server', script: 'src/server.js', instances: 1, autorestart: true, max_memory_restart: '1G', env: { NODE_ENV: 'production' } }] }启动命令:
pm2 start ecosystem.config.js6.5 前端路由懒加载与文件缓存策略
Nginx托管前端静态资源时,应把带hash的静态资源设置长期缓存,HTML文件设置不缓存。这样用户访问时,浏览器会优先走缓存加载资源,版本更新时HTML引用的新文件名会强制重新拉取。
location /assets/ { expires 30d; add_header Cache-Control "public, no-transform"; } location / { add_header Cache-Control "no-store"; try_files $uri $uri/ /index.html; }不处理缓存策略的后果是:每次发布新版本,用户浏览器还在用旧的JS文件,导致前端页面报错或行为不一致。这个问题在真实运维中经常出现,而且特别难排查,因为只有特定用户会报错。
这些坑我替你踩过了,你只管跳过去
整套项目从前端初始化到后端接口联调,再到生产部署,全流程我完整走了一遍。过程中最大的体会是,前后端分离的复杂度不在某一个技术点上,而是所有细节叠加在一起产生的摩擦。跨域、权限、动态路由、统一错误处理、文件上传、身份认证,每一个单拎出来都不难,但组合在一起就需要清晰的架构和足够的耐心。
如果你也是打算从零搭一套这种结构,我的建议是别贪多,先把认证流程跑通,再扩展业务模块。认证是前后端协作最频繁的部分,也是最容易出错的地方。这个模块一旦稳定了,后面加任何业务模块都只是在既有骨架上添砖加瓦,顺手得多。
最后说个我在实际项目中经常用的习惯:创建Git仓库时,把.gitignore配置好,尤其是.env文件和node_modules目录。很多人因为把.env提交到了仓库里,导致泄露数据库密码、JWT密钥。这种事一旦发生,基本就得全量改配置,牵扯一大批服务。谨慎一些,把它写进.gitignore,并在README里提供一个.env.example供开发者自行填充,这是最稳妥的做法。