前后端分离这个概念喊了很多年,但真正做过完整项目的人都知道:很多团队做出来的东西其实是"半分离"——前端只管写页面,后端只管出接口,一进联调阶段就开始互相推诿。我这些年前后参与过几个前后端分离的项目,从内部的运营后台到面向用户的商城系统,从两三个人到十几个人规模的团队,最大的感悟是:一个完整的分离项目,拆的不只是代码,而是责任边界和协作流程。
这篇文章想把我搭完整项目的那套东西系统整理出来,从技术选型、目录设计、后端分层、前端对接,到部署上线和常见坑,一条线讲清楚。内容按"真实项目该有的完整链路"来组织,适合正在学前后端分离、准备做毕业设计或团队项目,以及团队还没形成统一规范的同学参考。看完你会发现,所谓完整版,不是把代码写出来能跑就行,而是从第一行代码到最终上线,每一步都有章可循。
1. 前后端分离不是在分代码,而是在分责任边界
很多新手对前后端分离的理解停留在"前端一套代码、后端一套代码、接口对接"这个层面。这没错,但太浅了。真正把项目做完整,首先要搞清楚分离到底分的是什么。
1.1 分离之前,先回答三个问题
我每次接手新项目或带新人做项目,都会先让他们回答三个问题:
第一个问题:页面上的数据,谁负责获取?在分离架构里,前端负责调用接口并把数据渲染到页面上,后端只负责按约定返回数据,不关心这些数据最终渲染成什么样子。同理,页面上用户点了按钮,前端负责收集行为、组装参数,后端负责校验和业务处理。
第二个问题:数据出错了,谁负责处理?很多项目的混乱就是从这儿开始的:后端返回的错误千奇百怪,有的直接抛异常给前端看堆栈,有的返回一个错误字符串,前端拿到之后完全不知道该怎么提示用户。正确的做法是,前后端约定一套统一的错误码和错误信息结构,后端抛业务异常时返回固定结构,前端拿到结构后统一解析。
第三个问题:状态变了,怎么通知对方?这里的"状态"有两个含义。一个是业务数据的状态,比如订单从待支付变成已支付,前端要刷新。另一个是用户登录状态,token 过期了,前端要能感知并引导用户重新登录。这需要前后端在接口设计上预留好约定,而不是临时想办法。
把这三个问题想清楚,前后端各自该做什么基本就浮出水面了。
1.2 视图、状态、契约三者如何分割
用做饭来类比可能更好懂。前端是负责摆盘和上菜的餐厅服务员,后端是后厨。服务员要懂菜单(接口文档),知道每道菜大概多久能上(接口响应时间),但不需要亲自去后厨炒菜。后厨要保证出菜稳定,但不用管客人桌号怎么排。
在这个类比里,菜单就是前后端之间的"契约"——即接口文档。而"状态"则是订单的状态:客人点菜之后,后厨要告诉服务员"这道菜已经下锅了",服务员才能回复客人。落实到项目里,前端通过接口请求传递"用户意图"(比如提交订单),后端返回"业务结果"(比如订单号、状态码),前端再根据结果更新页面状态。
所以分割的本质是三层:
- 视图层(前端模板和组件)——只管怎么展示,不管数据怎么来。
- 状态层(前端状态管理和后端数据库状态)——前端管页面状态,后端管持久化数据。
- 契约层(API 定义)——双方共同维护,是所有协作的基础。
1.3 常见误解:分离不等于各干各的
有个很常见的误解:分离就是前端和后端老死不相往来,各写各的,最后对接就行。这么干的项目,百分之百在联调阶段翻车。
真实情况是,分离之后沟通成本反而更高了。以前一个人改一段代码就能完成的改动,现在可能要前后端两个人配合。所以分离项目最怕的是"没有共同语言":前端看不懂后端的字段命名,后端不理解前端为什么非要某个格式的数据。这个问题在后面的接口规范部分会详细讲,这里先记住一个原则:分离之后,接口文档和数据结构约定是团队的公共财富,必须投入精力维护。
2. 从一张目录开始:完整项目的顶层设计
搭项目的第一个动作不是写代码,而是搭目录。很多新手项目烂掉,不是因为代码写得差,而是因为目录结构从一开始就没有设计,东西越堆越乱,最后自己都找不到文件在哪。
2.1 前后端目录怎么摆,才不会烂
我见过两种典型方案。
第一种是"一个大仓库里分前后端两个目录",类似这样:
my-project/ ├── frontend/ # 前端工程 │ ├── src/ │ ├── package.json │ └── vite.config.js ├── backend/ # 后端工程 │ ├── src/ │ ├── pom.xml 或 package.json │ └── README.md └── docs/ # 接口文档、部署文档、会议记录这种方案适合中小型团队,前后端代码放一个仓库,容易统一管理版本,新人clone一次就能看到全貌。缺点是仓库会越来越大,不好做单端的权限控制。
第二种是"前后端两个独立仓库"。适合团队规模较大、前后端各有专人维护的场景,但跨仓库的接口变更管理比较麻烦,需要靠文档和自动化工具联动。
我个人的偏好是,项目刚开始时用第一种,至少保证 everyone 能看到全貌;等团队超过十个人再拆仓库也不迟。
2.2 源码、文档、脚本三件套
一个完整项目,至少要有三个组成部分:源码、文档、脚本。
源码就不展开了。文档方面,不要只写 README,至少要覆盖:
- 项目说明:这个项目是干嘛的,给谁用。
- 本地开发指南:怎么跑起来,依赖哪些环境。
- 接口文档:每个接口的地址、参数、返回结构。
- 部署文档:生产环境怎么构建、怎么发布、怎么回滚。
脚本方面,把"一键跑起来"和"一键部署"这种重复劳动脚本化。我通常会在项目根目录放一个Makefile或几个 shell 脚本,把启动后端、启动前端、初始化数据库、打包构建这些常用命令包起来。别小看这件事,它能极大降低团队新人的入门成本。
2.3 数据流与异常流的设计原则
在搭目录之外,还要在脑子里过一遍数据流和异常流。
数据流好理解——用户请求从浏览器到前端路由,再到后端 controller,然后到 service 和数据库,最后原路返回。这个链路要保证每一层都职责单一,不在 controller 里写业务逻辑,不在 service 层拼 HTML。
异常流是很多项目忽略的点。正常的接口返回好设计,但异常情况呢?参数校验失败、业务规则不满足、数据库断电、上游服务超时,这些情况下的返回结构也得是统一的。我习惯在项目文档里专门画一张异常流表,列出常见的异常类型、错误码、前端应该展示什么提示。联调阶段减少的扯皮数量,远超最开始花掉的那点时间。
3. 环境准备:选型取舍与本地开发链路
谈到技术选型,我不想直接给一个"标准答案",因为不同的项目规模、团队背景,适合的选型完全不同。但环境这一层的坑是通用的,值得单独拿出来说。
3.1 版本管理是第一个容易崩的点
前后端分离项目,最怕的不是写代码,而是"在我这是好的,换台机器就崩了"。这通常不是代码问题,而是环境版本问题。
前端的 Node 版本、包管理器版本、脚手架版本,后端的语言版本、依赖框架版本,数据库的版本,任何一个对不上,都会出现莫名其妙的问题。所以项目的第一件事是把开发环境版本写进文档或.nvmrc、.java-version这类文件里,团队统一。我见过一个团队因为有人用 Node 20、有人用 Node 16,导致一个老项目在新版本 Node 下构建报错,排查了半天才找到原因。
3.2 前后端脚手架怎么选
前端脚手架选择上,近几年的最优解已经从 webpack 全家桶转向了基于 Vite 这类构建工具的方案。它启动快、热更新快,对开发体验的提升非常明显。如果你是新手,想快速跑通前后端分离,直接选最新的稳定版脚手架即可,不用纠结。
后端脚手架选择,Java 生态有成熟的工程模板,Go 生态也有类似 gin 的快速启动框架,Python 生态则有 Django 和 FastAPI。关键不是选哪个框架,而是选哪个组织结构适合你的团队。我见过有的团队用 Java 但代码结构写得跟单体 PHP 一样,照样能跑,只是维护成本高。
选型的核心原则:选团队最熟的,而不是网上吹得最热的。项目能不能成,靠的是人对工具的熟悉程度,不是工具本身的新鲜程度。
3.3 本地跑通的最小闭环
环境准备完之后,先别急着写业务代码,先把"最小闭环"跑通:前端起一个页面,后端起一个接口,前端可以通过代理把请求打到后端并拿到返回数据。
这一步通常卡在两个地方。一个是跨域:前端页面跑在 localhost:5173,后端接口跑在 localhost:8080,浏览器默认不允许跨端口请求。开发环境下最简单的方案是在前端脚手架里配开发服务器代理,把/api前缀的请求转发到后端地址,这样浏览器看到的是同源请求,不存在跨域问题。
另一个是接口联通的验证:先让后端写一个健康检查接口(比如GET /api/health返回{ "status": "ok" }),前端封装好请求工具之后先调这个接口,通过之后再开始正式业务开发。这个"健康检查先行"的习惯,能帮你排除掉一半的环境配置问题。
4. 后端落地:领域分层与接口规范
环境通了,开始写后端。很多初学者写后端接口的方式是:数据库表长什么样,接口就返回什么样。这个做法在小项目里没什么问题,但一旦业务复杂起来,就会发现数据库表的字段和前端需要的数据结构往往对不上,最后要么后端被迫改结构,要么前端被迫做各种数据转换。
4.1 后端分层:不要把数据库表直接搬到接口
一个稍微正规点的后端项目,至少要分三层:
- Controller 层:只做参数接收和请求转发。解析 HTTP 请求,调用 Service 层,把结果封装成响应返回。
- Service 层:业务逻辑的核心。事务管理、业务校验、数据组装都在这层。
- Mapper / DAO 层:只写数据访问,不做业务判断。
举例说明。假设用户表是t_user(id, username, password_hash, email, phone, created_at...),但你给前端返回用户信息时,绝不应该返回password_hash这种敏感字段。Controller 层返回的应该是"视图模型"——即前端真正需要的数据。这就是分层的意义:数据库模型和接口模型可以不一样,中间有一层专门做领域组装。
在具体代码上,我的习惯是给每个业务模块建一个VO(视图对象)类或结构体,放在专门的dto包里,Controller 返回的是 VO 而不是数据库实体。这个习惯养成了,接口的稳定性会大幅提升。
4.2 统一响应体与全局异常处理
前后端分离项目里,接口的返回结构必须有统一约定。我常用的统一响应体结构如下:
{ "code": 0, "message": "成功", "data": { } }这里的code是业务码,0表示成功,非0表示失败。message是给前端提示用的话,data是业务数据。
为什么不用 HTTP 状态码直接表示业务状态?因为 HTTP 状态码只有几十个,语义有限。比如"参数校验失败"和"订单已取消不能支付",如果都用 HTTP 400,前端没法区分到底是哪种失败,也没法做精细化的用户提示。有了业务码之后,前端可以针对不同 code 做不同处理。
后端要做配套的全局异常处理。比如 Java 生态里通常用@ControllerAdvice做统一异常拦截,把业务异常、参数校验异常、系统异常分别映射到不同的 code 和 message,保证任何情况下返回给前端的结构都是一致的。这一步做好,联调的时候前端可以非常舒服——它只需要处理统一结构,不用应对各种"奇葩返回"。
4.3 一个用户模块从建表到出接口的全过程
说了这么多理论,用一个用户注册接口串一下完整流程。
第一步,建表。用户表除了基本字段,还要考虑唯一索引、创建时间等公共字段。
第二步,写 Service 层逻辑。注册的逻辑一般是:校验用户名是否重复 → 密码加密 → 插入数据库 → 返回用户 ID。密码加密必须用专用加密哈希算法,不允许存明文。
第三步,写 Controller 层。接收POST /api/user/register请求,参数是{ "username": "...", "password": "..." },Controller 层做参数格式校验,然后调 Service 层,最后返回统一响应体:
{ "code": 0, "message": "注册成功", "data": { "userId": 1001, "username": "Tom" } }第四步,写全局异常处理。如果用户名重复了,Service 层抛一个业务异常,全局异常处理器把它转换为code: 1001, message: "用户名已被注册"返回。
这个流程走下来,前端对接的时候体验非常好:成功就是成功结构,失败就是失败结构,该提示什么用户信息后端都写好了,前端不用猜。
5. 前端落地:请求封装与状态管理
后端接口出来了,前端开始接。前端最容易出的问题有两个:一个是请求代码到处重复,另一个是状态管理乱成一团。
5.1 请求层要封装到什么粒度
我说的请求封装,不是把axios或者fetch包装一层就叫封装了。完整的请求封装至少要覆盖以下能力:
- 统一的 baseURL 配置:所有请求都走同一个基础路径,这样切换环境(本地、测试、生产)的时候只需要改一处配置。
- 统一的请求和响应拦截:请求拦截器负责自动携带 token,响应拦截器负责统一解析响应结构、处理 code 非 0 的情况、处理 401 未授权跳转登录页。
- 统一的 loading 处理:可以在请求开始和结束时自动调用全局 loading 状态,前端 UI 直接响应。
- 错误兜底:网络超时、断网、后端 500,这些情况必须有默认的错误提示,不能抛到页面控制台就完事。
在这一层的基础上,建议再按业务域建 API 模块文件,比如api/user.js、api/order.js,每个模块里导出这个域的所有接口函数。这样页面里引用接口时只需import { register } from '@/api/user',可读性非常好。
5.2 状态管理:别一上来就全局
状态管理(以 Vuex / Pinia / Redux 之类的库为代表)是前端架构里被滥用最严重的部分。很多新手项目一上来就把所有数据放进全局 store,导致组件间数据流动混乱,任何改动都可能触发不知名的副作用。
我的经验是,要先用"局部优先"的原则考虑状态存放位置:
- 组件自己的 UI 状态,用组件内部状态就行。
- 父子组件之间的传参,用 props 和方法调用。
- 多个组件共享的页面级状态,可以放在父组件,或做一个局部共享 store。
- 只有真正全局共享的状态(登录信息、用户权限、全局主题设置、多页面共用的业务数据),才放进全局 store。
这个原则写进团队规范里,能避免很多后续的维护问题。前端架构的复杂度,很多时候不是技术带来的,而是状态管理不当带来的。
5.3 路由守卫与权限控制的落地姿势
页面权限是前后端分离项目里绕不开的一环。后端的权限按接口控制,前端的权限按"路由和按钮"控制。
前端路由守卫的核心逻辑很简单:在每次路由跳转前,检查用户是否有权限进入目标页面。具体到实现,主要做三件事:
- 判断用户是否已登录(有没有 token)。
- 判断用户是否有目标页面的权限(从后端拿到的权限表里查一下)。
- 没有权限就跳转到 404 或提示页,未登录就跳转到登录页。
按钮级权限也是类似思路,通常是封装一个自定义指令或组件,当前用户没有对应权限时不渲染按钮。记住一个原则:前端的权限控制只是用户体验层面的限制,真正的安全校验必须由后端在每个接口上做。前端跳过了没权限的按钮,不代表后端可以不校验。
6. 联调与自测:工程素养的分水岭
前后端写完了,进入联调环节。如果有人跟你说"联调就是连上之后跑流程",那他大概率还没经历过真正的联调地狱。一次顺利的联调,背后需要做大量的前置工作。
6.1 接口文档先行的节奏
先有接口文档,再写代码。哪怕是你一个人同时写前后端,也建议遵循这个节奏。为什么?因为在写接口文档的过程中,你被迫把数据结构、错误码、边界条件都想清楚。直接写代码时,大多数人会偷懒,网络异常怎么处理、边界参数怎么校验,往往会模糊带过。
接口文档我建议用通用格式,而不是 Excel 或 Word 流传。现在有很多开源接口管理工具,可以很方便地维护接口定义和在线调试。文档里必须包含的信息有:接口路径、请求方法、请求参数(名称、类型、是否必传、说明)、返回结构(成功和失败两种示例)、错误码列表。把这些写全,联调时基本不会出现理解偏差。
6.2 Mock 前置与联调切换
理想的项目节奏应该是:后端还在开发时,前端已经能用 Mock 数据开始写页面了。前后端分离项目之所以能并行开发,靠的就是 Mock。
前端的做法一般是:在 API 模块层做一个开关,process.env.VITE_USE_MOCK === 'true'时走本地 Mock,否则走真实后端接口。这样前端开发时完全不依赖后端是否 ready,后端 ready 之后把开关一关就切到真实联调。
Mock 数据不是随便写的,它应该严格遵循接口文档里的结构,包括成功和失败两种情况。我见过不少前端同学用 Mock 时只写成功数据,结果联调时一遇失败情况页面就崩。Mock 的时候就要把错误分支也覆盖掉。
6.3 联调前置检查清单
每次联调前,我习惯先过一遍自查清单,减少无效沟通:
| 项目 | 检查内容 |
|---|---|
| 环境 | 前后端是否都跑在预期环境,代理指向是否正确 |
| 登录 | 登录接口能否正常返回 token,token 是否能被前端正确携带 |
| 权限 | 不同角色的权限数据是否正确返回,前端路由守卫是否生效 |
| 成功链路 | 核心业务流程(比如创建订单、支付、查询)能否完整走通 |
| 失败链路 | 参数校验失败、业务校验失败、token 失效是否都有正确提示 |
| 边界条件 | 空数据、超长文本、并发重复提交是否有兜底处理 |
这份清单不需要多复杂,关键是把高频问题列出来,每次联调前过一遍。很多项目延期的原因,根本不是技术难题,而是一次次"我以为你那边处理了"的重复联调。
7. 部署上线:构建、服务与反向代理
项目开发完,最后一步是上线。部署环节的坑和开发完全不同,很多项目在本地跑得好好的,一上生产就崩,基本都是部署配置的问题。
7.1 前端构建与静态资源优化
前端部署的本质是"构建 + 静态托管"。代码写完后,通过构建命令把源码编译成静态资源目录(HTML、JS、CSS、图片等),然后把构建产物放到服务器上,或者放到 CDN 上。
构建时要注意几个优化点:
- 代码分割:把大象级的依赖库单独打包,利用浏览器缓存减少重复加载。
- 资源压缩:JS 和 CSS 压缩、图片压缩,减少传输体积。
- 文件名哈希:给文件名加上内容哈希,这样代码更新后用户可以拿到新文件,不会被缓存卡住。
- 网关压缩:在部署层开启 Gzip 或 Brotli 压缩,文本资源的体积能再降不少。
这些优化的收益,在页面加载速度上体现得非常直观。一个带压缩优化和一个不带压缩优化的前端资源路径,首屏耗时差距经常是几倍。
7.2 后端服务化与进程守护
后端的部署方式和语言生态强相关,但有几个共识级的要点。
第一个是永远不要用"前端进程 + 后端进程裸奔"的方式启动服务。生产环境至少要有进程守护能力:服务崩了要能自动拉起,否则凌晨服务挂了,你还在睡觉,用户已经在骂了。用 systemd、容器编排等进行进程守护,都是常见做法。
第二个是独立的配置管理。不要把数据库密码、第三方密钥直接写死在代码里。环境变量或配置中心来管这些敏感信息,并且要把"本地配置"和"生产配置"分离。很多泄漏事故都是因为开发者把带密钥的配置直接提交到了代码仓库。
第三个是日志。没有日志的线上服务等于盲人摸象。至少要有访问日志和错误日志,并按天切分,保留足够的周期方便追溯。
7.3 反向代理与 HTTPS 的配置要点
前端静态资源和后端接口,最终需要通过同一入口对外提供服务,否则浏览器里前端页面和接口地址不一致又会触发跨域。最常规的方案是用反向代理服务器(最典型的是 Nginx 这类)把两者统一起来。
一个典型的反向代理配置逻辑是这样的:
# 静态资源,直接指向前端构建产物目录 location / { root /var/www/my-project; index index.html; } # 接口请求,转发到后端服务 location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }这样做的好处是:前端页面和接口走同一个域名和端口,不存在跨域问题;静态资源不经过后端服务,压力小;后端的真实端口也不会直接暴露在公网。
还有两个配置细节不能忽略:一是单页面应用的路由模式,需要配置 try_files 规则把所有路径重写到 index.html,否则用户刷新页面或直接访问某个子路径时会 404;二是全站启用 HTTPS,配置好证书并强制跳转,这已经是上线的基本要求。
8. 这些坑我踩过,希望你绕开
最后分享一些实战中反复出现的问题。这些问题技术难度不高,但非常消耗人,而且每个团队都会遇到一遍。
8.1 跨域不是配了 CORS 就完事
很多人的解决方案是:后端开 CORS,允许所有来源,然后上线了。开发环境倒没什么问题,但生产环境一旦页面域名和接口域名不一致,随便一个来源都来调你的接口,安全隐患很大。
正确做法是区分环境来配置。开发环境用前端代理转发,根本不让浏览器发起跨域请求,不需要后端开 CORS。生产环境尽量让前端和后端走同一个域名,还是不需要 CORS。只有确实存在独立域名拆分需求时才启用 CORS,并且要把允许的来源精确到具体域名,而不是*。
8.2 字段命名、时区与超时:三类隐性问题
字段命名不一致是联调阶段最常见的扯皮点。后端返回user_name,前端代码里写的是userName,这种情况在 JavaScript 对象里根本不会报错,只是页面怎么渲染都拿不到值。建议在接口文档阶段就统一命名规范,把字段名写死在文档里,代码按文档来。
时区问题隐蔽性更强。数据库存的是标准时间,前端展示的时候如果直接用new Date(str)解析,不同时区的用户看到的时刻会完全不一样。凡是涉及时间展示,前端要注意时区转换;凡是涉及时间存储,后端要明确用哪种时区格式。
超时问题也一样隐蔽。默认请求超时时间可能很长,用户点击提交后如果后端一直不返回,页面就会一直转圈。前端请求层必须设置合理的超时时间,比如 10 秒或 15 秒,超时后直接提示"请求超时,请重试"。这个处理方式虽然简单,但真能救很多用户流失。
8.3 环境变量泄漏与构建产物检查
前后端都支持环境变量,但很多人分不清什么是能放进前端构建产物的、什么是绝对不能放的。前端代码构建之后是公开资源,凡是写在前端代码里的内容,任何人都能在浏览器里看到。因此,前端的接口地址、业务相关的非敏感配置可以放,但私钥、数据库密码、后端内部密钥,绝对不能出现在前端环境变量里。
我见过一起事故:一个人不小心把云存储的密钥写进了前端代码并提交上线,结果被人扫描并刷走了巨额费用。这不是夸张,是真实存在的风险。上线前建议做一次构建产物检查,搜索一下产物文件里有没有不该出现的密钥关键词。
每次搭完一个完整项目,我都会把最初的架构图和最终的代码结构翻出来对比一遍。说实话,真正落地的方案永远比初始计划更复杂,但只要责任边界清晰、接口规范统一、部署链路完整,这个复杂度就是可控的。我这里整理的内容,更多是基于通用前后端分离架构都能用上的"公共骨架";真正到你的具体场景时,不建议迷信任何一种固定解法,而是从"谁负责什么、数据怎么流、出了错怎么办"这三个问题出发,逐步演化你自己的方案。
最后分享一个实际的体会:别急着在项目一开始就追求"最佳实践"和"最全架构"。完整项目的目标从来不是架构华丽,而是让每个参与者在任何一个环节都知道下一步该做什么,并且有文档、有规范、有脚本可以依赖。能把这一条做到,你的前后端分离项目就已经比大多数项目完整了。