前端跨域完全指南:从 JSONP 到 Nginx 反向代理,一次性彻底搞懂

17 阅读5分钟

前端跨域完全指南:从 JSONP 到 Nginx 反向代理,一次性彻底搞懂

同源策略是浏览器最坚实的护城河,而跨域方案就是一道道精心设计的城门。

前言

前后端分离开发早已成为标配。前端跑 localhost:5173,后端跑 localhost:3000,端口不同,跨域就来了。再加上调用第三方 API、对接合作商接口,跨域问题几乎是每个前端开发者的必修课。

这篇文章从「为什么会有跨域」出发,一次性梳理 JSONP、CORS、WebSocket、postMessage、Vite Proxy、Nginx 反向代理六种跨域方案,附带可运行的代码示例,争取做到「看一篇就够了」。


一、同源策略:跨域的根源

同源策略(Same-Origin Policy) 是浏览器最核心的安全机制。所谓「同源」,要求两个 URL 的协议、域名、端口三者完全一致。

当前页面请求目标是否同源原因
http://site.comhttp://site.com/api全相同
http://site.comhttps://site.com协议不同
http://site.comhttp://api.site.com域名不同(子域名也算)
http://site.com:3000http://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 把请求分成了两档。

简单请求,浏览器直接发,不做额外检查。必须同时满足三个条件:

  • 方法是 GETPOSTHEAD 之一
  • 请求头只包含 AcceptAccept-LanguageContent-LanguageContent-Type(且值为 text/plainmultipart/form-dataapplication/x-www-form-urlencoded
  • 没有自定义请求头

大部分日常的 fetch('/api/data') 就是简单请求,浏览器直接发,后端返回带 CORS 头的响应就完事。

复杂请求,会多一次预检(Preflight)。触发条件包括:

  • 用了 PUTDELETEPATCH 等非简单方法
  • 加了自定义请求头(如 AuthorizationX-Custom-Header
  • Content-Typeapplication/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/usershttps://api.example.com/users(去前缀)
  • 没有 //api/usershttps://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 接口
postMessageHTML5 跨窗口通信 APIiframe / 弹窗跨域通信需双方配合、必须验证 origin
Vite Proxy开发环境 Node.js 反向代理本地开发跨域调试仅开发环境
Nginx 反向代理生产环境服务器转发,浏览器无感知线上部署的统一入口需运维配置、内部使用为主

写在后面

跨域不是一个孤立的知识点,它串起了浏览器安全模型、HTTP 协议、前端工程化和部署架构。理解跨域的方案,本质上是在理解「浏览器为什么这样设计」以及「我们如何在不破坏安全的前提下让通信变得可能」。

从 JSONP 这种早期 hack,到 CORS 成为标准,再到反向代理这一运维层的优雅解法,跨域方案的演进也折射了 Web 开发生态从「能用就行」到「工程化、规范化」的成熟过程。

如果你觉得这篇文章有帮助,欢迎点赞收藏,也欢迎在评论区交流你的跨域踩坑经历 👋