
很多朋友在本地开发前后端分离项目时最头疼的往往不是业务逻辑本身而是明明后端接口用 Postman 测试得好好的前端一接就报错。报 CORS、报 404、报 Cookie 丢了、报请求体是空的……这些破事看着小一查能查一下午。我自己从早期的 JSP 时代一路做到现在的 SpringBoot Vue、FastAPI Vue 这类前后端分离架构踩过的联调坑比写过的业务代码都多。这篇文章把本地联调这件事从头到尾捋一遍包括环境怎么梳理、CORS 到底怎么根治、Cookie 怎么带、代理怎么配、接口扯皮怎么避免最后再给一份常见问题的排查清单。不是教科书式的搬运全是实操里磨出来的经验。1. 本地联调到底在解决什么问题1.1 为什么 Postman 能通前端就通不了先说一个最常见的现象后端同事把接口调通了Swagger 文档也贴出来了你用 Postman 一打数据正常返回。结果前端页面一调浏览器控制台直接给你甩一个红色的 CORS error或者请求发出去了但看 Network 面板里根本没带 Cookie。这时候很多人的第一反应是“后端代码有问题”其实多半不是。这里面的核心差异在于Postman 是一个独立客户端它发请求时不执行 JavaScript、不遵守浏览器的同源策略、也不会自动带上 Cookie。而浏览器里的前端页面是带“身份”的它跑在某个 origin协议域名端口下向另一个 origin 发起请求时浏览器会强制执行同源策略。所谓“本地联调”本质上就是在本地环境里模拟出浏览器与服务器之间那种“既隔离又需协作”的生产关系把线上的跨域、鉴权、代理转发问题提前暴露在开发阶段。所以本地联调不是简单地把前端项目跑起来、后端项目跑起来然后在浏览器里访问一下就够了。你得确保双方的协议约定一致、数据格式能互相解析、安全策略不拦截、代理转发路径正确。任何一个环节没对齐联调就变成“调不通”。1.2 联调环境的三种典型形态本地联调的环境形态我见过的主要有三种各有适用场景。第一种是前后端都在本机跑前端用 Node 服务比如 Vite、Webpack DevServer后端用本地端口比如 SpringBoot 的 8080FastAPI 的 8000。这是大多数项目初期的标配优点是没有网络延迟改代码即时生效缺点是容易遇到 CORS 和 Cookie 跨域问题。第二种是前端在本机跑后端在远端开发服务器上跑比如后端同事在测试服务器上部署了一个 dev 环境给你一个地址。这种形态下前端必须要配代理或者让后端把测试服务器的 CORS 打开否则浏览器会直接拦截。第三种是全容器化前后端都用 Docker 在本地跑用 Docker Compose 编排通过内部网络通信。这种方式的隔离性好更接近生产环境但链路长排错也比前两种麻烦一点。我见过不少团队直接跳过前两种一上来就上容器编排结果环境问题比业务问题还多。对于大多数团队我建议初期先把前后端都跑在本机步调简单、日志好查等核心业务联调通了再引入容器化或者远端环境。2. CORS 是本地联调第一道坎但不该靠“允许所有源”糊弄2.1 CORS 到底是谁在拦截谁CORSCross-Origin Resource Sharing是浏览器的一种安全机制本质是浏览器在发起跨域请求时会先判断这次请求是否“简单请求”。如果是简单请求浏览器直接发出请求但在解析响应时会检查响应头里有没有 Access-Control-Allow-Origin如果不是简单请求比如 Content-Type 是 application/json或者用了自定义 Header浏览器会先发一个 OPTIONS 预检请求预检通过后才会发真实请求。这里有个关键点CORS 的拦截发生在浏览器端不是服务器端。后端接口其实已经执行了、数据已经返回了浏览器只是不让 JavaScript 读取这个响应而已。我见过很多新手在这里绕圈子总以为是后端没返回数据其实返回了就是被浏览器藏起来了。打开 DevTools 的 Network 面板看到那个请求状态是 (failed)net::ERR_FAILED 或者 CORS error再去 Console 看具体报错就能判断出来是预检没过还是响应头不对。在我接触的项目里后端用 SpringBoot 的话最常见的配置是写一个 CorsFilter 或者用 CrossOrigin 注解用 FastAPI 的话是用 CORSMiddleware。这些本身都没问题问题出在很多人直接配了 allow_origins[*]也就是允许所有源。本地联调这么配确实省事但一旦部署到生产这就是个安全隐患等于允许任何网站跨域读取你的接口数据只要对方知道接口地址且不需要非标准请求头。所以我的建议是本地联调时 CORS 可以宽松一点但代码里要留好配置开关联调完切回白名单模式。2.2 一个线上踩过的 CORS 配置示例先看 SpringBoot 的一个相对稳妥的 CORS 写法Configuration public class CorsConfig { Bean public CorsFilter corsFilter() { CorsConfiguration config new CorsConfiguration(); // 允许携带凭证 config.setAllowCredentials(true); // 用配置文件控制允许的域名本地联调可以设为 http://localhost:5173 config.setAllowedOriginPatterns(Arrays.asList( env.getProperty(cors.allowed-origins, http://localhost:5173).split(,) )); config.addAllowedHeader(*); config.addAllowedMethod(*); config.setMaxAge(3600L); UrlBasedCorsConfigurationSource source new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration(/**, config); return new CorsFilter(source); } }再来看 FastAPI 的写法from fastapi.middleware.cors import CORSMiddleware app.add_middleware( CORSMiddleware, allow_origins[http://localhost:5173], allow_credentialsTrue, allow_methods[*], allow_headers[*], )注意一个细节当 allow_credentials 为 true 时allow_origins 不能写成 [*]。这是规范明确禁止的因为如果允许任意源携带 Cookie那任何恶意网站都可以借浏览器的机制冒充用户发起请求。FastAPI 的 CORSMiddleware 遇到这种配置会直接报错SpringBoot 的 allowedOriginPatterns 则可以配合 allowCredentials 使用按 Spring 的文档说明用 allowedOriginPatterns 是允许和 allowCredentials 同时开启的。如果你用的是 SpringBoot 低版本或者 Servlet 容器版本较旧可能不支持 allowedOriginPatterns那就只能用白名单列表。2.3 预检请求的缓存优化还有一个容易忽略的点是 maxAge。每次跨域 POST 请求只要 Content-Type 是 application/json都会先触发一个 OPTIONS 预检。如果接口多、页面加载频繁网络面板里就会看到大量 OPTIONS 请求耗时虽小但看着烦、也拖慢首屏。设置 maxAge 之后浏览器会在指定时间内对这个跨域配置的结果做缓存期间不再重复发预检。SpringBoot 里就是上面的 setMaxAge(3600L)单位是秒FastAPI 里 CORSMiddleware 也支持 max_age 参数默认是 600 秒我习惯把它设为 3600。开发机基本是一天调一次这个值设置成 3600 不会有问题。3. Cookie 和登录态是联调最容易被忽视的暗坑3.1 浏览器怎么判断“能不能带 Cookie”跨域请求里带 Cookie是“前端登录完后端接口却不认识你”的头号原因。Cookie 能不能带上不是由前后端代码说了算而是由浏览器的 SameSite 策略、Cookie 的 Domain 属性、以及请求的 credentials 模式共同决定的。讲个具体场景前端跑在 http://localhost:5173后端跑在 http://localhost:8080前端用 axios 发请求即使后端返回了 Set-Cookie只要前端请求没有开启 withCredentials浏览器就不会保存这个 Cookie。axios 里要加这么一行axios.defaults.withCredentials true;如果你用的是 fetch要写成fetch(/api/user, { credentials: include });但光是加了 withCredentials 还不够。后端的 Set-Cookie 响应头里如果没设置 SameSiteNone; Secure浏览器同样可能拒绝保存跨站 Cookie。注意SameSiteNone 必须配合 Secure而 Secure 要求请求必须是 HTTPS 或者 localhost 是特例——在 Chrome 里http://localhost 可以被视为 secure context所以本地联调时设置 SameSiteNone; Secure 是可行的。但如果你的后端跑在 127.0.0.1 而不是 localhost部分浏览器版本可能不会把它当作 secure context这时候 Cookie 就存不下来。经验之谈本地联调统一用 localhost不要混用 127.0.0.1 和 localhost两个主机名在 Cookie 层面是完全不同的 Domain。3.2 前后端分离下的登录态保持方案在实际项目里我见到的登录态联动大致有两种流派。一种是 Session Cookie后端用 HttpSession 存登录态通过 Set-Cookie 下发一个 JSESSIONIDSpringBoot 默认或 sessionidFastAPI 自己实现。这种方式在本地联调时最容易踩 SameSite 的坑而且前后端分离之后Session 的存在感会被弱化因为部署时前端和后端大概率不在同一个域名下Cookie 的管理成本很高。另一种是 Token 方式前端把用户名密码发给后端后端校验后返回一个 TokenJWT 或者 Opaque Token前端把它存在 localStorage 或内存里每次请求在 Authorization Header 里带上。这种方式天然规避了 Cookie 的跨域问题也是现在前后端分离项目的绝对主流。我个人的倾向是本地联调阶段如果项目已经定了用 Token那就坚定地用 Token不要在中间临时混入 Cookie 方案。Cookie 方案在本地联调容易遇到环境差异而且一旦浏览器策略更新比如 Chrome 逐步收紧第三方 Cookie你都不知道自己是怎么挂的。而 Token 方案只要保证前端每次请求都带上 Authorization 头后端 CORS 配置里允许该 Header基本就没有大的跨域障碍。不过 Token 方案也有它的坑Token 存在 localStorage 里页面刷新后还在但如果是存在内存变量里刷新就丢了会导致用户“莫名其妙被登出”。本地联调时后端改了代码会触发自动重启前端的 Token 如果还是旧的可能因为密钥变了而失效。遇到这种“重启后忽然 401”的情况先清一下本地存储再重新登录能省很多排查时间。3.3 代理模式下 Cookie 的 Domain 要重新理解当你用了前端代理比如 Vite 的 server.proxy把 /api 转发到后端时浏览器看到的请求目标始终是 http://localhost:5173/api/xxx后端设置的 Set-Cookie 的 Domain 如果不带端口默认就是 localhost。这时候 Cookie 能正常存下来。但如果后端返回的 Set-Cookie 里显式写了 Domainlocalhost:8080浏览器是不是会按 8080 端口去匹配这里有个很多新手搞不清的细节——Cookie 的 Domain 属性是不包含端口的。Domainlocalhost 可以匹配 http://localhost:5173 和 http://localhost:8080因为它只匹配域名部分不匹配端口。但 Domainlocalhost:8080 这种写法是错的浏览器不会匹配任何请求。所以后端在 Set-Cookie 时不要写端口就让浏览器按默认规则处理。4. 本地域名和 hosts 映射彻底摆脱端口号地狱4.1 为什么推荐给本地项目分配专属域名联调进行到一定阶段特别是当你在一个项目里同时涉及前端页面、后端接口、静态资源、WebSocket 推送等多个服务时“http://localhost:5173 调用 http://localhost:8080”这种多端口组合会变得非常难管理。端口号一多CORS 配置越来越长Cookie 的 Domain 也容易出问题而且前端页面里如果写死了回调地址换一个端口就要改一大批配置。我推荐的做法是在本地 hosts 文件里给项目分配一个专属域名比如 dev.myproject.com让前端域名是 http://dev.myproject.com:5173后端反向代理统一走 http://dev.myproject.com/api这样从浏览器视角来看所有请求都是同源的CORS 和 Cookie 问题直接消失。hosts 文件的位置Windows 在 C:\Windows\System32\drivers\etc\hostsmacOS/Linux 在 /etc/hosts。添加一行127.0.0.1 dev.myproject.com这里有个细节如果你用 Docker 跑后端并且端口映射到了 127.0.0.1只加 127.0.0.1 这一行就够了如果后端容器没做端口映射而是通过 Docker 网络直接访问你可能还需要加容器的 IP。本地开发我一般建议做端口映射别去折腾 Docker 内部网络。4.2 基于 Nginx 的本地反向代理配置有了域名之后还需要一个入口把请求分发到对应的服务。最常用的工具是 Nginx。在本地装一个 Nginx配一个 server 块大概长这样server { listen 80; server_name dev.myproject.com; # 前端静态资源或者代理到前端 DevServer location / { proxy_pass http://127.0.0.1:5173; 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_set_header X-Forwarded-Proto $scheme; } # 后端接口 location /api/ { proxy_pass http://127.0.0.1:8080/api/; proxy_set_header Host $host; } # WebSocket 支持如果项目里有推送功能 location /ws/ { proxy_pass http://127.0.0.1:8080/ws/; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection upgrade; } }这一段配置里proxy_pass 后面的 URI 是否带斜杠直接把 /api/ 前缀是否保留这件事决定了很容易写错。如果 proxy_pass http://127.0.0.1:8080;不带路径那么请求 /api/user 会被转发到 http://127.0.0.1:8080/api/user如果写成 http://127.0.0.1:8080/;带斜杠那 /api/user 会被转发成 /user。实际项目里后端接口多半是带 /api 前缀的所以我通常选择不带路径的写法保留完整 URI。4.3 换域名之后的前端配置调整配好域名和 Nginx 之后前端代码里原来的 axios baseURL 可以直接改成相对路径 /api不再需要写完整的 http://localhost:8080。这样代码里就不再有环境相关的地址部署到测试环境、生产环境时只要在 Nginx 或网关层改代理规则就行。Vite 的 server.proxy 配置也不需要了因为所有请求已经被 Nginx 转发前端 DevServer 只需要提供页面本身。这里有个经验改用统一域名后Vite 的 HMR热更新默认走的是 WebSocket如果你用 Nginx 代理到 5173 端口注意 HMR 的 WebSocket 连接可能会因为默认的 host 配置而失败。解决办法是 Vite 配置里加一段server: { host: true, hmr: { host: dev.myproject.com, protocol: ws } }否则你会发现页面能打开但改代码不热更新或者控制台刷一堆 WebSocket 连接失败的报错。5. 开发服务器代理和 Mock 数据怎么配合着用5.1 前端脚手架的代理能力本地联调的第一道武器在还没上 Nginx、或者后端还没完全准备好的时候前端脚手架自带的代理能力是我们最趁手的工具。Vite 的 server.proxy 默认基于 http-proxyWebpack 的 devServer.proxy 基于 http-proxy-middleware原理都是把前端服务器的某些路径转发到后端地址。以 Vite 为例配置是这样的// vite.config.js export default defineConfig({ server: { port: 5173, proxy: { /api: { target: http://localhost:8080, changeOrigin: true, } } } })这里的 changeOrigin 很关键。它会把请求头里的 Host 改成 target 的域名避免后端根据 Host 做校验时认为请求来源不合法。很多联调问题都是忘了配 changeOrigin后端收到请求一看 Host 是 localhost:5173直接拒绝或者处理逻辑走了分支。Webpack 的配置几乎一样// vue.config.js module.exports { devServer: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true, } } } }代理的路径匹配规则也值得一提。默认情况 /api 前缀匹配之后转发到后端时 /api 会原样保留。如果你的后端接口路径本来就带 /api那就没问题。但如果你后端接口路径不带 /api而前端统一加了 /api 前缀就需要用 rewrite 功能把前缀去掉。Vite 的写法是proxy: { /api: { target: http://localhost:8080, changeOrigin: true, rewrite: (path) path.replace(/^\/api/, ) } }Webpack 则用 pathRewriteproxy: { /api: { target: http://localhost:8080, changeOrigin: true, pathRewrite: { ^/api: } } }5.2 Mock 数据和联调怎么无缝切换当后端接口还没开发完前端又需要先写页面时Mock 数据是避免“互相等”的关键手段。我见过两种 Mock 思路一种是纯前端拦截比如用 vite-plugin-mock 或者 Mock.js拦截 XMLHttpRequest 请求并返回假数据另一种是后端起一个 Mock 服务提供同构的接口。前者在页面调试时很爽但有一个隐患Mock.js 是拦截 XHR 的如果你用 fetch 发请求或者通过代理走 WebSocketMock.js 就拦不住。我比较推荐的做法是在代理层做 Mock 开关。还是用 Vite 的 proxy配一个 fallback 逻辑当后端不可用时自动走本地的 Mock 文件。你可以写一个简单的中间件在 proxy 配置里加一个 bypass 函数判断某个接口是否命中 Mock 列表命中就直接返回 Mock 数据。这样前端代码完全不用感知自己在用 Mock 还是真实接口切换环境时只需要改一个配置项。但 Mock 数据有一个致命的坑Mock 数据往往是“正常情况”的数据。一旦你只依赖 Mock 联调就会漏掉很多边界情况——接口超时、返回 500、字段为 null、数组为空。等到接真实接口时前端代码可能会因为没做空值判断而直接白屏。所以我的经验是Mock 数据要尽量覆盖边界情况至少要有空的列表、缺失字段、错误码这三种类型。页面在这些异常数据下不崩溃接真实接口才有底气。5.3 代理模式下静态资源路径也要小心本地联调时前端静态资源图片、CSS、JS的路径如果有问题页面会“看起来打开了但又没完全打开”。比如你在 Vue 项目里给图片写了个绝对路径 /assets/logo.png但部署时前端资源的 base 路径是 /app/那本地联调时图片可能能显示部署到线上就 404。这类问题不属于传统意义上的联调问题但实践中特别常见。我的建议是前端资源地址一律用相对路径或者在 Vite 里配好 base 配置让它和部署时的路径一致。如果你用 Nginx 做了统一入口那 base 路径也由 Nginx 的 location 规则决定前端代码里尽量不要写死绝对根路径。6. 接口约定和字段格式联调扯皮的重灾区6.1 统一响应包装别让字段名大小写成为战场前后端联调最容易爆发争执的地方就是接口字段的命名和数据格式。后端习惯下划线命名 user_name前端习惯驼峰命名 userName。如果后端是 Java 系一般会用 Lombok 或者 Jackson 配置把字段序列化成下划线如果前端拿到的是下划线字段用 TypeScript 写类型定义时就会很别扭。到底听谁的严格来说这不是技术问题而是规范问题但技术上有办法缓解。最省心的方案是让后端保持自己的命名风格前端在 Model 层做一次映射。比如你用的是 Axios可以在响应拦截器里统一把 snake_case 转成 camelCase用 TypeScript 的话定义一个转换函数function toCamel(obj: any): any { if (Array.isArray(obj)) { return obj.map(toCamel); } if (obj ! null typeof obj object) { return Object.keys(obj).reduce((acc, key) { const camelKey key.replace(/_([a-z])/g, (_, c) c.toUpperCase()); acc[camelKey] toCamel(obj[key]); return acc; }, {} as any); } return obj; }注意这种“地毯式转换”在数据量大的时候会有性能损耗而且遇到嵌套对象里的 Date 字符串、文件 Blob 等特殊类型时可能会误伤。所以我个人更推荐的做法是在项目启动时就把接口规范定下来。用什么格式、时间怎么传、错误码怎么定义先出个简单的接口约定文档哪怕只有半页纸也会少掉 80% 的扯皮。6.2 时间、枚举和金额三种最容易出问题的类型本地联调时经常出现“接口通了但页面显示的数据不对”的情况多数时候问题出在类型没有对齐。时间字段是最典型的。后端返回的可能是 2024-06-01 12:00:00也可能是 2024-06-01T12:00:00.00008:00前端如果用 new Date() 去解析第一种格式在不同的浏览器里结果可能不一致iOS 的 JavaScriptCore 解析不了没有 T 的日期格式。我的建议是前后端约定一个统一格式比如 ISO 8601后端负责把时间序列化成带时区的 ISO 字符串前端用 dayjs 或 date-fns 统一解析。别让后端返回 yyyy-MM-dd HH:mm:ss 这种 format 好的字符串前端不仅不好解析还会因为时区问题导致显示偏差。枚举值也一样。后端返回 0、1、2前端如果不在代码里维护一张映射表就变成魔法数字改一个状态就得翻代码查上下文。联调阶段最好让后端多返回一个可读的文案字段虽然在生产上不一定需要但调试时可以省很多事。金额字段则是精度问题。Java 后端如果有 BigDecimal序列化出来可能带很长的小数JavaScript 的 Number 处理大数会有精度丢失。前后端 Dev 环境数据量小可能看不出问题一旦出现超过 2^53 的 ID比如雪花算法生成的分布式 ID前端拿到的值就变了查询数据时会出现“查不到”的诡异问题。解决办法后端把大整数 ID 序列化成字符串前端用 string 类型接收。6.3 接口文档工具怎么选才能让扯皮变少现在前后端团队普遍会引入接口文档工具比如 SwaggerSpringDoc 或 springfox、knife4j、Apifox、或者 YApi。就本地联调而言我更推荐把接口文档“接入到本地运行环境”里而不是只在测试环境开一个。拿 SpringBoot 来说本地联调时把 springdoc-openapi 依赖加上访问 http://localhost:8080/swagger-ui.html 就能看到实时文档。前端在联调时如果发现某个字段含义不明确可以直接看接口定义省去问后端的等待时间。FastAPI 就更方便了FastAPI 自带 /docs 和 /redoc基本上开箱即用。用如果项目是用若依框架这类二次开发框架一般也会自带 Swagger 集成登录后能看到接口列表。文档工具能解决“接口定义是什么”的问题但解决不了“字段含义是什么”的业务语义问题。我的习惯是在接口注释里写清楚每个字段的用途、取值范围、是否必填、以及典型的示例值。看似增加了开发量实际上是在给自己省沟通成本。7. 从本地联调到持续集成环境切换的实践经验7.1 接口地址与环境配置怎么管才不会一锅粥本地联调只是开发周期的第一步后面还有测试环境、预发环境、生产环境。如果每个环境的接口地址都在代码里写死你会在切换环境时改到怀疑人生。我推荐的做法是前端项目里用一个环境配置文件集合比如 .env.development、.env.test、.env.productionVite 会在启动时根据模式加载对应文件。文件内容大概是这样# .env.development VITE_API_BASE_URL/api VITE_USE_MOCKfalse代码里统一用 import.meta.env.VITE_API_BASE_URL 拼请求路径。这样本地联调时你只需要保证 .env.development 里的地址和你的代理规则匹配。如果你用 Nginx 做了统一入口那 baseURL 直接用 /api 就行因为 Nginx 已经把 /api 转发到后端了。7.2 等本地联调稳定之后再上 CI/CD很多团队有一个惯性项目一启动就搭 CI/CDGit push 自动构建、自动部署。这个流程本身没问题但如果在本地联调还没有稳定的时候就上 CI/CD你会面临一个尴尬的局面——每次提交代码流水线跑出来的产物在测试环境里根本跑不通因为环境变量、代理配置、数据库初始化都是乱的。这时候去排查你会发现 CI/CD 的日志里报的错往往不是代码逻辑问题而是环境配置问题。我的建议是先把本地联调彻底打通包括前后端接口交互正常、登录态正常、资源加载正常然后再把 CI/CD 的阶段加上。这样流水线一旦失败你可以大概率先怀疑构建配置或部署环境的问题而不是在代码和配置两团迷雾里找方向。说到这正好呼应一下那些搜“cicd部署前后端项目”的朋友CI/CD 只是自动化工具它不能替代本地联调的完整验证。你可以在流水线里加入一个“联调冒烟测试”的阶段把本地联调的关键请求集成到自动化测试里确保每次构建后核心链路不破。7.3 容器化环境里的本地联调端口映射和网络模式要注意最后聊一下 Docker 化的本地联调。如果你用 Docker 跑后端跑前端那么端口映射一定要理清楚。前后端不在同一个容器网络里通过宿主机端口互相访问时要注意容器里的 localhost 指的是容器自己不是宿主机。所以前端容器里访问后端接口时不能写 http://localhost:8080而应该写宿主机 IP 或者 Docker 内部的服务名。一个常见的 docker-compose 配置长这样services: backend: build: ./backend ports: - 8080:8080 frontend: build: ./frontend ports: - 8081:80 environment: - VITE_API_BASE_URLhttp://backend:8080/api在这个编排里frontend 容器里可以通过 http://backend:8080 访问 backend因为 Docker Compose 会自动建立内部 DNS。但如果你在前端代码里用 VITE_API_BASE_URLhttp://backend:8080/api这个地址是跑在浏览器里的浏览器可不知道 backend 是什么域名它只会把你的请求发给 DNS 服务器然后失败。所以容器化环境里的 API 地址要么写宿主机 IP要么通过 Nginx 反代到宿主机端口真正的转发放到容器内部做。8. 常见问题与排查技巧实录8.1 四个高频坑CORS、404、Cookie、请求体为空把这么多年遇到的本地联调问题归归类最频繁的无非这四类。第一类CORS 报错。最经典的场景是前端页面在 5173 端口后端在 8080前端请求接口后控制台报错 “Access to XMLHttpRequest at http://localhost:8080/api/xxx from origin http://localhost:5173 has been blocked by CORS policy”。排查顺序是先看请求有没有发出Network 面板再看预检请求有没有返回正确的 Access-Control-Allow-Origin最后看后端 CORS 配置里 allow_origins或 allowedOrigins是否包含前端地址。第二类404。这种一般是路径没对齐。前端请求 /api/user后端接口映射是 /user虽然语义上都是用户接口但路径对不上后端框架直接返回 404。排查看 Network 面板里实际请求的 URL再到后端路由表里对比。很多人会忽略代理的 rewrite 规则比如你在 Vite 里把 /api 前缀去掉了但后端接口确实有 /api 前缀这就会导致 404。第三类Cookie 丢失。登录接口通了但后续请求带不上登录态。先看 Set-Cookie 响应头在不在再在浏览器 Application 面板看 Cookie 存没存下来最后检查 SameSite 属性和 withCredentials/credentials 配置。代理模式下要特别注意 Set-Cookie 的 Path 属性如果 Path 不对Cookie 只在特定路径下才会附带上。第四类请求体为空。前端 POST 的数据后端一接收全是 null。大概率是 Content-Type 或者字段名对不上。FastAPI 的 POST 接口如果你声明了一个 Pydantic 模型但前端发的是 form-dataFastAPI 默认按 JSON 解析自然拿不到数据。SpringBoot 同理RequestBody 需要 JSON但前端发的是 URL-encoded就会报错或者得到 null。8.2 一套万能的排查序列省掉一半的瞎猜面对一个联调问题我习惯按照一套固定顺序去排查而不是打开控制台看到红色就瞎猜。第一步打开浏览器的 DevTools切到 Network 面板把 Preserve log 勾上防止页面刷新后日志清了然后复现问题。先看清请求到底发出去没有状态码是多少响应体是什么。这一步能定位掉 70% 的问题。第二步看请求的 URL 是不是预期中的。特别是代理环境下URL 会被改写看最终发给后端的地址是什么。第三步看请求 Headers 和 Payload。Content-Type 是否正确Authorization 有没有带上请求体格式是不是后端期望的 JSON。第四步看响应 Headers。有没有 CORS 相关的头Set-Cookie 是否存在Cache-Control 有没有异常。第五步如果请求根本没到后端检查代理配置和 Nginx 日志如果请求到了但响应不对去后端日志看异常堆栈。这套流程走下来很少有联调问题能藏得住。8.3 那些“没报错但就是不对”的玄学场景有些时候接口通了、状态码 200、数据也返回了但页面上就是不显示。这种问题最磨人因为没有任何报错信息。我遇到过的典型案例后端返回了一个 JSON 数组但前端拿到的却是字符串比如被后端序列化成了字符串前端忘了 JSON.parse或者后端返回的字段名和前端定义的不一致userName vs user_name前端拿到 undefined导致渲染失败。这种时候我的做法是先在后端日志里把返回的 JSON 打印出来再在前端拦截器里把 response.data 打印出来对比两边数据问题瞬间暴露。在联调阶段这两个日志可以临时保留上了生产再关掉。如果你们用了若依这类框架它自带的前端请求包装层已经统一处理了返回体格式遇到“没报错但不对”的情况反而要更仔细地检查返回体的 code、msg、data 结构和预期是否一致。8.4 浏览器缓存带来的联调假象还有一个很容易被忽略的坑浏览器缓存。本地联调时后端改了接口返回前端一刷新发现还是旧数据。这种时候先别怀疑后端没生效按 F12 打开 Network 面板看看请求是不是被标记成了 from disk cache 或者 from memory cache。如果是那就是浏览器认为这个 GET 请求可以走缓存没有真正发到服务器。解决办法很简单前端在 dev 模式下给请求加一个随机参数或者后端在响应头里设置 Cache-Control: no-cache。但注意这只是针对开发环境的权宜之计生产环境反而要好好利用缓存。如果你们上了 CI/CD每次构建后静态资源的文件名会带 hash这天然就规避了缓存问题。9. 联调这件事本质是让团队拥有一套真实的反馈回路说到底本地联调不是把两个服务跑起来那么简单它是在“代码写出来的那一刻”和“代码真正对外提供服务的那一刻”之间建立一条尽量短、尽量真实的反馈回路。这条回路越顺畅团队对代码的信心就越强。后端改一个字段前端马上能看到变化前端调一个参数后端立刻能从日志里确认——这种体验带来的效率提升远远大于省掉某一个配置步骤所带来的“捷径”感。我在实际项目里还有一个压箱底的小经验建议前后端各自在本地预留一个“接口健康检查”接口不需要多复杂返回一个最简单的 JSON 比如 {status:ok}。联调开始前先各自用浏览器或者 curl 打一次这个接口确认两边服务都活着、代理都通着再开始正式联调。这个动作每次只要十秒钟但能省掉后面半小时的瞎折腾。本地联调这张网铺好了后面无论是接测试环境、上容器编排还是跑 CI/CD都会顺很多。所以别嫌配置 CORS、配代理、理接口约定这些事琐碎它们就是前后端协作的地基。地基稳了楼才能盖高。