必须搞懂的跨域解决方案

622 阅读16分钟

核心总结:
跨域是浏览器的同源策略限制,协议、域名、端口任何一个不同就算跨域。本质是浏览器的安全机制,防止恶意网站窃取数据。解决方案主要是代理、CORS、JSONP,实际项目中开发用代理、生产用nginx最常见。

什么是跨域:

1. 同源策略的定义
浏览器规定,协议、域名、端口三者完全相同才算同源,否则就是跨域。

// 当前页面:http://www.example.com:80/page.html

// 同源
http://www.example.com:80/api/users  ✓

// 跨域情况
https://www.example.com:80/api       ✗ 协议不同(http vs https)
http://api.example.com:80/users      ✗ 域名不同(www vs api)
http://www.example.com:8080/users    ✗ 端口不同(80 vs 8080)

2. 跨域限制的内容

  • Ajax请求无法发送(XMLHttpRequest、fetch)
  • 无法读取Cookie、LocalStorage、IndexedDB
  • 无法操作iframe的DOM

3. 不受跨域限制的

  • <script>标签加载JS
  • <link>标签加载CSS
  • <img>标签加载图片
  • <video><audio>标签
  • <form>表单提交(但拿不到返回结果)

4. 为什么需要同源策略
防止恶意网站通过脚本读取其他网站的敏感数据。

比如你登录了银行网站,如果没有同源策略,恶意网站的JS可以直接读取银行网站的cookie、发起转账请求,这就很危险。

如何解决跨域:

方案1:开发环境代理(最常用)

本地开发时,前端代码运行在localhost:5173,后端在localhost:8080,这就跨域了。

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

原理:前端请求发到Vite开发服务器,服务器再转发给后端。浏览器看到的是同源请求localhost:5173/api/users,实际Vite服务器帮你请求了localhost:8080/users

**注意:**这只在开发环境有效,build后的生产代码没有这个代理服务器。

方案2:生产环境Nginx反向代理

生产环境最主流的方案,前后端部署在同一个域名下。

server {
  listen 80;
  server_name www.example.com;
  
  # 前端静态文件
  location / {
    root /var/www/html;
    index index.html;
    try_files $uri $uri/ /index.html;
  }
  
  # API请求代理到后端
  location /api/ {
    proxy_pass http://backend-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;
  }
}

用户访问:

  • www.example.com/ → 前端页面
  • www.example.com/api/users → 后端接口

浏览器看来都是同一个域名,不跨域。

方案3:后端设置CORS(需要后端配合)

CORS(Cross-Origin Resource Sharing)跨域资源共享,后端设置响应头允许跨域。

// Node.js示例
app.use((req, res, next) => {
  // 允许哪个域名访问
  res.header('Access-Control-Allow-Origin', 'http://localhost:5173');
  // 允许的请求方法
  res.header('Access-Control-Allow-Methods', 'GET,POST,PUT,DELETE,OPTIONS');
  // 允许的请求头
  res.header('Access-Control-Allow-Headers', 'Content-Type,Authorization');
  // 允许携带cookie
  res.header('Access-Control-Allow-Credentials', 'true');
  
  // 处理预检请求
  if (req.method === 'OPTIONS') {
    return res.status(200).end();
  }
  next();
});

CORS分类:

简单请求(直接发送)

  • 方法:GET、POST、HEAD
  • Content-Type:text/plain、multipart/form-data、application/x-www-form-urlencoded
  • 无自定义请求头

非简单请求(先预检)

  • PUT、DELETE等方法
  • Content-Type: application/json
  • 自定义请求头如Authorization

非简单请求会先发OPTIONS预检请求,询问服务器是否允许,服务器同意后再发真正的请求。

// 预检请求
OPTIONS /api/users
Origin: http://localhost:5173
Access-Control-Request-Method: POST
Access-Control-Request-Headers: Content-Type

// 服务器响应
Access-Control-Allow-Origin: http://localhost:5173
Access-Control-Allow-Methods: POST
Access-Control-Allow-Headers: Content-Type
Access-Control-Max-Age: 86400  // 预检结果缓存24小时

方案4:JSONP(已淘汰)

利用<script>标签不受跨域限制的特性。

// 前端
function handleData(data) {
  console.log(data);
}

const script = document.createElement('script');
script.src = 'http://api.com/data?callback=handleData';
document.body.appendChild(script);

// 后端返回
handleData({name: 'test', age: 18});

缺点:

  • 只支持GET请求
  • 不安全,容易被XSS攻击
  • 需要后端配合
  • 现在基本不用了,都用CORS代替

方案5:postMessage(跨窗口通信)

不同源的iframe、window.open之间通信。

// 父页面 http://parent.com
const iframe = document.getElementById('child');
iframe.contentWindow.postMessage({type: 'greeting', data: 'hello'}, 'http://child.com');

// 子页面 http://child.com
window.addEventListener('message', (event) => {
  // 验证来源
  if (event.origin !== 'http://parent.com') return;
  
  console.log(event.data); // {type: 'greeting', data: 'hello'}
  
  // 回复消息
  event.source.postMessage({type: 'reply', data: 'hi'}, event.origin);
});

用于微前端、iframe嵌套等场景。

方案6:WebSocket(全双工通信)

WebSocket协议不受同源策略限制。

const ws = new WebSocket('ws://api.example.com');
ws.onmessage = (event) => {
  console.log(event.data);
};

适合需要实时通信的场景,如聊天、推送。

实际项目中的选择:

我的标准方案:

  • 开发环境:Vite/Webpack代理
  • 生产环境:Nginx反向代理
  • 特殊情况:第三方API用后端转发,避免暴露key

具体流程:

// 1. 环境变量配置
// .env.development
VITE_API_BASE=/api

// .env.production
VITE_API_BASE=/api

// 2. axios配置
import axios from 'axios';
const instance = axios.create({
  baseURL: import.meta.env.VITE_API_BASE,
  timeout: 5000,
  withCredentials: true // 携带cookie
});

// 3. 开发环境vite代理
// vite.config.js
proxy: {
  '/api': {
    target: 'http://localhost:8080',
    changeOrigin: true
  }
}

// 4. 生产环境nginx配置
location /api/ {
  proxy_pass http://backend:8080/;
}

常见坑和注意事项:

坑1:代理只在开发环境有效
很多人配完代理就以为搞定了,结果打包上线还是跨域。生产环境必须配nginx或让后端开CORS。

坑2:cookie跨域携带问题

// 前端需要设置
fetch('/api', {
  credentials: 'include'
});

// 后端CORS配置
Access-Control-Allow-Credentials: true
Access-Control-Allow-Origin: 具体域名(不能用*)

坑3:CORS的*通配符限制
如果需要携带cookie,Access-Control-Allow-Origin不能设置为*,必须是具体域名。

坑4:预检请求被拦截
非简单请求会先发OPTIONS,如果后端没处理好会报错。后端需要:

if (req.method === 'OPTIONS') {
  res.status(200).end();
  return;
}

坑5:本地hosts配置导致的问题
有时候改了hosts文件,域名变了,忘记改配置,也会跨域。

为什么不推荐直接用CORS:

  • 开发环境用CORS不安全,容易把开发配置带到生产
  • 后端配置Access-Control-Allow-Origin: *有安全风险
  • 代理方案前端可控,不依赖后端配合

我的实践经验:

大部分项目都是前后端分离部署,我的方案是:

  1. 本地开发用Vite代理到后端开发环境或本地后端
  2. 测试/生产环境统一用nginx,前端和API同域名不同路径
  3. 第三方API调用在后端做一层转发,避免暴露key和跨域问题

跨域本质不是技术难题,是部署架构问题。关键是和后端、运维沟通好,统一部署方案。不要想着前端单方面解决,一定要配合后端和nginx配置。

跨域的前置知识:同源策略

关于跨域是什么,我们这里先不做介绍,我们先介绍下跨域的前置知识(同源策略)。同源策略是浏览器中非常重要的安全策略,用于限制不同源的文档或它加载的脚本,对其他文档的访问,帮助阻拦恶意文档,减少可能被攻击的媒介。

同源的定义

判断两个URL是否同源,主要判断是协议、主机、端口号,三者是否一致,只有这三者都相同,才是同源。

image.png

  • 同源的例子

image.png

  • 不同源的例子

image.png

IE浏览器中同源策略的差异

IE浏览器中的同源策略和其他浏览器中的同源策略主要有以下两个差异点:

  1. 授信范围:IE浏览器认为如果两个URL是高度互信的域名,如公司域名,则不受同源策略的限制。
  2. 端口:IE浏览器未将端口号纳入到同源策略的检查中,因此即使端口号不同,只要主机和协议相同,也是属于同源的。

跨域网络访问的类型

同源策略控制不同源之间的交互,这些交互可以分为以下三类:

跨域写操作

跨域写操作一般是允许的,例如链接(links)、重定向以及表单提交。

跨域读操作

一般是不被允许的,但是我们可以通过内嵌资源来巧妙地进行读取访问。

跨域资源嵌入

这种类型一般是被允许地,主要有以下实例:

  • script标签
  • link标签
  • img标签
  • video和audio播放地多媒体资源
  • 通过<object><embed><applet>嵌入的插件。
  • 通过@font-face引入的字体。
  • 通过<iframe>载入的任何资源。

跨域请求有没有发送到服务器端?

跨域请求实际上已经发送到了服务器,并且客户端也接收到了返回的消息,然而浏览器在接收消息后发现这个信息违反了同源策略且没有被允许跨域,所以在解析该消息的时候会报错。

同源策略限制哪些,不限制哪些?

限制以下内容:

一般为跨域读操作。

  • Ajax请求。
  • Cookie、LoaclStorage。
  • DOM对象。

不限制以下内容:

不限制的内容主要是上文的跨域资源嵌入部分。

跨域解决方案

方案一:JSONP

核心思路:利用html中的script标签不受同源策略的限制来进行跨域,在客户端脚本中定义好处理的函数,然后通过请求参数传递给服务器端,服务器端进行字符串拼接后返回调用该函数。

  • 客户端 image.png
  • 服务器端(express) image.png

CodeSandBox在线演示

优缺点

  • 优点:兼容IE。
  • 缺点:仅支持get方法,且需要服务器端进行协同。由于是script标签,所以读不到ajax那么精确的状态,不知道状态码是什么,也不知道响应头是什么。

JSONP带来的安全风险

使用JSONP跨域可能会带来JSONP劫持的问题,这个问题属于CSRF攻击范畴,当某网站通过JSONP的方式来实现跨域并传递给用户认证的敏感信息后,攻击者可以构造恶意的JSONP调用页面,诱导被攻击者访问来达到截取用户敏感信息的目的。

关于JSONP劫持漏洞攻击可以看下面的这篇文章。

JSONP 劫持原理与挖掘方法

JSONP漏洞利用的原理

下面以一个实例为例介绍什么是JSONP劫持。

  1. 假设用户已经在网站B上注册并进行了登录,网站B包含了用户的id,name,email等敏感信息。
  2. 此时有一个恶意网站A,用户通过浏览器向网站A发送URL请求。
  3. 网站A向用户返回响应界面,这个响应的页面中包含了一个JS函数和向网站B请求的script标签。(script标签中的内容如下所示)

image.png

  1. 用户收到响应后,解析JS代码,将回调函数作为参数向网站B发送请求。
  2. 网站B收到请求后,解析请求的URL,以JSON格式生成请求需要的数据,将封装的包含用户信息的JSON数据作为回调函数返回给浏览器。
  3. 网站B数据返回后,浏览器自动执行callback函数对步骤4返回的JSON格式的数据进行处理,此时就可能将数据传回给网站A的服务器,这样网站A利用网站B的JSONP漏洞便获得了用户在网站B注册的信息。

方案二:CORS

CORS(跨域资源共享)会允许服务端来指定哪些主机可以从这个服务端加载资源。CORS通过HTTP头的形式告诉浏览器哪些不同来源的客户端可以访问本站的资源的。实现CORS跨域的关键在服务端,只要服务端设置了Access-Control-Allow-Origin就可以开启CORS,客户端发送请求时请求头加origin,服务器返回的响应头加Access-control-allow-origin,浏览器以此来判断是否允许跨域。该属性可以设置哪些域名可以访问服务器,如果设置为星号则表示所有资源都可以访问服务器资源。CORS进行跨域的时候会将请求分为简单请求和预检请求。

1. 简单请求

只要同时满足下面的两个条件,就可以判断为简单请求,简单请求只需在请求时加上origin字段,响应时包含Access-Control-Allow-Origin字段,浏览器以此来判断是否允许跨域。

  1. 请求方法是以下三种方法之一:
  • HEAD
  • GET
  • POST
  1. HTTP的头信息Request Headers不超出以下几种字段
  • Accept
  • Accept-Language
  • Content-Language
  • Content-Type: (只限于下面的三个值)
    • text/plain
    • multipart/form-data
    • application/x-www-form-urlencoded

2. 预检请求

不满足简单请求条件的,则判断为需要进行预检请求,浏览器首先使用OPTIONS方法发起一个预检请求到服务器,以获知服务器是否允许该实际请求。预检请求的使用可以避免未获得许可的调用方,调用了有副作用的API对服务器端的数据进行修改。

预检请求包含的字段
  • OPTIONS
  • Origin
  • Access-Control-Request-Method
  • Access-Control-Request-Headers
预检请求的响应字段

image.png

上面的字段告知服务器,实际请求将采用什么方法,将包含什么样的请求头。预检请求完成之后,将发送实际请求。

CORS跨域的优缺点

  • 优点
    • CORS通信与同源的AJAX通信没有差别,代码容易维护。
    • 支持所有类型的HTTP请求
  • 缺点
    • 存在兼容性问题,特别是IE10以下的浏览器

    • 第一次发送非简单请求时会多一次预检请求(第一次之后,服务器对预检请求的响应有一个有效时间)

CORS跨域实例

  • 客户端 image.png

  • 服务器端 image.png

注意:CORS请求如果需要携带cookie信息的时候,需要将withCredentials置为true。

JSONP和CORS比较

  • JSONP只支持GET请求,CORS支持所有类型的HTTP请求。
  • JSONP比CORS的兼容性好。

方案三:Nginx

在介绍什么是Nginx跨域之前,我们首先来系统性的介绍下什么是反向代理。

反向代理和正向代理

反向代理指的是隐藏了真实的服务端,举个例子,当我们请求百度的时候,背后可能有成千上万台服务器为我们服务,但具体是哪一台,我们并不知道,我们只需要知道我们的反向代理服务器是百度即可,反向代理会帮我们把请求转发到真实的服务器那里去,Nginx就是一种反向代理服务器。顺便提一下,正向代理指的是隐藏了真实的客户端,比如我们如果想要通过代理访问谷歌,此时的代理就是正向代理,因为此时隐藏了真实的客户端。

Nginx跨域的原理

Nginx作为反向代理服务器,就是把客户端的HTTP请求转发到另一个服务器上,代理服务器访问另一个服务器是不存在跨域问题的,nginx代理服务器获取到数据后,再转发给客户端即可实现跨域。

  • 浏览器角度

从浏览器的角度看,就像是访问同源服务器上的URL。

  • 服务器角度

从服务器角度看,并不知道这个请求是来自代理服务器的,简单来说是nginx服务器欺骗了浏览器,让浏览器认为是同源调用,又通过重写url,欺骗了真实的服务器,让他以为这个HTTP请求是直接来自用户浏览器的,这样就解决了跨域问题。

方案四:iframe + postMessage

postMessage是HTML5新增的一项功能,该方法提供了一种受控机制来规避同源策略,可以实现跨源通信。

实现原理

父页面中通过iframe标签引入子页面,主页面加载完毕之后,通过postMessage方法向子页面发送消息,同时监听来自子页面的消息。子页面通过window.addEventListener方法监听来自父页面的消息,并通过top.postMessage将消息传递给父页面,从而实现跨域通信。

下面是实现的效果图

image.png

方案五:window.name + iframe

跨域原理

核心原理:在一个窗口window的生命周期内,窗口载入的所有页面共享一个window.name,每个页面对window.name都有读写权限,window.name持久存在一个窗口载入过的所有页面中。。

实现流程

假如A页面要跨域访问B页面,下面介绍下如何实现。

  1. 在页面A中创建一个iframe,将其src指向要跨域的页面B,此时页面B中的window.name中存储着数据,A页面中的iframe此时也能够拿到这个数据。
  2. 在A页面第一次调用onload事件的时候,将其src属性改为本地域的一个代理html,这个文件可以是一个空文件,修改src属性后,onload会被再次触发,但是此时window.name还是可以获取到的。
  3. 获取数据后,为了防止其他iframe获取这个数据,需要销毁这个iframe。

在线实现

方案六:WebSocket跨域

WebSocket协议是HTML5一种新的协议,实现了浏览器与服务器的全双工通信,同时允许跨域通讯,允许服务器端主动向浏览器端发送消息。WebSocket和HTTP都是应用层协议,都基于TCP协议,但是WebSocket在建立的时候需要借助HTTP协议,连接建立好之后,客户端和服务器端之间的双向通信就与HTTP无关了。

WebSocket跨域原理

客户端可以通过new WebSocket创建一个socket实例,然后通过onopen方法中的send方法将要发送的数据传到后端,也可以利用onmessage方法来监听服务端发送过来的消息,服务端是首先引入ws模块,然后通过new WebSocket.Server来监听一个端口,然后利用message接收数据,利用send向客户端发送数据。

实例分析

客户端是建立在5500端口的,服务端是在5000端口的,这样可以测试能否跨域。

  • 客户端实现
<script>
    let socket = new WebSocket('ws://localhost:5000');
    socket.onopen = function() {
        socket.send('服务端你好');
    }
    socket.onmessage = function(e) {
        console.log('服务端发过来的消息为:',e.data);
    }
</script>
  • 服务端实现
let express = require('express');

let app = express();

let webSocket = require('ws');

let wss = new webSocket.Server({port:5000});

wss.on('connection',function(ws) {
    ws.on('message',function(data) {
        console.log('客户端发过来的消息为:',data);
        ws.send('客户端你好');
    })
})

console.log("http://localhost:5000");

方案七:NodeJs中间件代理跨域

node中间件实现跨域,原理大致和nginx跨域类似,都是通过一个代理服务器,实现数据的转发,类似的中间件有http-proxy-middleware。

服务端8000端口上有客户端想要获取的数据,客户端在5500端口上,代理服务器在3000端口上。

  • 客户端代码
<script>
    fetch('http://localhost:3000/api', {
        method: 'GET'
    }).then(value => { console.log(value); })
</script>
  • 代理服务器端代码
const express = require('express');
const { createProxyMiddleware } = require('http-proxy-middleware');
const app = express();

app.use('/api', createProxyMiddleware({
    // 客户端想要访问的跨域目标地址
    target: "http://localhost:8000",
    // 可以让参数是域名
    changeOrigin: true,
    pathRewrite: {
        '^/api': '',
    }
}))

// 主动监听3000端口
app.listen(3000);
console.log('代理服务器运行在:http://localhost:3000');
  • 目标跨域页面代码
<body>
    <h1>这是用户目标想要跨域的页面</h1>
</body>

其余跨域方案

image.png

参考资料