1. 为什么还要回头写一个前后端分离的Demo
前后端分离这个词现在听起来已经不算新鲜了,但真正动手从零搭一套 Vue + Spring Boot 的完整项目,很多人还是会卡在跨域、接口联调、打包部署这几个环节上。我这次回头重写这个 Demo,不是为了追什么新技术,而是想把这套最经典的组合彻底捋一遍,把当年踩过的坑、绕过的弯路都整理出来,给刚接触前后端分离的朋友一个能直接抄的参考。
这个 Demo 的核心目标很明确:前端用 Vue 负责页面渲染和交互,后端用 Spring Boot 提供 RESTful 接口,两边通过 HTTP 请求通信,各自独立开发、独立部署。它解决的是传统 JSP 或 Thymeleaf 那种前后端代码混在一起、改一个按钮都要重新编译整个项目的问题。适合谁看?如果你已经会一点 Java 和 JavaScript,但还没完整搭过一个前后端分离项目,或者你搭过但总是被跨域和接口格式搞得头大,那这篇内容应该能帮到你。
我这次用的技术栈是 Vue 3 + Vite + Axios 做前端,Spring Boot 3 + MyBatis-Plus + MySQL 做后端,整体结构不复杂,但每个环节我都会把“为什么这么选”讲清楚。比如为什么用 Vite 不用 Webpack,为什么接口返回要统一封装,为什么跨域不能随便用@CrossOrigin糊弄过去。这些细节才是真正决定项目能不能顺利跑起来的关键。
2. 整体架构设计与技术选型思路
2.1 前后端分离到底分的是什么
很多人以为前后端分离就是前端写页面、后端写接口,其实这只是表面。真正的分离体现在三个层面:代码分离、开发分离、部署分离。代码分离意味着前端项目和后端项目是两个独立的代码仓库,前端不依赖后端模板引擎,后端也不关心页面长什么样。开发分离是指前端工程师和后端工程师可以并行开发,只要提前约定好接口文档,前端可以用 Mock 数据先跑起来。部署分离则是前端打包成静态文件放到 Nginx 或 CDN,后端打成 Jar 包跑在服务器上,两边互不干扰。
我见过不少项目号称前后端分离,结果前端代码里还嵌着后端返回的 HTML 片段,或者后端接口直接返回页面路径,这种就是典型的“假分离”。真正的分离要求后端只返回数据,通常是 JSON 格式,前端拿到数据后自己决定怎么渲染。这样做的好处是前端可以灵活替换技术栈,今天用 Vue,明天换 React,后端完全不用动。后端也可以同时服务 Web 端、App 端、小程序端,一套接口多处复用。
2.2 为什么选 Vue 3 + Vite 而不是其他组合
Vue 3 的 Composition API 是我这次选它的主要原因。Options API 写多了之后,逻辑分散在 data、methods、computed 里,一个功能要改好几个地方。Composition API 可以把相关逻辑写在一起,复用起来也方便。Vite 的冷启动速度比 Webpack 快很多,改代码后热更新几乎是秒级,开发体验提升明显。Axios 就不用说了,拦截器机制用来统一处理 token 和错误提示非常顺手。
后端选 Spring Boot 3 是因为它对 Java 17 支持更好,而且 Spring Boot 的自动配置让整合 MyBatis-Plus、MySQL 驱动这些变得很简单。MyBatis-Plus 相比原生 MyBatis 省去了大量 XML 配置,简单的增删改查直接用BaseMapper就能搞定,复杂查询再写自定义 SQL。MySQL 选 8.0 版本,主要是为了支持 JSON 字段类型和更好的性能。
2.3 项目目录结构怎么划分才合理
前端项目我习惯按功能模块划分目录,而不是按文件类型。比如src/views下面放页面组件,src/components放公共组件,src/api放接口请求,src/utils放工具函数。这样找文件的时候直接按业务模块找,不用在components里翻半天。后端项目按经典的三层架构分:controller层负责接收请求和返回响应,service层处理业务逻辑,mapper层操作数据库。实体类放在entity包,数据传输对象放在dto包,视图对象放在vo包。
这里有个经验:不要把entity直接返回给前端。数据库实体可能包含密码、删除标记这些敏感字段,直接返回会有安全隐患。我一般用vo类专门做返回数据的封装,只暴露前端需要的字段。比如用户表里有password字段,但返回给前端的UserVO里只有id、username、nickname,这样既安全又清晰。
3. 后端核心细节与实操要点
3.1 Spring Boot 项目初始化与依赖配置
用 Spring Initializr 生成项目骨架的时候,我勾选了 Spring Web、MyBatis Framework、MySQL Driver、Lombok 这几个依赖。Lombok 一定要加,不然实体类的 getter、setter 写起来太啰嗦。pom.xml里我额外加了 MyBatis-Plus 的 starter,版本选 3.5.5 以上,对 Spring Boot 3 的支持更稳定。
<dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.5</version> </dependency>配置文件application.yml里主要配三块:数据源、MyBatis-Plus、服务器端口。数据源用 HikariCP,Spring Boot 默认就是它,性能比 Druid 好一些。MyBatis-Plus 要配mapper-locations指向 XML 文件位置,还要开map-underscore-to-camel-case,这样数据库的user_name字段能自动映射到 Java 的userName属性。
spring: datasource: url: jdbc:mysql://localhost:3306/demo_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver mybatis-plus: mapper-locations: classpath:mapper/*.xml configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl server: port: 8080注意:
serverTimezone一定要配,不然 MySQL 8.0 连接时会报时区错误。log-impl配成 StdOutImpl 可以在控制台看到执行的 SQL,调试的时候非常有用,但上线前记得关掉,不然日志会刷屏。
3.2 统一接口返回格式的设计
后端接口返回的数据格式必须统一,不然前端处理起来会很乱。我定义了一个Result类,包含code、message、data三个字段。code用 200 表示成功,其他值表示各种错误。message放提示信息,data放实际数据。这样前端只需要判断code是不是 200,就能知道请求成功还是失败。
@Data public class Result<T> { private Integer code; private String message; private T data; public static <T> Result<T> success(T data) { Result<T> result = new Result<>(); result.setCode(200); result.setMessage("操作成功"); result.setData(data); return result; } public static <T> Result<T> error(String message) { Result<T> result = new Result<>(); result.setCode(500); result.setMessage(message); return result; } }这里有个细节:data字段用了泛型,这样返回不同类型的数据时不用强转。比如Result<UserVO>和Result<List<UserVO>>都能用同一个类。另外我建议加一个全局异常处理器,用@RestControllerAdvice捕获所有异常,统一返回Result.error(),这样前端永远不用处理 500 页面,所有错误都是 JSON 格式。
3.3 跨域问题的正确处理方式
跨域是前后端分离绕不开的坎。浏览器同源策略规定,协议、域名、端口三者只要有一个不同,就算跨域。前端跑在localhost:5173,后端跑在localhost:8080,端口不同,所以跨域。很多人图省事直接在 Controller 上加@CrossOrigin,但这样每个接口都要加,而且生产环境可能还有 Nginx 代理,配置会乱。
我推荐用全局配置类来处理跨域,在config包下建一个CorsConfig类,实现WebMvcConfigurer接口,重写addCorsMappings方法。这样所有接口自动支持跨域,不用一个个加注解。
@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOriginPatterns("*") .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowedHeaders("*") .allowCredentials(true) .maxAge(3600); } }注意:
allowedOriginPatterns和allowedOrigins的区别在于,前者支持通配符,后者在allowCredentials为 true 时不能用*。所以如果你要传 Cookie 或 Token,必须用allowedOriginPatterns。maxAge设置预检请求的缓存时间,3600 秒意味着一天内同一个跨域请求不用重复发 OPTIONS 预检,能减少请求次数。
4. 前端核心细节与实操要点
4.1 Vue 3 项目搭建与 Axios 封装
用npm create vite@latest创建项目,选 Vue 模板,然后装 Axios 和 Vue Router。Axios 我习惯封装一个request.js,在里面创建实例、配置基础路径、设置请求拦截器和响应拦截器。请求拦截器用来统一加 Token,响应拦截器用来统一处理错误码。
import axios from 'axios' const request = axios.create({ baseURL: 'http://localhost:8080', timeout: 10000 }) request.interceptors.request.use( config => { const token = localStorage.getItem('token') if (token) { config.headers.Authorization = `Bearer ${token}` } return config }, error => Promise.reject(error) ) request.interceptors.response.use( response => { const res = response.data if (res.code !== 200) { alert(res.message || '请求失败') return Promise.reject(new Error(res.message)) } return res.data }, error => { alert('网络异常,请稍后重试') return Promise.reject(error) } ) export default request这样封装之后,页面里调用接口只需要request.get('/user/list'),返回的已经是data字段的内容,不用每次写res.data.data。错误提示也统一在拦截器里处理了,页面代码干净很多。
4.2 接口请求的模块化管理
不要把接口请求散落在各个组件里,我习惯在src/api目录下按模块建文件。比如user.js放用户相关的接口,article.js放文章相关的接口。每个文件里导出具体的请求函数,组件里直接 import 调用。
// src/api/user.js import request from '@/utils/request' export function getUserList(params) { return request.get('/user/list', { params }) } export function addUser(data) { return request.post('/user/add', data) } export function updateUser(data) { return request.put('/user/update', data) } export function deleteUser(id) { return request.delete(`/user/delete/${id}`) }这样做的好处是接口路径集中管理,改路径的时候只改一个地方。而且函数名有语义,getUserList比直接写request.get('/user/list')更容易理解。如果后端接口有变动,前端只需要改api目录下的文件,组件代码不用动。
4.3 页面路由与权限控制
Vue Router 用createRouter创建实例,路由模式选createWebHistory,这样 URL 里没有#号,看起来更干净。路由配置里可以加meta字段标记哪些页面需要登录,然后在全局前置守卫里判断。
router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') if (to.meta.requiresAuth && !token) { next('/login') } else { next() } })注意:前端路由守卫只是提升用户体验,不能作为安全屏障。真正的权限校验必须放在后端,每个接口都要验证 Token 是否有效、用户是否有权限访问该资源。前端守卫可以被绕过,后端校验才是最后一道防线。
5. 前后端联调与常见问题排查
5.1 接口联调的标准流程
联调的时候我习惯先用 Postman 或 Apifox 测后端接口,确保单独跑没问题。然后再到前端页面里调,如果前端报错,先看浏览器控制台的 Network 面板,确认请求 URL、请求方法、请求头、请求体是否正确。常见的问题有:请求方法写错了(比如后端要 POST,前端发了 GET)、参数格式不对(后端要 JSON,前端发了 form-data)、请求头缺少 Content-Type。
我一般会在后端 Controller 里加日志,打印接收到的参数,这样能快速判断是前端没传对还是后端没接住。前端也可以在 Axios 拦截器里打印请求配置,两边对照着看,问题很快就能定位。
5.2 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 浏览器报 CORS 错误 | 后端未配置跨域 | 看控制台是否有Access-Control-Allow-Origin相关提示 | 添加全局跨域配置类 |
| 请求返回 404 | 接口路径不对 | Network 面板看请求 URL | 检查baseURL和接口路径拼接 |
| 请求返回 401 | Token 无效或未传 | 看请求头是否有 Authorization | 检查登录逻辑和拦截器 |
| 请求返回 500 | 后端代码异常 | 看后端控制台堆栈信息 | 根据异常信息修复代码 |
| 数据返回但页面不渲染 | 字段名不匹配 | 对比接口返回字段和页面绑定字段 | 统一字段命名或做映射 |
| 跨域预检失败 | OPTIONS 请求被拦截 | Network 面板看是否有 OPTIONS 请求 | 后端放行 OPTIONS 方法 |
5.3 我踩过的几个坑
第一个坑是@RequestBody和@RequestParam混用。前端发 JSON 数据时,后端必须用@RequestBody接收,而且只能有一个。如果同时想接收路径参数和 JSON 体,路径参数用@PathVariable,JSON 体用@RequestBody,两者不冲突。我当初把@RequestBody和@RequestParam写在一个方法里,结果一直报 400 错误,排查了半天才发现是注解用错了。
第二个坑是日期格式。Java 的LocalDateTime默认序列化成数组或时间戳,前端拿到后没法直接显示。解决办法是在字段上加@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss"),或者在配置文件里全局配置日期格式。我建议用全局配置,省得每个字段都加注解。
spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8第三个坑是打包部署后接口 404。开发环境前端跑在 5173 端口,后端跑在 8080 端口,跨域配置生效。但打包后前端静态文件放到 Nginx,后端还是 8080,这时候如果前端请求的baseURL还是localhost:8080,用户浏览器访问的是 Nginx 的地址,请求就会发到用户自己的电脑上,自然 404。解决办法是在 Nginx 里配置反向代理,把/api开头的请求转发到后端服务。
location /api/ { proxy_pass http://localhost:8080/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }这样前端baseURL改成/api,请求先到 Nginx,再由 Nginx 转发给后端,就不存在跨域问题了。这也是生产环境推荐的做法,比后端配跨域更安全、更灵活。
6. 项目打包与部署的实操记录
6.1 前端打包与静态资源优化
前端打包用npm run build,Vite 会把代码压缩、分割、生成带哈希的文件名。打包产物在dist目录,里面是index.html和assets文件夹。我一般会把dist里的文件传到 Nginx 的html目录,然后配置 Nginx 的root指向这个目录。
Vite 默认的打包配置已经够用,但有几个地方可以优化。比如build.rollupOptions.output.manualChunks可以把第三方库单独打包,避免业务代码更新后用户还要重新下载 Vue、Axios 这些不变的库。还有build.chunkSizeWarningLimit可以调大,避免打包时提示文件过大。
// vite.config.js export default defineConfig({ build: { chunkSizeWarningLimit: 1500, rollupOptions: { output: { manualChunks: { vendor: ['vue', 'vue-router', 'axios'] } } } } })6.2 后端打包与运行
后端用 Maven 打包,mvn clean package生成可执行的 Jar 文件。运行的时候用java -jar xxx.jar,可以加--spring.profiles.active=prod指定生产环境配置。生产环境的配置文件里,数据库密码、日志级别这些要和开发环境区分开。
我习惯把配置文件分成application-dev.yml和application-prod.yml,主配置文件里用spring.profiles.active指定当前环境。这样本地开发用 dev,服务器部署用 prod,互不干扰。生产环境的日志级别调成 WARN,避免 INFO 日志太多占满磁盘。
6.3 部署后的验证清单
部署完成后,我一般按这个清单逐项验证:前端页面能否正常打开、静态资源是否加载成功(看 Network 面板有没有 404)、登录接口是否正常返回 Token、带 Token 的请求是否通过鉴权、数据库增删改查是否生效、Nginx 反向代理是否配置正确。每一项都确认没问题,才算部署完成。
提示:部署后如果页面白屏,先看浏览器控制台有没有报错。常见原因是静态资源路径不对,比如 Vite 默认的
base是/,如果部署在子目录下需要改成./或具体子路径。还有可能是 Nginx 的try_files没配,刷新页面后 404,需要加try_files $uri $uri/ /index.html。
7. 这个 Demo 还能怎么扩展
这套基础框架跑通之后,可以往上加的东西很多。比如加 JWT 做无状态鉴权,把 Token 存到 Redis 里做黑名单管理;加 Spring Security 做细粒度权限控制,区分管理员和普通用户;加 Swagger 或 Knife4j 自动生成接口文档,前后端对接更高效;加 Redis 缓存热点数据,减轻数据库压力;加消息队列处理异步任务,比如发送邮件、生成报表。
前端方面可以加 Pinia 做状态管理,把用户信息、权限列表这些全局状态统一管理;加 Element Plus 或 Ant Design Vue 做 UI 组件库,页面开发速度能快不少;加 ESLint 和 Prettier 做代码规范,团队协作时风格统一。
我个人在实际操作中的体会是,不要一上来就把所有东西都堆进去。先把最基础的增删改查跑通,把跨域、接口封装、异常处理这些基础设施搭好,后面加功能就是水到渠成的事。我见过太多项目一开始就上微服务、上消息队列,结果基础功能还没跑通,光环境问题就耗了一两周。先把单体能跑通,再考虑扩展,这个顺序不能乱。
最后再分享一个小技巧:接口文档一定要在开发前就定好,用 Apifox 或 Swagger 写清楚每个接口的路径、方法、参数、返回格式。前后端并行开发的时候,前端用 Mock 数据先跑页面,后端按文档实现接口,联调的时候基本一次过。我试过几次没写文档直接口头沟通,结果字段名对不上、参数类型不对,改来改去浪费的时间比写文档多得多。