前端跨域完全指南:从 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 文件:
<script src="https://cdn.example.com/library.js"></script>
浏览器不会拦。这本来是设计上的合理豁免(CDN 加载总得允许吧),但 JSONP 把它变成了一个数据传输通道。
2.2 完整实现
前端代码:
function jsonp({ url, params, callback }) {
return new Promise((resolve, reject) => {
// ① 创建 script 标签(目前还是空壳)
let script = document.createElement('script')
// ② 在全局挂载回调函数,后端返回的代码会调用它
window[callback] = function(data) {
resolve(data)
document.body.removeChild(script) // 清理现场
}
// ③ 把 callback 参数合并进 query string
params = { ...params, callback }
let arrs = []
for (let key in params) {
arrs.push(`${key}=${params[key]}`)
}
// ④ 设置 src,拼接完整 URL
script.src = `${url}?${arrs.join('&')}`
// ⑤ 插入 DOM,浏览器开始下载 → 执行
document.body.appendChild(script)
})
}
// 使用
jsonp({
url: 'http://localhost:3000/say',
params: { wd: 'HelloWorld' },
callback: 'show'
}).then(data => {
console.log(data) // { id: 1, username: 'admin' }
})
后端代码(Node.js):
const http = require('http')
http.createServer((req, res) => {
if (req.url.startsWith('/say')) {
const url = new URL(req.url, `http://${req.headers.host}`)
const callback = url.searchParams.get('callback')
// Content-Type 是 text/javascript,不是 application/json!
res.writeHead(200, { 'Content-Type': 'text/javascript' })
const data = { id: 1, username: 'admin' }
// 关键:把 JSON 数据包在函数调用里返回
res.end(`${callback}(${JSON.stringify(data)})`)
} else {
res.writeHead(404)
res.end('404 Not Found')
}
}).listen(3000)
2.3 请求全链路
前端调用 jsonp({ callback: 'show' })
↓
window.show = function(data) { resolve(data) } // 挂上全局函数
↓
script.src = "http://localhost:3000/say?wd=HelloWorld&callback=show"
↓
appendChild → 浏览器发送 GET 请求
↓
后端解析 callback 参数,返回 JS 代码:
show({"id":1,"username":"admin"})
↓
浏览器执行这段 JS → 调用 window.show(data) → resolve
↓
.then(data => console.log(data)) 拿到数据
「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 核心响应头
后端在响应里加上这几个头,跨域通信就打开了:
Access-Control-Allow-Origin: https://my-site.com # 允许哪些域名
Access-Control-Allow-Methods: GET, POST, PUT, DELETE # 允许哪些方法
Access-Control-Allow-Headers: Content-Type, Authorization # 允许哪些自定义头
Access-Control-Allow-Credentials: true # 是否允许携带 Cookie
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 方法打一个探路请求:
OPTIONS /api/users HTTP/1.1
Origin: http://localhost:5173
Access-Control-Request-Method: DELETE
Access-Control-Request-Headers: Authorization
后端收到后,返回一组 CORS 头表明态度:
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: http://localhost:5173
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization
浏览器拿到 204 响应,逐条检查:域名在不在白名单?方法允不允许?请求头支不支持?全通过了,才发出真正的 DELETE 请求。
注意:预检是浏览器自动完成的,前端代码不需要任何特殊处理。你写好 fetch,浏览器在幕后完成两段式交互。
四、WebSocket:绕过同源的天生通行证
WebSocket 是一个独立的协议(ws:// / wss://),不归同源策略管。它天然可以跨域通信。
// 先通过 HTTP 协议握手
new WebSocket('ws://chat.example.com')
// 服务器返回 101 状态码,协议升级
// HTTP/1.1 101 Switching Protocols
// 此后双方基于消息机制进行全双工通信
ws.onmessage = (event) => {
console.log('收到消息:', event.data)
}
ws.send('Hello Server')
WebSocket 建立连接时依赖 HTTP 完成一次握手(Upgrade 请求),握手成功后协议从 HTTP 升级为 WebSocket,之后就不再走 HTTP 那一套了。由于它不是 HTTP 协议,同源策略的约束对它无效。
适用场景很明确:聊天、实时协作、游戏、金融行情推送等需要服务端主动推送的场景。如果只是普通的 RESTful 接口调用,没必要为跨域而上 WebSocket。
五、postMessage:窗口之间的秘密通道
HTML5 提供的 postMessage API,让不同源的窗口、iframe、页面之间可以互相传递消息,即使它们域名完全不同。
典型场景是第三方支付的流程:
主站页面(shop.com)
↓ postMessage 传订单详情
第三方支付 iframe(pay.com)
↓ 用户完成支付
↓ postMessage 回传支付结果
主站页面收到消息,更新订单状态
基本用法:
// 主站 → iframe
const iframe = document.querySelector('#paymentFrame')
iframe.contentWindow.postMessage(
{ orderId: '123', amount: 99 },
'https://pay.com' // 明确指定目标域名,防止信息泄露
)
// iframe → 主站
window.parent.postMessage(
{ status: 'success', orderId: '123' },
'https://shop.com'
)
// 接收端
window.addEventListener('message', (event) => {
// 一定要验证来源!
if (event.origin !== 'https://shop.com') return
console.log('收到消息:', event.data)
})
postMessage 的精髓在于 origin 校验:发送时指定目标域名,接收时验证来源域名。这保证消息不会被恶意页面截获或伪造。如果省略 origin 校验直接处理消息,相当于把「安全通道」变成了「任意门」。
需要注意的是,<iframe> 标签本身会带来性能开销(每个 iframe 是一个独立的渲染上下文),现代方案更推荐用弹窗重定向等更轻量的方式处理支付这类场景。
六、反向代理:藏在服务器背后的聪明方案
前面五种方案都是在正面解决跨域:要么让后端配合(CORS),要么利用协议的漏洞或豁免(JSONP、WebSocket、postMessage)。反向代理的思路完全不同:既然跨域是浏览器的限制,那就不要让浏览器知道它跨域了。
核心逻辑:浏览器自始至终只跟一个地址通信,代理服务器作为中间人,替浏览器去跟真正的后端交涉,再把结果带回来。
6.1 Vite 开发环境代理
本地开发时,Vue/React 项目跑在 localhost:5173,后端跑在 localhost:3000。配置文件长这样:
// vite.config.js
export default defineConfig({
server: {
proxy: {
'/api': {
target: 'http://localhost:3000', // 真正后端地址
changeOrigin: true, // 修改 Host 头,避免后端识别错误
rewrite: (path) => path.replace(/^\/api/, '') // 去掉 /api 前缀
}
}
}
})
请求链路:
前端代码: fetch('/api/users')
↓ 浏览器补全为
浏览器发出: http://localhost:5173/api/users
↓ 同源(页面的 origin 也是 localhost:5173),浏览器放行
Vite dev server 收到,匹配到 /api 代理规则
↓ 用 Node.js(非浏览器!)向 target 发请求
Node.js 发出: http://localhost:3000/users
↓ 服务器对服务器通信,没有同源策略约束
后端返回数据
↓ Vite 把响应传给浏览器
浏览器: 拿到了数据
关键就在这里:Vite dev server 是一个 Node.js 进程,它用自己的 HTTP 模块去请求后端。Node.js 发 HTTP 请求时,没有同源策略的约束。 浏览器自始至终只和 localhost:5173 说过话,它完全不知道后端 localhost:3000 的存在。
6.2 Nginx 生产环境代理
生产环境的思路完全一致,只是执行者从 Vite dev server 换成了 Nginx:
server {
listen 80;
server_name my-domain.com;
# 前端静态资源
location / {
root /usr/share/nginx/html;
index index.html;
}
# 反向代理 API 请求
location /api/ {
proxy_pass https://api.example.com/; # 注意末尾的 / 会自动去掉 /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;
}
}
proxy_pass 末尾的 / 是一个容易踩的坑:
- 有
/:/api/users→https://api.example.com/users(去前缀) - 没有
/:/api/users→https://api.example.com/api/users(保留前缀)
这个行为等价于 Vite proxy 里的 rewrite,只是语法不同。
生产环境流量路径:
用户浏览器 → Nginx(80端口) → 静态资源走 / → 返回 index.html
→ API 请求走 /api/ → proxy_pass 转发到后端 → 返回数据
浏览器只知道 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 开发生态从「能用就行」到「工程化、规范化」的成熟过程。
如果你觉得这篇文章有帮助,欢迎点赞收藏,也欢迎在评论区交流你的跨域踩坑经历 👋