前端跨域完全指南:从 JSONP 到 Nginx 反向代理,一次性彻底搞懂
同源策略是浏览器最坚实的护城河,而跨域方案就是一道道精心设计的城门。
前言
前后端分离开发早已成为标配。前端跑 localhost:5173,后端跑 localhost:3000,端口不同,跨域就来了。再加上调用第三方 API、对接合作商接口,跨域问题几乎是每个前端开发者的必修课。
这篇文章从「为什么会有跨域」出发,一次性梳理 JSONP、CORS、WebSocket、postMessage、Vite Proxy、Nginx 反向代理六种跨域方案,附带可运行的代码示例,争取做到「看一篇就够了」。
一、同源策略:跨域的根源
同源策略(Same-Origin Policy) 是浏览器最核心的安全机制。所谓「同源」,要求两个 URL 的协议、域名、端口三者完全一致。
| 当前页面 | 请求目标 | 是否同源 | 原因 |
|---|---|---|---|
| http://site.com | http://site.com/api | ✅ | 全相同 |
| http://site.com | https://site.com | ❌ | 协议不同 |
| http://site.com | http://api.site.com | ❌ | 域名不同(子域名也算) |
| http://site.com:3000 | http://site.com:4000 | ❌ | 端口不同 |
它到底在防什么?
设想你登录了网银,Cookie 里存着登录凭证。如果不小心点开了一个恶意网站,这个网站偷偷向网银的 API 发请求,浏览器如果放行,对方就能用你的 Cookie 执行转账操作。同源策略就是堵住这个口子:它限制非同源的网页读取资源、操作 DOM、发起受限的 HTTP 通信。
一句话定位:同源策略保护的是用户数据,防止恶意网站冒充用户访问受信站点。跨域问题本质上是「如何在安全的前提下合理突破这个限制」。
二、JSONP:上古时代的智慧热修复
JSONP(JSON with Padding)诞生于 2005 年前后,比 CORS 标准早了整整十年。它的核心思路极其巧妙:正面走不通,就走侧面。
2.1 漏洞在哪里?
同源策略有一个天然的「盲区」:<script> 标签的 src 属性不受同源策略约束。你可以随意引入任何域名的 JS 文件:
1<script src="https://cdn.example.com/library.js"></script> 2
浏览器不会拦。这本来是设计上的合理豁免(CDN 加载总得允许吧),但 JSONP 把它变成了一个数据传输通道。
2.2 完整实现
前端代码:
1function jsonp({ url, params, callback }) { 2 return new Promise((resolve, reject) => { 3 // ① 创建 script 标签(目前还是空壳) 4 let script = document.createElement('script') 5 6 // ② 在全局挂载回调函数,后端返回的代码会调用它 7 window[callback] = function(data) { 8 resolve(data) 9 document.body.removeChild(script) // 清理现场 10 } 11 12 // ③ 把 callback 参数合并进 query string 13 params = { ...params, callback } 14 let arrs = [] 15 for (let key in params) { 16 arrs.push(`${key}=${params[key]}`) 17 } 18 19 // ④ 设置 src,拼接完整 URL 20 script.src = [`${url}?${arrs.join('&')}`](https://xplanc.org/primers/document/zh/10.Bash/90.%E5%B8%AE%E5%8A%A9%E6%89%8B%E5%86%8C/EX.join.md) 21 22 // ⑤ 插入 DOM,浏览器开始下载 → 执行 23 document.body.appendChild(script) 24 }) 25} 26 27// 使用 28jsonp({ 29 url: 'http://localhost:3000/say', 30 params: { wd: 'HelloWorld' }, 31 callback: 'show' 32}).then(data => { 33 console.log(data) // { id: 1, username: 'admin' } 34}) 35
后端代码(Node.js):
1const http = require('http') 2 3http.createServer((req, res) => { 4 if (req.url.startsWith('/say')) { 5 const url = new URL(req.url, [`http://${req.headers.host}`](http://${req.headers.host})) 6 const callback = url.searchParams.get('callback') 7 8 // Content-Type 是 text/javascript,不是 application/json! 9 res.writeHead(200, { 'Content-Type': 'text/javascript' }) 10 11 const data = { id: 1, username: 'admin' } 12 13 // 关键:把 JSON 数据包在函数调用里返回 14 res.end([`${callback}(${JSON.stringify(data)})`](https://xplanc.org/primers/document/zh/03.HTML/EX.HTML%20%E5%85%83%E7%B4%A0/EX.data.md)) 15 } else { 16 res.writeHead(404) 17 res.end('404 Not Found') 18 } 19}).listen(3000) 20
2.3 请求全链路
1前端调用 jsonp({ callback: 'show' }) 2 ↓ 3window.show = function(data) { resolve(data) } // 挂上全局函数 4 ↓ 5script.src = "http://localhost:3000/say?wd=HelloWorld&callback=show" 6 ↓ 7appendChild → 浏览器发送 GET 请求 8 ↓ 9后端解析 callback 参数,返回 JS 代码: 10 show({"id":1,"username":"admin"}) 11 ↓ 12浏览器执行这段 JS → 调用 window.show(data) → resolve 13 ↓ 14.then(data => console.log(data)) 拿到数据 15
「Padding」的含义就在这里:后端把 JSON 数据外面包裹了一层函数调用。没有这层壳,浏览器收到裸 JSON {"id":1} 会直接报语法错误——字符串不是合法的 JS 语句。
2.4 JSONP 的致命伤
| 缺陷 | 原因 |
|---|---|
| 仅支持 GET | <script> 标签只能发 GET,规范决定,没法改 |
| XSS 风险 | 加载的是外部 JS,如果对方被黑,返回的代码会直接在页面执行 |
| 阻塞渲染 | <script> 标签默认同步加载,执行期间页面暂停渲染 |
| 无错误处理 | 后端挂了,script 只会静默失败,不像 XHR 有 onerror |
结论:JSONP 本质是 hack,用 <script> 的设计豁免来绕过同源策略的限制。现代项目中已基本被 CORS 取代,但它作为「用巧劲解决问题」的经典案例,仍然值得理解。
三、CORS:官方的正规解法
CORS(Cross-Origin Resource Sharing,跨域资源共享)是一套基于 HTTP 头的标准化机制。和 JSONP 的「曲线救国」不同,CORS 直接告诉浏览器:「这几个域名是可信的,放行。」
3.1 核心响应头
后端在响应里加上这几个头,跨域通信就打开了:
1Access-Control-Allow-Origin: https://my-site.com # 允许哪些域名 2Access-Control-Allow-Methods: GET, POST, PUT, DELETE # 允许哪些方法 3Access-Control-Allow-Headers: Content-Type, Authorization # 允许哪些自定义头 4Access-Control-Allow-Credentials: true # 是否允许携带 Cookie 5
Access-Control-Allow-Origin 可以设成 * 放行全部,也可以指定白名单。现代后端框架(Express、Koa、Spring、Django 等)都有现成的 CORS 中间件,引入即用。
3.2 简单请求 vs 复杂请求
CORS 把请求分成了两档。
简单请求,浏览器直接发,不做额外检查。必须同时满足三个条件:
- 方法是
GET、POST、HEAD之一 - 请求头只包含
Accept、Accept-Language、Content-Language、Content-Type(且值为text/plain、multipart/form-data、application/x-www-form-urlencoded) - 没有自定义请求头
大部分日常的 fetch('/api/data') 就是简单请求,浏览器直接发,后端返回带 CORS 头的响应就完事。
复杂请求,会多一次预检(Preflight)。触发条件包括:
- 用了
PUT、DELETE、PATCH等非简单方法 - 加了自定义请求头(如
Authorization、X-Custom-Header) Content-Type是application/json
3.3 预检请求的完整过程
预检是一个先问再行动的机制。浏览器在真实请求之前,先用 OPTIONS 方法打一个探路请求:
1OPTIONS /api/users HTTP/1.1 2Origin: http://localhost:5173 3Access-Control-Request-Method: DELETE 4Access-Control-Request-Headers: Authorization 5
后端收到后,返回一组 CORS 头表明态度:
1HTTP/1.1 204 No Content 2Access-Control-Allow-Origin: http://localhost:5173 3Access-Control-Allow-Methods: GET, POST, PUT, DELETE 4Access-Control-Allow-Headers: Content-Type, Authorization 5
浏览器拿到 204 响应,逐条检查:域名在不在白名单?方法允不允许?请求头支不支持?全通过了,才发出真正的 DELETE 请求。
注意:预检是浏览器自动完成的,前端代码不需要任何特殊处理。你写好 fetch,浏览器在幕后完成两段式交互。
四、WebSocket:绕过同源的天生通行证
WebSocket 是一个独立的协议(ws:// / wss://),不归同源策略管。它天然可以跨域通信。
1// 先通过 HTTP 协议握手 2new WebSocket('ws://chat.example.com') 3 4// 服务器返回 101 状态码,协议升级 5// HTTP/1.1 101 Switching Protocols 6 7// 此后双方基于消息机制进行全双工通信 8ws.onmessage = (event) => { 9 console.log('收到消息:', event.data) 10} 11ws.send('Hello Server') 12
WebSocket 建立连接时依赖 HTTP 完成一次握手(Upgrade 请求),握手成功后协议从 HTTP 升级为 WebSocket,之后就不再走 HTTP 那一套了。由于它不是 HTTP 协议,同源策略的约束对它无效。
适用场景很明确:聊天、实时协作、游戏、金融行情推送等需要服务端主动推送的场景。如果只是普通的 RESTful 接口调用,没必要为跨域而上 WebSocket。
五、postMessage:窗口之间的秘密通道
HTML5 提供的 postMessage API,让不同源的窗口、iframe、页面之间可以互相传递消息,即使它们域名完全不同。
典型场景是第三方支付的流程:
1主站页面(shop.com) 2 ↓ postMessage 传订单详情 3第三方支付 iframe(pay.com) 4 ↓ 用户完成支付 5 ↓ postMessage 回传支付结果 6主站页面收到消息,更新订单状态 7
基本用法:
1// 主站 → iframe 2const iframe = document.querySelector('#paymentFrame') 3iframe.contentWindow.postMessage( 4 { orderId: '123', amount: 99 }, 5 'https://pay.com' // 明确指定目标域名,防止信息泄露 6) 7 8// iframe → 主站 9window.parent.postMessage( 10 { status: 'success', orderId: '123' }, 11 'https://shop.com' 12) 13 14// 接收端 15window.addEventListener('message', (event) => { 16 // 一定要验证来源! 17 if (event.origin !== 'https://shop.com') return 18 console.log('收到消息:', event.data) 19}) 20
postMessage 的精髓在于 origin 校验:发送时指定目标域名,接收时验证来源域名。这保证消息不会被恶意页面截获或伪造。如果省略 origin 校验直接处理消息,相当于把「安全通道」变成了「任意门」。
需要注意的是,<iframe> 标签本身会带来性能开销(每个 iframe 是一个独立的渲染上下文),现代方案更推荐用弹窗重定向等更轻量的方式处理支付这类场景。
六、反向代理:藏在服务器背后的聪明方案
前面五种方案都是在正面解决跨域:要么让后端配合(CORS),要么利用协议的漏洞或豁免(JSONP、WebSocket、postMessage)。反向代理的思路完全不同:既然跨域是浏览器的限制,那就不要让浏览器知道它跨域了。
核心逻辑:浏览器自始至终只跟一个地址通信,代理服务器作为中间人,替浏览器去跟真正的后端交涉,再把结果带回来。
6.1 Vite 开发环境代理
本地开发时,Vue/React 项目跑在 localhost:5173,后端跑在 localhost:3000。配置文件长这样:
1// vite.config.js 2export default defineConfig({ 3 server: { 4 proxy: { 5 '/api': { 6 target: 'http://localhost:3000', // 真正后端地址 7 changeOrigin: true, // 修改 Host 头,避免后端识别错误 8 rewrite: (path) => path.replace(/^\/api/, '') // 去掉 /api 前缀 9 } 10 } 11 } 12}) 13
请求链路:
1前端代码: fetch('/api/users') 2 ↓ 浏览器补全为 3浏览器发出: http://localhost:5173/api/users 4 ↓ 同源(页面的 origin 也是 localhost:5173),浏览器放行 5Vite dev server 收到,匹配到 /api 代理规则 6 ↓ 用 Node.js(非浏览器!)向 target 发请求 7Node.js 发出: http://localhost:3000/users 8 ↓ 服务器对服务器通信,没有同源策略约束 9后端返回数据 10 ↓ Vite 把响应传给浏览器 11浏览器: 拿到了数据 12
关键就在这里:Vite dev server 是一个 Node.js 进程,它用自己的 HTTP 模块去请求后端。Node.js 发 HTTP 请求时,没有同源策略的约束。 浏览器自始至终只和 localhost:5173 说过话,它完全不知道后端 localhost:3000 的存在。
6.2 Nginx 生产环境代理
生产环境的思路完全一致,只是执行者从 Vite dev server 换成了 Nginx:
1server { 2 listen 80; 3 server_name my-domain.com; 4 5 # 前端静态资源 6 location / { 7 root /usr/share/nginx/html; 8 index index.html; 9 } 10 11 # 反向代理 API 请求 12 location /api/ { 13 proxy_pass https://api.example.com/; # 注意末尾的 / 会自动去掉 /api 前缀 14 proxy_set_header Host $host; 15 proxy_set_header X-Real-IP $remote_addr; 16 proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 17 proxy_set_header X-Forwarded-Proto $scheme; 18 } 19} 20
proxy_pass 末尾的 / 是一个容易踩的坑:
- 有
/:/api/users→https://api.example.com/users(去前缀) - 没有
/:/api/users→https://api.example.com/api/users(保留前缀)
这个行为等价于 Vite proxy 里的 rewrite,只是语法不同。
生产环境流量路径:
1用户浏览器 → Nginx(80端口) → 静态资源走 / → 返回 index.html 2 → API 请求走 /api/ → proxy_pass 转发到后端 → 返回数据 3
浏览器只知道 my-domain.com 一个地址,前端部署和后端 API 被 Nginx 统一收敛到了同一个域名、同一个端口下。对浏览器来说这就不是跨域,对架构师来说这实现了关注点分离。
6.3 什么时候用 CORS,什么时候用反向代理?
| 反向代理 | CORS |
|---|---|
| 同一团队控制前后端和部署 | 第三方公开 API / 合作商接口 |
| 浏览器始终同源,零感知 | 需要接口提供方配合设置响应头 |
| 配置在运维层,前端零改动 | 白名单灵活控制访问权限 |
| 适合部门内部、公司内部系统 | 适合集团子公司、外部合作伙伴 |
两者不互斥,很多项目同时使用:内部接口走 Nginx 代理,外部第三方调 CORS。
七、六种方案一张表
| 方案 | 原理 | 适用场景 | 局限性 |
|---|---|---|---|
| JSONP | 利用 <script> 标签 src 不受同源限制 | 历史遗留项目、极简 GET 请求 | 仅 GET、XSS 风险、无错误处理 |
| CORS | 后端设置 HTTP 响应头声明白名单 | 现代 Web 应用的标准方案 | 需要后端配合、复杂请求多一次 OPTIONS |
| WebSocket | 独立协议,不归同源策略管 | 实时通信(聊天、游戏、行情) | 不适合普通 RESTful 接口 |
| postMessage | HTML5 跨窗口通信 API | iframe / 弹窗跨域通信 | 需双方配合、必须验证 origin |
| Vite Proxy | 开发环境 Node.js 反向代理 | 本地开发跨域调试 | 仅开发环境 |
| Nginx 反向代理 | 生产环境服务器转发,浏览器无感知 | 线上部署的统一入口 | 需运维配置、内部使用为主 |
写在后面
跨域不是一个孤立的知识点,它串起了浏览器安全模型、HTTP 协议、前端工程化和部署架构。理解跨域的方案,本质上是在理解「浏览器为什么这样设计」以及「我们如何在不破坏安全的前提下让通信变得可能」。
从 JSONP 这种早期 hack,到 CORS 成为标准,再到反向代理这一运维层的优雅解法,跨域方案的演进也折射了 Web 开发生态从「能用就行」到「工程化、规范化」的成熟过程。
如果你觉得这篇文章有帮助,欢迎点赞收藏,也欢迎在评论区交流你的跨域踩坑经历 👋