做 Vue 项目做到第 49 天正好卡在前后端联调这个坎上。你本地跑着npm run dev接口地址填的是后端同事的http://192.168.x.x:8080一刷新页面浏览器控制台直接给你一行红字Access to XMLHttpRequest at ... from origin http://localhost:5173 has been blocked by CORS policy。这行字就是今天要聊的跨域问题。前端圈子里解决跨域的方案基本就三板斧开发阶段用 Proxy 代理后端配合开 CORS老项目里偶尔还能见到 JSONP。这篇就把这三种方案从原理到配置、从开发到生产全部拆开讲明白顺便把我在真实项目里踩过的坑都列出来。我们先用一句话定位跨域不是后端接口挂了也不是前端代码写错了是浏览器出于安全策略主动拦截了响应。理解这一点后面所有方案就都顺了。1. 先搞懂跨域浏览器到底在拦什么1.1 同源策略不是 bug是安全机制浏览器为什么不让你的 Vue 应用随便请求别的域名的接口因为你访问的是http://localhost:5173后端接口是http://api.example.com这俩的协议、域名、端口任何一个不一样就构成了跨域。浏览器内部有一道叫“同源策略”的墙默认只允许页面请求它自己所在源的资源。如果谁都允许那恶意网站就可以用你的登录态去请求你的银行转账接口了。但同源策略和跨域拦截其实还不是一回事。同源策略规定的是“脚本只能读取同源资源的内容”而跨域拦截是浏览器在发出请求之后、拿到响应之前检查响应头里有没有允许当前源的字段没有就报错。所以你会发现请求其实发出去了后端也执行了只是浏览器不把响应交给你。这就是很多后端同事说“我接口没问题用 Postman 能通”的原因——Postman 没有同源策略限制。理解了这一层你就会明白跨域问题的本质是“浏览器端的响应拦截”不是你服务器拒绝了你。1.2 前端开发里最容易触发跨域的几种场景我整理了一下日常开发中跨域几乎都集中在这三种情况本地开发环境请求远端测试环境接口localhost:5173请求http://10.20.30.40:8080/api静态页面部署在 Nginx 上后端是独立域名的服务https://admin.example.com请求https://api.example.com前后端同域但端口不同http://localhost:8080请求http://localhost:9090第三种最容易让人困惑明明 IP 一样但端口不同依然跨域。我遇到过一个同事开发时前端跑 8080后端跑 9090他硬是查了半天最后才发现端口不同也是跨域。这些场景里要判断“到底是谁来解决”其实就一条主线能改谁就让谁配合。开发环境前端自己配代理最快生产环境后端开 CORS 或者运维配 Nginx 反代最稳JSONP 属于纯前端能打的方案但限制也多。1.3 跨域报错长什么样别被浏览器提示带偏以 Vue 项目最常用的 axios 为例跨域报错一般有两类简单请求报错控制台显示Access to XMLHttpRequest at http://api.example.com/api/user from origin http://localhost:5173 has been blocked by CORS policy: No Access-Control-Allow-Origin header is present on the requested resource.预检请求失败网络面板里先看到一个OPTIONS请求紧接着报Response to preflight request doesnt pass access control check: It does not have HTTP ok status.我见过很多新手把每次跨域报错都发到群里问其实浏览器已经把答案写在里面了——“No Access-Control-Allow-Origin header is present” 说明后端没返回跨域头“preflight request doesnt pass” 说明你的请求触发了预检机制。先学会读报错再谈解决方案。2. 开发环境最常用Vite 的 Proxy 代理2.1 为什么开发阶段首选 proxy说个现实情况现在新开的 Vue 3 项目基本都是 Vite 脚手架。Vite 自带一个开发服务器而开发服务器本身是一个 Node 环境没有浏览器同源策略限制。我们可以让浏览器只请求localhost:5173这个同源地址再由 Vite 开发服务器把请求转发到真正的后端地址然后由开发服务器把响应带回来。整个过程浏览器全程只跟同源打交道跨域问题在服务器到服务器的转发层就被解决了。选 proxy 还有一个现实原因后端接口地址经常变。如果前端代码里到处写死http://192.168.1.100:8080/api后端换台电脑你就得全局替换。用 proxy 的话你只需要改 Vite 配置文件里的一处target代码里的请求路径还是干净的/api/user。我个人经验是开发阶段永远先配 proxy就算后端已经开了 CORS 也一样。因为 proxy 不依赖后端改动本地调试更可控还能顺便解决图片、WebSocket 这些资源的跨域问题。2.2 手把手配置 Vite 的 server.proxy新建一个 Vite 项目后找到根目录的vite.config.js或者vite.config.ts。核心配置是server.proxy我直接给你一个可直接复制的版本import { defineConfig } from vite import vue from vitejs/plugin-vue export default defineConfig({ plugins: [vue()], server: { host: 0.0.0.0, port: 5173, proxy: { // 以 /api 开头的请求统统转发到 target 指向的服务器 /api: { target: http://192.168.1.100:8080, changeOrigin: true, // 路径重写把请求路径里的 /api 去掉 rewrite: (path) path.replace(/^\/api/, ) } } } })这里解释几个关键参数target后端真实服务地址包括协议、IP、端口。填错最常见后端换端口你就要同步改。changeOrigin: true让转发过来的请求头里Host变成 target 的域名。很多后端接口会校验Host不设这一项可能被 Nginx 拦截。rewrite路径重写。如果后端接口是/user/list而前端请求是/api/user/list就需要把/api前缀去掉否则后端看到的是/api/user/list很可能 404。配置好之后前端请求代码里应该这样写// request.js import axios from axios const request axios.create({ baseURL: /api, // 注意这里不是完整的后端地址 timeout: 10000 }) export default request然后在组件里直接request.get(/user/list)实际上浏览器发出的请求是http://localhost:5173/api/user/listVite 开发服务器会把/api/user/list转发到http://192.168.1.100:8080/user/list。你在 Network 面板里是能看到这个真实转发过程的请求好像还是走的同一个域但已经不在浏览器同源策略的管辖范围里了。很多新手在这里有个误区以为设置了 proxy 之后请求地址可以从localhost:5173变成后端地址。其实不是前端代码里不能直接写后端地址必须写成相对路径/api/...或者同源绝对路径否则代理规则根本没机会生效。2.3 vue-cli 老项目的 webpack 代理配置公司里可能还有 Vue 2 vue-cli 的老项目。它们的代理配置放在vue.config.js里写法跟 Vite 类似但是名字不一样叫devServer.proxymodule.exports { devServer: { port: 8080, proxy: { /api: { target: http://192.168.1.100:8080, changeOrigin: true, pathRewrite: { ^/api: } } } } }pathRewrite等价于 Vite 的rewrite写法是对象而不是函数。注意如果你用的代理对象里有一个专门的/匹配规则那会把所有请求都代理出去导致 Vite 热更新和静态资源请求也被转发这属于经典坑我会在后面的排查清单里专门说。2.4 proxy 的翻车现场常见的三个大坑第一个坑热更新/静态资源被代理。proxy配置里如果写的是/而不是/apiVite 会把整个开发服务器流量都交给代理页面会出现加载不出来、样式丢失控制台一堆 404。正确的做法是只代理带特定前缀的接口路径静态资源不要碰。第二个坑pathRewrite 填写错误。很多后端接口路径本身就带/api前缀那就别重写。如果你的后端路由是http://server:8080/api/user前端也请求/api/user那么 rewrite 应该写成(path) path什么都不做而不是强行去掉/api。我在项目里见过好几次前端配了 rewrite 把/api去掉结果后端 404查了半天才发现后端路由也有/api前缀。第三个坑局域网设备访问问题。Vite 默认只监听 localhost如果你的页面要在手机或者别的电脑上测试需要在server.host里设置0.0.0.0。不然同事通过你的 IP 访问时接口也会因为无法命中代理而变成真的跨域。3. 后端配合方案CORS 跨域资源共享3.1 CORS 是怎么解决的后端主动开门CORSCross-Origin Resource Sharing的方案思路跟 proxy 完全不同。它不搞转发而是由后端在 HTTP 响应头里加一个“允许访问”的声明。浏览器看到声明里的 origin 跟当前页面匹配就把响应交给前端。最简单的响应头长这样Access-Control-Allow-Origin: http://localhost:5173你也可以写成*表示允许所有来源。但注意如果你需要在请求里带上 CookieAccess-Control-Allow-Origin就不能是*必须指定具体域名同时还要设置Access-Control-Allow-Credentials: true。这个细节我后面会单独讲因为真的是高频坑。3.2 后端开 CORS 的几种姿势以 Java Spring Boot 项目为例后端开 CORS 有三种常见姿势我按推荐程度排个序方案一全局 CORS 配置最推荐生产可控新建一个配置类import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; import org.springframework.web.filter.CorsFilter; Configuration public class CorsConfig { Bean public CorsFilter corsFilter() { CorsConfiguration config new CorsConfiguration(); config.addAllowedOrigin(http://localhost:5173); config.addAllowedOrigin(https://admin.example.com); config.addAllowedHeader(*); config.addAllowedMethod(*); config.setAllowCredentials(true); UrlBasedCorsConfigurationSource source new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration(/**, config); return new CorsFilter(source); } }方案二CrossOrigin注解适合小范围快速开在 Controller 类或方法上加CrossOrigin(origins http://localhost:5173) GetMapping(/user/list) public Result userList() { return Result.success(userService.list()); }这个注解的好处是粒度细可以只给某个接口开。坏处是分散而且如果你用了 Spring Security预检请求可能被拦截。方案三拦截器或过滤器手动设置响应头适合那种你没法改动现有框架代码、只能加一层过滤器的场景Component public class CorsFilter implements Filter { Override public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) { HttpServletResponse response (HttpServletResponse) res; response.setHeader(Access-Control-Allow-Origin, req.getHeader(Origin)); response.setHeader(Access-Control-Allow-Methods, GET, POST, PUT, DELETE, OPTIONS); response.setHeader(Access-Control-Allow-Headers, Content-Type, Authorization); response.setHeader(Access-Control-Allow-Credentials, true); if (OPTIONS.equalsIgnoreCase(((HttpServletRequest) req).getMethod())) { response.setStatus(HttpServletResponse.SC_OK); } else { chain.doFilter(req, res); } } }方案三手动从请求头拿Origin再原样写回响应头这样比写死单个域名更灵活支持多个来源。3.3 预检请求 OPTIONS 的真实处理细节先解释一下为什么有时候有 OPTIONS 请求有时候没有。浏览器把跨域请求分成两类简单请求方法只能是 GET、POST、HEAD且不能包含自定义 HeaderContent-Type 只能是text/plain、multipart/form-data、application/x-www-form-urlencoded之一。这类请求不需要预检。复杂请求只要满足“方法不是简单方法”或者“带了自定义 Header 比如 Authorization、X-Token”或者“Content-Type 是 application/json”浏览器就会先发一个 OPTIONS 预检请求问后端“你允许我这么干吗”后端返回的响应头里有允许的字段浏览器才发真实请求。日常开发里 Vue 项目用 axios 默认的Content-Type是application/json而且你几乎一定会带自定义 Header所以 99% 的情况都会触发 OPTIONS 预检。后端如果对 OPTIONS 请求处理不对你看到的真实请求永远发不出去只剩一个OPTIONS返回 404/403。让我给你一个非常典型的教训后端用了 Spring Security但没有放行 OPTIONS 请求。结果前端请求直接报Response to preflight request doesnt pass access control check: It does not have HTTP ok status.原因就是安全框架把预检请求当成普通请求做了鉴权返回了 401。解决办法是让安全框架放行OPTIONS请求或者在后端过滤器里对 OPTIONS 直接返回 200。3.4 带 Cookie 和自定义 Header 时的 CORS 配置坑这里说两个我实际线上踩过的坑。坑一Credentials 与 Allow-Origin 不能共用*如果你做登录功能axios 开启了withCredentials: true那后端响应头里Access-Control-Allow-Origin还写成*浏览器会直接拦截报错。因为带凭证的跨域请求不允许*必须指定完整 origin。我在某个项目里就是因为图省事写了*结果登录接口一调试就炸后来改成动态获取当前请求头里的 Origin 才解决。坑二自定义 Header 必须在 Allow-Headers 里声明前端传了一个X-User-Token的自定义头后端Access-Control-Allow-Headers里没有这个字段预检请求直接失败。建议后端 Authorization、Content-Type、X-Token 之类的都放行用*也可以但要注意老版本浏览器兼容性。4. 历史遗留方案JSONP 与前端实战4.1 JSONP 的原理套路用 script 标签偷渡JSONPJSON with Padding是跨域解决方案里的“老古董”它利用的是script 标签不受同源策略限制这一特性。简单来说你动态创建一个script标签把它的src指向一个跨域接口并带上一个回调函数名参数后端返回的内容是一段 JS 代码这段代码会调用你定义好的回调函数并把数据作为参数传进去。流程大概是这样前端定义全局函数window.handleResult function(data) { console.log(data) }动态创建script srchttp://api.example.com/user?callbackhandleResult并插入页面后端返回内容不是 JSON而是handleResult({id: 1, name: 张三})浏览器把这段内容当 JS 执行于是handleResult被调用你拿到了数据这个方案纯前端就能解决跨域不需要后端做任何 CORS 配置只需后端支持返回 callback 包裹的数据。很多老系统比如某些 PHP 接口只支持 JSONP不接受 CORS你就只能用它。4.2 手写一个 JSONP 封装不依赖第三方库Vue 项目里用 axios 是没有 JSONP 能力的需要单独封装。我直接给你一个可以直接复制进utils/jsonp.js的版本// utils/jsonp.js export default function jsonp({ url, params {}, callbackName callback, timeout 10000 }) { return new Promise((resolve, reject) { // 生成唯一的回调函数名避免多个请求相互覆盖 const callbackFnName jsonp_ Date.now() Math.floor(Math.random() * 1000) // 保存超时定时器 let timer null // 成功回调 window[callbackFnName] (data) { clearTimeout(timer) delete window[callbackFnName] document.body.removeChild(script) resolve(data) } // 失败回调script 加载失败时触发 const handleError (err) { clearTimeout(timer) delete window[callbackFnName] document.body.removeChild(script) reject(err) } // 拼 URL 参数 let queryString Object.keys(params) .map((key) encodeURIComponent(key) encodeURIComponent(params[key])) .join() // 拼接完整 src const src url (url.indexOf(?) -1 ? ? : ) queryString callbackName callbackFnName // 创建 script 标签 const script document.createElement(script) script.src src script.onerror handleError // 设置超时 timer setTimeout(() { handleError(new Error(JSONP request timeout)) }, timeout) document.body.appendChild(script) }) }用法import jsonp from /utils/jsonp jsonp({ url: http://api.example.com/user, params: { id: 1 } }).then((data) { console.log(用户数据, data) })这里面有个容易被忽略的细节script.onerror只对“标签加载失败”生效如果后端返回了一个 HTTP 错误状态但内容还是能被当作 JS 执行那么回调还是会走成功逻辑。所以 JSONP 的错误处理天生不完善你得配合超时机制兜底。4.3 JSONP 的局限为什么现在不常用JSONP 能工作的前提是后端接口主动支持 callback 参数并且返回application/javascript类型的响应。如果后端只是普通 JSON 接口JSONP 就无能为力。另外它只能用 GET 请求不能发 POST不能带自定义 Header数据量一大还有性能问题。更麻烦的是随着浏览器安全策略升级现代项目已经很少使用 JSONP改用 CORS 才是主流。但有一个场景 JSONP 依然有优势你调用的是别人家的公共服务接口你没法要求对方改 CORS 配置而对方历史原因只支持 JSONP。比如一些地图服务的接口老版本 API 就会提供 JSONP 参数。所以作为一个前端哪怕不常用也建议把这段封装存着。5. 生产环境下的跨域部署方案5.1 用 Nginx 反向代理收口跨域开发环境解决了项目要上线。如果后端还是不肯开 CORS或者上线后域名结构又变了你往往会在 Nginx 层做反向代理。思路跟 Vite proxy 一样让所有/api请求都先打到 Nginx由 Nginx 转发给后端服务。一个典型的 Nginx 配置片段server { listen 80; server_name admin.example.com; location / { root /usr/share/nginx/html; index index.html; # 单页应用路由处理找不到文件时回退到 index.html try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://internal-api-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; proxy_set_header X-Forwarded-Proto $scheme; } }注意proxy_pass后面的路径要带不带斜杠很有意思如果写成http://internal-api-server:8080/带斜杠会把/api/user里的/api部分去掉转发为/user如果写成http://internal-api-server:8080不带斜杠会保留完整路径/api/user。这个细节跟 Vite 的 rewrite 是同一个问题配错了就 404。我在实际项目里就吃过这个亏前端请求/api/user/list后端接口是/api/user/list我只想让它原样转发。结果 Nginx 写对了但后端网关又做了一次路径裁剪导致多了一层/api。最后统一约定网关前的 Nginx 保留路径网关内再处理前缀。跨域问题排查起来最怕这种链路层传递的路径不一致。5.2 生产环境 CORS 的安全建议生产环境跟开发环境最大的区别是你不能再无脑把Access-Control-Allow-Origin设成*。如果管理后台只允许特定域名访问就明确写白名单如果允许所有第三方站点调用你的开放接口再用*并避免携带 Cookie 凭证。我用 Spring Boot 举例一个相对安全的做法是写一个动态 Origin 校验的 CORS 配置Bean public CorsFilter corsFilter() { CorsConfiguration config new CorsConfiguration(); // 从配置中心或环境变量里读取允许来源列表 ListString allowedOrigins Arrays.asList( https://admin.example.com, https://www.example.com ); allowedOrigins.forEach(config::addAllowedOrigin); config.addAllowedHeader(*); config.addAllowedMethod(*); config.setAllowCredentials(true); UrlBasedCorsConfigurationSource source new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration(/**, config); return new CorsFilter(source); }如果允许的来源非常多可以写一个CorsConfigurationSource实现动态从数据库读取。总之生产环境的原则是开放越少越安全不要为了省事把整个接口都裸奔。5.3 用 Fiddler 调试跨域请求的实战姿势说到调试我在排查跨域问题时习惯用抓包工具确认“请求到底有没有发出去”。Fiddler 是 Windows 上很好用的抓包工具它也支持自定义代理规则来模拟请求转发。简单做法是在 Fiddler 的 QuickExec 命令行里输入!passhost localhost:5173然后设置断点查看请求头。比如我用 Fiddler 抓到一个请求发现响应头里确实没有Access-Control-Allow-Origin就能判断问题在后端如果响应头里有这个字段但不对就能判断是来源不匹配。Fiddler 还能在 AutoResponder 里添加一条规则把后端某个接口模拟成假数据返回方便前端在联调前就能开发。我记得有次后端接口崩了我就是用 Fiddler 把返回数据改成 mock JSON前端照常开发一点不耽误。不过现在更多前端已经用浏览器自带的 Network 面板 Vite 代理了Fiddler 更多是后端或者需要抓 HTTPS 解密时用。这里提一句如果你要抓 HTTPS 请求需要在 Fiddler 里安装并信任它的证书否则抓到的全是加密乱码。5.4 方案选型什么时候用 proxy什么时候用 CORS什么时候用 JSONP我用一张自己的思路地图总结一下前端开发阶段能配 proxy 就配 proxy。唯一的缺点是只在开发服务器里生效打包后就不管用了。后端可配合时首选 CORS因为它是标准方案生产、开发、各种环境都适用。但要注意预检、Credentials 等细节。前端不能动后端配置且接口支持 callback 参数只能用 JSONP。这个方案是兜底中的兜底。项目要上线且前后端分开部署优先在 Nginx 层做反向代理这也是最稳定的生产方案还能顺带做负载均衡、HTTPS 终结。其实还有一种“同源化”的思路如果前端静态资源和后端接口都在同一个域名下通过路径区分那就根本不存在跨域。很多单体项目把 Vue 打包后的文件扔进 Spring Boot 的static目录请求全部走同源也是常见做法。我在一个后台管理项目里就这么干过打包上传到springboot/src/main/resources/static直接用同一个端口访问页面和接口跨域问题直接从根上消失。不过这个方案适合内部系统别硬套在前后端完全分离的大型项目上。6. 常见问题与排查技巧实录6.1 跨域问题排查清单按顺序来碰到跨域报错我建议你按这个顺序自查看 Network 面板确认请求发出没有。如果请求状态是(failed)或者CORS error说明被浏览器拦截了如果 404/500那是后端问题。确认前端请求 URL 对不对。打开 Network 面板看Request URL别只看控制台报错。很多 404 是因为 rewrite 把路径改错了。看响应头里有没有Access-Control-Allow-Origin。没有 → 后端 CORS 没配有但不匹配 → 后端来源白名单写错了。看是不是预检请求失败。找到 OPTIONS 请求看它的状态码和响应头。如果用了代理确认代理规则是否覆盖了当前请求路径。比如你请求的是/foo但代理只配了/api那就等于没代理。这个清单基本能解决 90% 的跨域问题。6.2 高频问题速查表现象常见原因解决办法请求发出响应头没有 Access-Control-Allow-Origin后端没开 CORS后端配置 CORS或前端用代理转发报错 No Access-Control-Allow-Origin header is present后端未正确处理跨域检查后端 CORS 过滤器是否生效预检 OPTIONS 返回 404/403/401后端安全框架未放行 OPTIONS放行 OPTIONS 请求或对 OPTIONS 直接返回 200404 且带有 /api 前缀Nginx/Vite rewrite 重写错误调整 rewrite/proxy_pass 路径规则设置了 withCredentials 后接口仍失败Allow-Origin 是 *不允许带凭证改为明确来源并设置 Allow-Credentials: true代理下页面静态资源丢失代理把/全代理了代理规则精确到/api不要代理根路径本地正常、部署到服务器后跨域生产环境没走 Vite 代理后端开 CORS或用 Nginx 反代请求带自定义 Header 时预检失败Allow-Headers 缺少该 Header后端在 Allow-Headers 加上对应字段这张表我建议你收藏遇到问题先对着查一遍比到处发截图问人要快得多。6.3 独家排查小经验再分享几个不外传的调试心得打开浏览器 Network 面板时勾选Preserve log跨域报错后页面可能会刷新日志会丢。用 Chrome 无痕模式调试 CORS因为浏览器插件有时候会注入额外 Header 干扰预检。后端开了 CORS 但还是报错可以试试直接 curl 接口看响应头curl -I http://api.example.com/api/user看返回里有没有Access-Control-Allow-Origin字段有就没有问题没有就说明后端配置没生效。排查是不是浏览器缓存导致的在 Network 面板勾选Disable cache然后 CtrlShiftR 强制刷新。如果是代理配置问题可以在vite.config.js里加一段日志直接打印转发的目标地址方便确认是不是命中规则proxy: { /api: { target: http://192.168.1.100:8080, changeOrigin: true, configure: (proxy) { proxy.on(proxyReq, (proxyReq, req) { console.log(正在代理到:, proxyReq.host proxyReq.path) }) } } }这个小工具曾帮我瞬间定位出同事配错了 IP 的问题。结尾聊聊我第 49 天的体会其实写到这里我已经把第 49 天的内容讲完了。但最后我还想说点题外话。跨域这个问题的奇妙之处在于它不是一个“报错修一下”的单一问题而是一个贯穿前后端协作理念的缩影。我见过很多前端同学遇到跨域第一反应就是把报错截图甩给后端也不管后端到底能不能改、改了有没有副作用也见过后端同学一口咬定“Postman 没问题一定是前端的问题”。实际上跨域是双方共同的责任越早建立“前端代理处理开发、后端 CORS 处理生产、Nginx 收口整体”的统一思路后面联调就越顺。在我做的这个 Vue 进阶阶段里第 49 天正好是个分水岭从这里往后你不再只是写页面的“模板仔”而是开始理解浏览器安全机制、HTTP 头、网络转发链路这些底层知识。把这些搞明白去面试的时候被问到“Vue 项目跨域怎么处理”你就能把 Proxy、CORS、JSONP 分别从原理到配置再到生产部署讲得清清楚楚。这才是 Vue 生态整合阶段真正要沉淀下来的东西。