前后端联调:跨域与 Vite 代理

1. 一个让后端开发者崩溃的现象

本地起前端 http://localhost:5173,Spring Boot 在 http://localhost:8080。前端调接口:

await axios.get('http://localhost:8080/api/notes')

报错

Access to XMLHttpRequest at 'http://localhost:8080/api/notes'
from origin 'http://localhost:5173' has been blocked by CORS policy

后端完全无感——日志里请求根本没到。前端明明能访问后端,浏览器却拦了自己

这就是同源策略(Same-Origin Policy)。理解它是联调一切问题的起点。


2. 同源的定义

同源 = 协议 + 域名 + 端口 都相同。

URL A URL B 是否同源
http://a.com/x http://a.com/y ✅ 同源
http://a.com https://a.com ❌ 协议不同
http://a.com http://b.a.com ❌ 域名不同
http://a.com http://a.com:8080 ❌ 端口不同
http://localhost:5173 http://localhost:8080 ❌ 端口不同

浏览器强制执行同源策略:非同源的 XHR/fetch 请求,响应回来时浏览器拒绝让 JS 读(请求其实发出去了、后端也收到了,只是前端代码拿不到结果)。

Java 对照:类似网关的权限校验——后端放行了,前面这道"安检"仍然可能拦。只是这道安检不在网关,而在用户浏览器里。


3. 为什么要同源策略

防止攻击。如果不限制:

<!-- 你打开恶意网站 evil.com -->
<script>
  // evil.com 上的 JS 去访问你的银行网站
  fetch('https://mybank.com/transfer?to=hacker&amount=1000', {
    credentials: 'include' // 带上你的银行 cookie
  })
</script>

你登录过银行网站,cookie 还在浏览器。恶意网站如果能任意跨域请求,就能用你的身份转账。

同源策略的核心作用限制一个站点上的 JS 不能随便读其他站点的数据


4. 解决跨域的几种思路

有 4 种主流方案,按使用场景:

方案 谁改 适合场景
CORS 后端 正式上线的前后端(最主流)
开发代理(Vite proxy) 前端 开发环境,前后端本地分别跑
Nginx 反向代理 运维/部署 生产环境同域部署
JSONP 后端 已过时,别用

in4vue 的策略:


5. CORS:跨域资源共享

CORS (Cross-Origin Resource Sharing) 是浏览器和服务器约定好的一套"允许跨域"的规则——通过 HTTP 响应头协商。

核心流程

浏览器 → 发跨域请求 → 服务器
浏览器 ← 响应 + Access-Control-Allow-Origin: https://你的前端.com ← 服务器
浏览器: "服务器允许这个源访问,放行"

服务器响应里没有对应的 CORS 头,浏览器就拦住。

5.1 简单请求 vs 预检请求

简单请求(直接发,服务器响应带 CORS 头即可):

预检请求(Preflight)(浏览器先发 OPTIONS 问服务器):

浏览器 → OPTIONS /api/note → 服务器 (预检,问"我能发 DELETE 吗")
浏览器 ← 200 + Access-Control-Allow-Methods: DELETE ← 服务器 (行)
浏览器 → DELETE /api/note → 服务器 (真正的请求)

实战里 99% 是预检请求——带 JSON + 自定义 header(Authorization)就触发。

5.2 Spring Boot 配 CORS

方式 A:全局

@Configuration
public class WebConfig implements WebMvcConfigurer {
    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/**")
            .allowedOrigins("http://localhost:5173", "https://in4vue.pages.dev")
            .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
            .allowedHeaders("*")
            .allowCredentials(true) // 允许带 Cookie
            .maxAge(3600); // 预检缓存 1 小时
    }
}

方式 B:Spring Security(如果在用)

http.cors(cors -> cors.configurationSource(corsConfigurationSource()));

@Bean
CorsConfigurationSource corsConfigurationSource() {
    CorsConfiguration config = new CorsConfiguration();
    config.setAllowedOrigins(List.of("http://localhost:5173"));
    config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
    config.setAllowedHeaders(List.of("*"));
    config.setAllowCredentials(true);
    UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
    source.registerCorsConfiguration("/**", config);
    return source;
}

坑点

5.3 核心响应头

响应头 含义
Access-Control-Allow-Origin 允许哪些源(* 或具体域名)
Access-Control-Allow-Methods 允许的 HTTP 方法
Access-Control-Allow-Headers 允许的自定义 header
Access-Control-Allow-Credentials 允许带 Cookie(true / false)
Access-Control-Max-Age 预检结果缓存多久
Access-Control-Expose-Headers 暴露给 JS 的响应头(默认只能读常见的)

6. Vite 代理:开发环境的首选

开发时前后端同时跑在本机但端口不同——不想折腾后端 CORS,就用 Vite 代理。

原理:前端代码里请求 /api/notes → Vite dev server 拦截这个请求 → 转发到 http://localhost:8080/notes浏览器看到的是同域,完全没有跨域问题。

6.1 配置

// vite.config.ts
export default defineConfig({
  server: {
    port: 5173,
    proxy: {
      '/api': {
        target: 'http://localhost:8080',
        changeOrigin: true,
        rewrite: (path) => path.replace(/^\/api/, ''),
      },
    },
  },
})

参数解释

6.2 不改路径的情况

如果后端本身就以 /api 开头:

'/api': {
  target: 'http://localhost:8080',
  changeOrigin: true,
  // 不要 rewrite,保留 /api 前缀
}

前端请求 /api/notes → 代理到 http://localhost:8080/api/notes

6.3 多个后端

一个前端连多个后端:

proxy: {
  '/api': { target: 'http://localhost:8080', changeOrigin: true },
  '/ai': { target: 'http://localhost:8787', changeOrigin: true }, // AI 代理
  '/ws': {
    target: 'ws://localhost:8080',
    ws: true, // WebSocket
    changeOrigin: true,
  },
},

6.4 前端代码这样写

// src/api/request.ts
const service = axios.create({
  baseURL: '/api', // 注意:不是 http://localhost:8080
  // ...
})

开发时走 Vite 代理,生产时这个 /api 也对(见后面 Nginx 章节)。一套代码两套环境通用,这才是 Vite 代理的精髓。

6.5 调试代理

代理出问题看不到日志?加 configure

'/api': {
  target: 'http://localhost:8080',
  changeOrigin: true,
  configure: (proxy) => {
    proxy.on('proxyReq', (proxyReq, req) => {
      console.log('[proxy]', req.method, req.url, '→', proxyReq.path)
    })
    proxy.on('error', (err, req) => {
      console.error('[proxy error]', req.url, err.message)
    })
  },
}

7. 生产环境:Nginx 反向代理

in4vue 的生产部署:前端放 Cloudflare Pages / Nginx,后端在另一台机器。最佳实践是同域部署,避免跨域:

用户浏览器 → https://in4vue.example.com/
             ├─ /             → Nginx 返静态文件(前端)
             ├─ /api/*         → Nginx 反代到 http://backend:8080/
             └─ /ws            → Nginx 反代 WebSocket

7.1 Nginx 配置

server {
    listen 443 ssl http2;
    server_name in4vue.example.com;

    ssl_certificate /etc/ssl/in4vue.crt;
    ssl_certificate_key /etc/ssl/in4vue.key;

    # 前端静态文件
    root /var/www/in4vue/dist;
    index index.html;

    # SPA 路由回退(必须)
    location / {
        try_files $uri $uri/ /index.html;
    }

    # 后端 API 反向代理
    location /api/ {
        proxy_pass http://localhost:8080/;  # 注意末尾斜杠,会吃掉 /api 前缀
        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;
    }

    # WebSocket
    location /ws {
        proxy_pass http://localhost:8080;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_read_timeout 86400s;
    }

    # 静态资源长缓存
    location /assets/ {
        expires 1y;
        add_header Cache-Control "public, immutable";
    }
}

7.2 proxy_pass 斜杠的坑

# A: 末尾有斜杠
location /api/ {
  proxy_pass http://backend:8080/;
}
# /api/notes → http://backend:8080/notes  (吃掉 /api)

# B: 末尾无斜杠
location /api/ {
  proxy_pass http://backend:8080;
}
# /api/notes → http://backend:8080/api/notes  (保留 /api)

选哪个:看后端接口是否自带 /api 前缀。Spring 项目一般配 server.servlet.context-path=/api,用方式 B;没配就用方式 A。

7.3 Cloudflare Pages 的反代

in4vue 目前部在 Cloudflare Pages。做反代有两种:

A. Edge Function(已有 edge/ 目录就是干这个):

// edge/functions/api/[[path]].ts (Cloudflare Pages Functions)
export async function onRequest(context: any) {
  const url = new URL(context.request.url)
  const target = 'https://backend.example.com' + url.pathname.replace('/api', '')
  return fetch(target, context.request)
}

B. _routes.json:声明哪些路径走 Function、哪些走静态:

{
  "version": 1,
  "include": ["/api/*", "/ai/*"],
  "exclude": []
}

Java 对照:Cloudflare Pages Function = Spring 的过滤器/拦截器 —— 在请求路由到真实业务前动手脚。


8. Cookie 跨域的坑

Authorization: Bearer xxx 这种走 header 的 token 不涉及 cookie 跨域。但项目用 httpOnly Cookie 存 token,会遇到:

8.1 前端要求带 cookie

// axios
const service = axios.create({
  baseURL: '/api',
  withCredentials: true, // 跨域时带上 cookie
})

// fetch
fetch('/api/user', { credentials: 'include' })

8.2 后端 CORS 配合

config.setAllowCredentials(true);
config.setAllowedOrigins(List.of("https://in4vue.pages.dev")); // 不能是 *

8.3 Cookie 本身的属性

Set-Cookie: token=abc;
  Path=/;
  HttpOnly;               ← JS 读不到,防 XSS
  Secure;                 ← 只走 HTTPS
  SameSite=None;          ← 跨站可带(配合 Secure 才生效)

SameSite 是关键

前后端不同域时,Cookie 必须 SameSite=None; Secure,否则浏览器不发。

同域部署就没这麻烦——这也是为什么生产推荐 Nginx 反代同域。


9. 前后端联调清单

新搭前后端对接,按这个顺序排查:

1. 能 ping 通后端吗?
   [后端日志能看到请求] → 进下一步
   [后端日志看不到] → 前端请求根本没出去(路径错/代理错)

2. 响应状态码对吗?
   [200] → 进 CORS / 数据格式
   [404] → 路径不对
   [401 / 403] → 鉴权
   [500] → 后端异常
   [0 / (failed)] → 网络错误或 CORS 预检失败

3. 浏览器报 CORS 错?
   → 后端 CORS 配置缺失或域名没列
   → 或者用 Vite proxy 绕过

4. 能拿到数据但字段不对?
   → 接口字段命名约定(驼峰还是下划线)
   → 日期格式、枚举值

5. 能跑但偶发失败?
   → 网络问题 → axios 重试
   → 后端偶发 5xx → 找后端
   → 用户端慢 → 加超时和友好提示

10. Network 标签的实战用法

Chrome DevTools → Network —— 联调时要一直开着

关键列

查错姿势

  1. 失败的请求点开 → Headers 看请求头、Payload 看请求体、Response 看响应
  2. Preview 自动格式化 JSON
  3. Timing 看耗时分布(DNS / Connect / Wait / Download)
  4. Copy → Copy as cURL —— 把请求复制成 curl 命令,直接给后端复现

11. Mock:后端没写完的过渡方案

联调最烦"后端接口没写好前端只能干等"。几种 Mock 方案:

11.1 前端自己 mock(最快)

// src/api/note.ts
const USE_MOCK = import.meta.env.VITE_USE_MOCK === 'true'

export const noteApi = {
  page: USE_MOCK
    ? async () => ({ list: [...mockNotes], total: 100 })
    : (params) => request.get('/note/page', { params }),
}

优点:零依赖 缺点:switch 散在业务代码里,上线前要清理

11.2 Mock Service Worker (MSW)

拦截浏览器层面的请求,不污染业务代码:

pnpm add -D msw
// src/mocks/handlers.ts
import { http, HttpResponse } from 'msw'

export const handlers = [
  http.get('/api/note/page', () => {
    return HttpResponse.json({ list: [...], total: 100 })
  }),
]

开发模式启动 Service Worker,业务代码原样发请求——MSW 拦下来返假数据。

11.3 独立 mock 服务

起个 json-servermockjs

pnpm dlx json-server db.json --port 8080

配合 Vite proxy 指向它,完全模拟真实后端。

in4vue 的实战选择:开发早期用 MSW(本地无依赖),接口稳定后关掉走真后端。


12. 常见坑点

现象 原因 解法
CORS preflight Access-Control-Allow-Origin header is empty 后端没配 CORS 或 OPTIONS 被拦 后端加 CORS 配置,确保 OPTIONS 返 2xx
Cookie 跨域不发 SameSite 或 Secure 问题 SameSite=None; Secure,或同域部署
代理配了还是 404 路径 rewrite 错了 console.log 代理后的 path 排查
本地跨域 OK 上线不行 生产没配 Nginx 反代 /api 反代或后端加正式 CORS
接口超时但后端已经返回 axios 默认 0ms = 不限时,但某些代理层超时 timeout: 10000
文件下载 Content-Disposition 读不到 没在 CORS 暴露这个 header Access-Control-Expose-Headers: Content-Disposition
POST 请求变 OPTIONS,真请求没发 预检失败 看 OPTIONS 响应,多半是 Allow-Headers 少了 Authorization

13. 三个经典场景完整例子

13.1 本地开发(前后端分别跑)

// vite.config.ts
server: {
  proxy: {
    '/api': { target: 'http://localhost:8080', changeOrigin: true },
  },
}

// src/api/request.ts
baseURL: '/api'

后端不用配 CORS,因为走代理后浏览器看到的是同域。

13.2 生产同域部署(推荐)

前端构建产物和后端在同一个域名下(不同路径)。Nginx 反代。前端 baseURL: '/api' 不变,后端也不用配 CORS。

13.3 前端和后端不同域(跨域正经部署)

frontend.example.com + api.example.com。后端必须配 CORS,前端 baseURL: 'https://api.example.com'


14. 心智模型

前端请求接口的本质:
  浏览器 ──HTTP──> 某个 URL

"跨域"只是浏览器的一条规则:
  "你从 A 站访问 B 站数据,我帮用户把把关"

绕开这条规则的三条路:
  1. 让 B 站(后端)说"允许 A 站来访"  → CORS
  2. 让 A 站的请求走同一个"门卫"      → Nginx / Vite proxy (反代)
  3. 把前后端部到同一个域名下         → 没有跨域问题

生产推荐: 反代 + 同域
开发推荐: Vite proxy

小练习

  1. in4vue 的 vite.config.ts 已经配了 proxy,本地跑起来,开 DevTools 看请求路径
  2. 故意改错 target 看代理失败是什么样
  3. 假设有个 Spring Boot 后端 localhost:8080/notes,配 CORS 允许 5173 访问
  4. 把 axios 的 baseURL 临时改成 http://localhost:8080,不走代理,观察 CORS 报错
  5. 写一个 Nginx 配置,前端静态 + /api 反代的完整示例

延伸阅读