前后端分离这个词,这几年几乎成了Web开发的默认姿势。打开招聘网站,十个后端岗位有八个写着“熟悉Spring Boot + Vue前后端分离开发”;GitHub上热门的前端项目也几乎都长一个样,dist目录打包静态资源,后端只负责出接口。但“分离”到底分离了什么?很多人其实没完全想清楚。有人觉得分离就是把HTML从后端模板里挪到前端工程里,有人觉得分离就是前端用Vue、后端用Spring Boot,还有人把前后端分离和跨域问题绑定在一起,一提分离就想到CORS。
我早年做项目是从JSP时代过来的,那时候一个页面里HTML、Java代码、SQL语句混在一起,改一个按钮颜色要重启整个服务,数据库加一列字段前后端都要跟着动,线上出问题还得把前后端代码一起回滚。后来切到前后端分离架构后,整个节奏完全变了——前端调接口、后端改数据,两条线并行推进,互不扯皮。但这套模式真落地的时候,又会踩到接口设计、鉴权、跨域、部署、联调协作一大堆坑。
这篇文章我不打算只讲概念,而是把这套前后端分离模式从设计思路、核心细节、项目实操到常见问题排查完整拆一遍。不管你是刚转行的新人,还是想把现有项目拆成前后端分离的老手,都能在这里找到一个可以照着做的完整方案。
1. 前后端分离拆开的不只是代码,更是协作方式
1.1 从模板渲染到前后端分离:为什么要拆
先聊聊这套模式为什么会出现。在早期的Java Web开发里,JSP是绝对的主流。一个.jsp文件里既能写HTML标签,又能嵌入<% %>脚本块,还能直接引用后端的JavaBean。这种写法的好处是“方便”,前端页面想要数据,直接在服务端取出来塞进页面就行。但坏处随着项目变大越来越明显:
第一,耦合严重。一个页面的展示逻辑和业务逻辑搅在一起,前端工程师要懂Java才能改页面,后端工程师改个字段还要担心页面渲染崩掉。
第二,联调效率低。前端页面和后端代码打包在同一个Web应用里,每次改动都要整个应用重新编译、重启。哪怕只是改个按钮颜色,也得走一遍完整的构建部署流程。
第三,分工困难。前端和后端的技能栈完全不同,硬塞在同一个工程里,两边都没法独立开发、独立测试。前端想用一个更好的构建工具,后端说不行,会影响我的工程结构。
前后端分离的本质,就是把“数据”和“展示”这两件事彻底拆开。后端专注提供数据接口,前端专注消费数据并渲染页面。双方通过一份约定好的接口契约交互,谁都不需要关心对方内部是怎么实现的。这其实和“设计模式”里的“开闭原则”是一个思路——对扩展开放,对修改关闭。接口稳定了,前后端各自内部怎么改都不会影响对方。
1.2 分离的真正边界:工程、接口、部署
很多人以为分离就是把代码文件分两个仓库,前端一个、后端一个。这只是最表面的工程边界。真正的前后端分离要拆三层:
第一层是工程边界。前端工程用Vite、Webpack这类构建工具管理,产出纯静态资源;后端工程是独立的API服务,不渲染任何页面。前后端各自有独立的代码仓库、独立的依赖管理、独立的测试流程。
第二层是接口边界。这是前后端分离里最重要也最容易忽略的一层。后端暴露的每个API,必须有明确的路径、方法、入参、出参定义。前端只依赖这两份东西,不依赖后端的具体实现语言、框架、部署地址。接口就是双方签的合同,合同摆好了,各自开发才不互相等。
第三层是部署边界。前端构建出的静态文件部署到Nginx这类Web服务器上,后端服务独立部署在应用服务器或容器里。两者可以分别水平扩展——前端静态资源流量大了,就多配几台Nginx;后端接口压力大了,就多扩几个实例。甚至可以把前端静态资源放到CDN上,这是传统模板渲染模式根本做不到的。
1.3 什么样的项目不适合做前后端分离
写到这里我得泼一盆冷水。前后端分离不是银弹,不是所有项目都适合无脑上。用这套模式是有成本的——接口设计要花时间、联调要花时间、跨域要处理、权限要重新梳理。如果项目很小,比如一个公司内部不到十个页面、没有独立前端团队的报表系统,用传统的服务端模板渲染反而更快,一个人就能搞定。
另外,对SEO要求极高的内容型网站,如果不上服务端渲染(SSR)或静态站点生成(SSG),纯前端渲染会带来很大劣势。搜索引擎爬虫抓到的可能是一个空的HTML骨架,或者执行完JS跑出来的结果,既要额外做预渲染又要解决直出问题,成本很高。
所以判断一个项目适不适合前后端分离,核心看三个条件:团队有没有明确的前端后端分工、项目规模大到值得维护一套接口契约、页面渲染和数据获取是否真的需要解耦。这三个条件都满足,再谈分离才有意义。
2. 分而不散:接口、鉴权与跨域是三大核心细节
2.1 接口契约先定好:统一返回体与全局异常
前后端分离项目最先要定,也最容易被忽略的,就是接口返回格式。很多项目前期没人管这件事,张三写的接口返回一个JSON对象,李四写的接口返回一个字符串,王五心情好又套了一层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("success"); result.setData(data); return result; } public static <T> Result<T> error(Integer code, String message) { Result<T> result = new Result<>(); result.setCode(code); result.setMessage(message); return result; } }同时配合全局异常处理器,让后端代码里不用到处写try-catch。业务异常统一抛出,最终都会转成上面这个结构返回给前端。这样一来,前端在axios拦截器里只要判断一次code,等于200就取data,不等于200就提示message,所有接口的交互逻辑完全一致。
分页接口也要单独定一个通用结构。我常用的分页返回体包含total、list、pageNum、pageSize四个字段,前端拿到这个结构就能直接渲染所有带分页的表格,不用每个接口单独适配。
2.2 JWT鉴权:登录态如何跨端保持
HttpSession那套方案,依赖服务端保存会话状态,天生就不适合前后端分离。因为前端可能是Web、可能是App、可能是小程序,后端可能是单体、可能是微服务集群。保持登录态最主流的方案是JWT(JSON Web Token)。
JWT的核心思路是:用户登录成功后,后端生成一个包含用户信息的签名Token返回给前端,前端每次请求都在Header里带上这个Token,后端验证签名即可。这个方案的好处是天然无状态,后端不需要存Session,集群环境下不需要做会话同步,验收也方便。
完整落地过程我一般分四步:
第一步,登录接口发放Token。用户提交用户名密码,校验通过后用HMAC或RSA算法签发Token,有效时长一般设两小时到一天。
第二步,前端存储Token并在请求头携带。Token存localStorage还是cookie是有讲究的。存localStorage更简单但容易被XSS攻击读取,存cookie要设置HttpOnly但会面临CSRF风险。项目里我常规做法是存localStorage,然后在axios请求拦截器里给每个请求加Authorization头。这样后端能直接读Header,不依赖cookie机制。
// axios 请求拦截器 service.interceptors.request.use(config => { const token = localStorage.getItem('token'); if (token) { config.headers['Authorization'] = `Bearer ${token}`; } return config; }, error => Promise.reject(error));第三步,后端配置拦截器统一校验。写一个JwtInterceptor,排除登录、注册等白名单接口,其余所有请求都先校验Token。校验失败直接返回401,前端响应拦截器收到401就跳转登录页。
第四步,登录过期处理。Token是有时效的,过期后前端所有请求都会401。我的做法是在响应拦截器里判断401后,清除本地Token,跳转到登录页并提示“登录已过期,请重新登录”。更流畅的方案是搞一个刷新Token的机制——一个短期Access Token配一个长期Refresh Token,Access Token失效时用Refresh Token重新换一个。这个机制在移动端很有用,Web端如果嫌麻烦,直接过期重新登录也完全能接受。
2.3 跨域问题:开发环境与生产环境要分开处理
前后端分离项目必然面临跨域。前端页面跑在localhost:5173,后端接口跑在localhost:8080,端口不同,浏览器的同源策略就会拦截请求。很多新人在这里被折磨到怀疑人生,配置半天CORS还不生效。其实跨域这个问题的解法在开发和生产环境是完全不同的两套思路。
开发环境的正解是前端代理。以Vite为例,配置server.proxy把/api开头的请求转发到后端地址:
// vite.config.js export default defineConfig({ server: { port: 5173, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true, rewrite: path => path.replace(/^\/api/, '') } } } });这样前端请求/api/user/list,Vite开发服务器会转发到http://localhost:8080/user/list,浏览器里看到的请求始终是同源的,完全不会触发跨域。这套机制的原理类似反向代理,Vite开发服务器充当了中转站。
生产环境的正解是Nginx统一入口。前端静态资源和后端接口都挂在同一个域名下,通过路径前缀区分。/api开头的请求走反向代理到后端服务,其余请求直接命中前端静态文件:
server { listen 80; server_name yourdomain.com; root /usr/share/nginx/html; index index.html; location /api/ { proxy_pass http://backend-server:8080/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } location / { try_files $uri $uri/ /index.html; } }这套配置下来,前后端天然同源,不需要后端额外开CORS。只有一种情况必须处理后端CORS——前后端域名分离部署,比如前端挂在CDN、后端是独立API服务。这时候才用@CrossOrigin注解或者CORS配置类,而且要注意配置allowedOrigins时必须写完整的前端域名,不能写*否则配了withCredentials也没用。
3. 从零到一落地:Spring Boot 3 + Vue 3 前后端分离实操
3.1 工程初始化与目录结构设计
拿一个典型的Spring Boot 3 + Vue 3项目举例,我会把工程分成两个独立仓库,前端用Vite构建,后端用Maven管理。
前端目录结构长这样:
frontend/ ├── public/ ├── src/ │ ├── api/ # 接口请求定义,按模块拆分 │ ├── assets/ # 静态资源 │ ├── components/ # 公共组件 │ ├── router/ # 路由配置 │ ├── store/ # Pinia 状态管理 │ ├── views/ # 页面组件 │ ├── utils/ # 工具函数,axios实例一般放这里 │ ├── App.vue │ └── main.ts ├── package.json └── vite.config.ts后端目录结构长这样:
backend/ ├── src/main/java/com/example/ │ ├── controller/ # 接口层 │ ├── service/ # 业务逻辑层 │ ├── mapper/ # 数据访问层 │ ├── entity/ # 实体类 │ ├── dto/ # 数据传输对象 │ ├── config/ # 配置类,包含CORS、拦截器注册 │ ├── common/ # 统一返回体、异常处理、工具类 │ └── security/ # 认证授权相关 ├── src/main/resources/ │ ├── application.yml │ └── mapper/ # MyBatis XML文件 └── pom.xml这个结构不复杂,但有几个关键的模块必须从第一天就放好:common里的统一返回体、config里的拦截器注册、api目录下的接口请求封装。这几个模块是前后端协作的地基,后面所有业务代码都建立在它们之上。
3.2 开发联调:后端接口文档与前端Mock双管齐下
前后端分离之后,最怕的是“后端还没写完,前端没法开工”。解决这个问题的标准做法是:后端用Swagger或Knife4j生成接口文档,前端基于文档先做Mock数据。
Spring Boot 3集成Knife4j的配置很简单,引入依赖后在配置类里加上文档扫描注解,启动项目后访问/doc.html就能看到接口文档页面。每个接口的路径、入参、出参模板一目了然,前端照着文档就能写接口调用代码。
前端自己再准备一套Mock也不是不行,但既然后端文档已经定义了接口结构,更高效的做法是前端在api目录下先把所有请求函数写好返回Promise,在Mock阶段直接返回假数据。等后端接口跑通了,只需要改一个baseURL切换开关。这里最忌讳的是前端自己造一套和后端结构不相符的假数据,等联调时全都要返工。
接口联调阶段我想强调一个工具,不是聊技术,是聊协作方式。前后端是同一团队的,建议直接拉一个接口联调群或者用一个在线协作表格,专门记录接口状态:是“文档已出”“后端已实现”“前端已联调通过”,还是“接口有变更”。很多项目的坑不是技术问题,是“接口改了但前端不知道”、“前端改了但后端还在按旧参数排查”。一份实时更新的接口清单比什么工具都好用。
3.3 后端接口数据交互的几个高频细节处理
联调阶段有几个细节是新手必踩的,我单独拎出来讲。
第一个是Long类型精度丢失。数据库自增主键和雪花ID都是Long类型,后端直接往前端返回JSON时,超过JavaScript安全整数的数值会被截断。比如主键ID是1853413796856741888,前端拿到手可能变成1853413796856741900,再把这个ID传回后端查询,直接查不到数据。解决办法有两种,一是在实体类的ID字段上加@JsonSerialize(using = ToStringSerializer.class)直接序列化成字符串,二是用Jackson Config全局处理。强烈建议全项目统一用第一种或者全局配置,不要一个接口一个接口地处理,容易漏。
第二个是日期格式统一。后端返回日期默认是2025-06-11T10:30:00.000+08:00这种格式,前端展示起来很痛苦。我通常在application.yml里配置全局日期格式化:
spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8这样后端所有日期接口都返回2025-06-11 10:30:00这种格式,前端不用重复做格式化处理。
第三个是下划线与驼峰映射。数据库字段习惯用user_name,Java实体类习惯用userName,前端拿到JSON希望是userName。MyBatis-Plus默认开启驼峰映射,如果用的原生MyBatis,记得在配置里加上mapUnderscoreToCamelCase=true,否则字段映射不上,查出来全是null,前端就会一脸懵。
3.4 遇到成熟脚手架项目时,怎么合理地“抄作业”
聊到实操,不能不提那些前后端分离的开源脚手架。比如你可能会在网上看到大量基于Spring Boot + Vue的快速开发平台,像若依框架这类项目就是典型代表。它们已经把用户管理、权限控制、代码生成、系统监控这些通用功能都实现好了,开发一个企业后台管理系统,基于它二次开发效率极高。
遇到这类脚手架我的建议是:一定要借鉴,但别无脑照搬。借鉴的是它的工程结构和通用模块设计——统一返回体怎么写的、权限拦截器怎么配的、菜单路由怎么动态生成的、代码生成器怎么用的。但要注意,开源框架为了覆盖大量场景,往往带了很多你用不到的功能模块,如果直接拿来做生产项目,一定要把用不到的功能删掉,否则项目体积臃肿不说,还要花额外精力维护。另外,这类框架用的依赖版本一般比较保守,整合进新项目前要先确认包版本有没有兼容性问题。
4. 不止于编码:部署、自动化与团队协作实践
4.1 用Jenkins搭建前后端分离项目的自动化部署流水线
项目开发完总要部署上线。前后端分离项目最大的优势就是在部署这一步体现得淋漓尽致——前后端可以独立构建、独立发布、独立回滚。
我在生产环境最常用的部署方式是:Jenkins从Git拉取代码并完成前端构建,再将dist目录同步到Nginx服务器,同时也拉取后端代码用Maven打包并重启后端服务。整套自动化流程我写成Jenkins Pipeline文件,长这样:
pipeline { agent any stages { stage('拉取前端代码') { steps { git url: 'git@github.com:yourname/frontend.git', branch: 'main' } } stage('构建前端') { steps { sh 'npm install --registry=https://registry.npmjs.org' sh 'npm run build' } } stage('部署前端') { steps { sh 'scp -r dist/* root@your-server:/usr/share/nginx/html/' } } stage('拉取后端代码') { steps { git url: 'git@github.com:yourname/backend.git', branch: 'main' } } stage('打包后端') { steps { sh 'mvn clean package -DskipTests' } } stage('重启后端服务') { steps { sh 'ssh root@your-server "systemctl restart backend.service"' } } } }这套流水线跑起来后,开发只需要把代码推到主干分支,剩下的构建、部署、重启全部自动完成。要注意前端的scp同步最好配合rsync做增量同步,首次全量没问题,后面每次构建只传变更文件,部署速度会快很多。
4.2 多人协作中最容易被忽视的接口变更管理
前后端分离后,团队内部的技术依赖变成了“接口依赖”。我最想给团队强调的一句话是:接口不是写出来的,是讨论出来的。后端的接口设计要秉承“面向前端需求”的原则,不能我数据库有什么字段就返回什么字段,而是前端页面要什么数据就返回什么数据,减少一次请求携带无用字段。
多人协作时接口变更管理是个大学问。代码层面要解决的是:后端接口签名改了,前端怎么及时感知;前端换了接口,后端怎么知道。解决思路是从流程上约束:
第一,接口变更必须走文档更新流程。先改接口文档,再改代码,最后通知前端同事。顺序反了,文档和代码就会出现不一致。
第二,接口版本管理要明确。如果是一次破坏性的重构——路径改了、参数改了、返回结构改了,建议不要直接覆盖原接口,而是新增一个V2版本,让前端有时间平滑迁移。
第三,定期做接口联调评审。我一般建议在每次迭代结束前,前后端坐在一起过一遍改动过的接口,用真实数据跑一遍流程,能提前发现一大批字段缺失、类型不匹配、状态码语义不统一的问题。
5. 常见问题排查与避坑实录
5.1 前后端分离项目高频问题速查表
我把这几年实操中遇到的高频问题,按“现象 — 原因 — 解决方案”整理成一个速查表,方便你遇到问题直接对照:
| 现象 | 原因 | 解决方案 |
|---|---|---|
| 前端接口请求404 | 代理没配全,或者Nginx的location匹配没生效 | 检查Vite proxy配置;Nginx配置里location顺序是否合理 |
| 前端能登录,但登录后接口401 | Token没带,或者拦截器没放行该路径 | 检查axios拦截器加Token的逻辑;后端JwtInterceptor白名单是否遗漏 |
| 刷新页面后404,白屏 | 前端用的是history路由,Nginx没配try_files | 在Nginx静态资源location里加try_files $uri $uri/ /index.html |
| 接口返回了数据,但页面显示不出来 | 字段名对不上,或者Long类型精度丢失 | 检查后端是否启用驼峰映射;Long类型ID按字符串序列化 |
| 开发环境接口能通,部署后跨域报错 | 生产环境没走Nginx代理,直接直连后端接口 | 用Nginx统一反向代理,保持同源 |
| 页面中文乱码 | 后端返回头没指定UTF-8,或者前端页面编码不对 | 检查后端server.servlet.encoding配置;确保HTML文件是UTF-8保存 |
| 上传大文件一直失败 | Nginx默认client_max_body_size太小 | 在Nginx增加client_max_body_size 50m |
5.2 跨域配置不生效的排查思路
跨域是最容易让人头大的问题。你配置了@CrossOrigin,但还是报CORS错误,我每次排查都会按下面这个顺序走一遍:
第一,确认请求是“简单请求”还是“预检请求”。如果前端请求带着自定义Header(比如Authorization),浏览器会先发一个OPTIONS请求探路。很多后端框架的拦截器直接把OPTIONS请求拦截掉了,导致预检请求都到不了Controller层,更谈不上返回CORS头。解决办法是在WebMvc配置类里,让CORS配置和拦截器都放行OPTIONS请求。
第二,确认CORS配置没有和Spring Security冲突。如果你用了Spring Security,CORS过滤器一定要配置在Security过滤器链之前,否则Security先返回了403,CORS头根本没机会写入响应。
第三,确认allowedOrigins没有写*。当你需要携带Cookie时,Access-Control-Allow-Origin不能是星号,必须是指定的完全匹配的域名,而且要配合allowCredentials(true)使用。
5.3 前端history路由刷新404的经典处理
这个问题我见过不下十次。前端用Vue Router的history模式,本地开发一切正常,打包部署到Nginx后,点击页面里跳转没问题,但一旦按F5刷新,直接就404。
原因是history模式的路由路径是浏览器地址栏的真实路径,比如/user/list。刷新时浏览器向服务器请求这个路径,但服务器上根本没有这个路径对应的静态文件,于是返回404。
解决办法就是Nginx的try_files配置。它会在静态文件确实不存在时,把请求重写到/index.html,让前端路由自己去匹配。注意上面那个配置里,location /块里必须写上try_files $uri $uri/ /index.html;,缺一不可。
还有一种变体,如果前端部署在子路径下,比如/admin,那么Nginx配置和前端路由的base参数都要同步设置,否则资源路径全乱。
5.4 联调阶段最大的隐藏坑:环境差异
最后一个坑我必须强调。有时候开发和测试环境都好好的,一上预发布环境就出一堆问题。多数情况不是代码问题,是环境差异:
后端接口的IP或域名配置,在前端是写死的还是走环境变量?很多前端工程里把baseURL写死在src/config.js里,换环境就要改代码重新构建。正确做法是区分构建环境,用Vite的import.meta.env按环境加载配置,或者更简单地,把API地址放在Nginx的代理配置里,前端始终请求相对路径/api,这样换环境完全不用重发前端。
数据库数据和开发环境不一致,比如开发环境有100条数据,测试环境只有3条,就会出现分页组件的边界问题。这个要靠测试环境的数据尽量仿真解决。
还有时间同步。后端服务器和用户本地时区差异会导致接口返回的时间相差8小时,记住后端统一用GMT+8,前端展示时也要关注本地时区。
前后端分离这套模式,说到底不是技术有多高深,而是它逼着你把“数据”和“展示”的边界想清楚。我见过太多项目,名义上说了前后端分离,实际上前端页面里到处硬编码接口地址、后端Controller里返回乱七八糟的结构,最后联调一个月才上得了线。这些坑,基本都在上面这些细节里。
如果你正准备把一个老项目改造成前后端分离,我的建议是从小处入手,先把某个模块的接口切出来,跑通开发联调部署全流程,再逐步推广。这个过程里,接口契约和自动化部署越早建立越好,它们会反过来逼着团队规范起来。我自己踩了这么久的坑,最深的体会是:前后端分离的难点从来不在“分”,而在“分完之后怎么还能高效地合在一起”。