一、 万恶之源?安全之盾?—— 什么是同源策略
在深入探讨解决方案之前,我们必须先理解问题本身。
同源策略 (SOP) 是浏览器提供的一个至关重要的安全功能。它规定,一个源(Origin)的文档或脚本,不能与另一个源的资源进行交互。换句话说,浏览器限制了来自不同源的“读”操作。
1. "源" (Origin) 的定义
什么才算“同源”?URL 由多个部分组成,但决定是否同源的只有三个:
协议 (Protocol):例如
http和https域名 (Domain):例如
www.google.com和api.google.com端口 (Port):例如
80和443
只有当这三者完全相同时,两个 URL 才被认为是同源的。
我们来看一个表格,假设当前页面的源是 http://www.example.com:8080/index.html:
2. 同源策略为何存在?
想象一个场景:你正在浏览器的一个标签页中登录着你的网上银行。同时,你在另一个标签页中打开了一个恶意网站。如果没有同源策略,这个恶意网站的脚本就可以向你的网上银行API发送请求(例如 fetch('https://mybank.com/api/transfer?to=hacker&amount=10000')),因为你的浏览器中存有银行的登录凭证(Cookies),这个请求会被认为是合法的,你的资金就会被盗走。
同源策略正是为了防止这种跨站请求伪造 (CSRF) 等安全风险而生的。它保证了你的浏览器在不同站点间的隔离性,构建了 Web 安全的基石。
3. 同源策略的限制范围
同源策略主要限制了以下三种行为:
无法读取非同源网页的 Cookie、LocalStorage 和 IndexedDB。
无法接触非同源网页的 DOM(例如,在一个页面中通过
<iframe>嵌入了非同源页面,父页面无法操作子页面的 DOM)。无法发送 AJAX 请求到非同源的服务器(这是我们最常遇到的问题)。
但值得注意的是,跨域请求并非完全无法发出。浏览器实际上已经将请求发送到了服务器,服务器也可能处理了请求并返回了数据。但是,浏览器在接收到响应时,会检查其来源,如果发现是非同源的,并且没有相应的跨域许可(我们稍后会讲到),就会拦截这个响应,不让你的 JavaScript 代码读到它,并在控制台抛出我们熟悉的那个错误。
而有些标签则不受同源策略限制,比如:
<script src="..."></script><img src="..."><link href="..."><iframe src="...">
这些标签加载的资源可以来自任何地方,这也是 CDN (内容分发网络) 能够工作的基本原理。
二、 八仙过海,各显神通 —— 主流跨域解决方案
理解了同源策略后,我们来看看如何“合法”地绕过这堵墙。
方案一:CORS (Cross-Origin Resource Sharing) - 官方标准,现代首选
CORS 是 W3C 的官方标准,是目前解决跨域问题最主流、最强大、最正规的方案。它的核心思想是:让服务器来声明,哪些源的请求是可以被接受的。
CORS 将请求分为两类:简单请求 (Simple Requests) 和 非简单请求 (Preflighted Requests)。
1. 简单请求
满足以下所有条件的即为简单请求:
请求方法是
GET、POST或HEAD之一。HTTP 头信息不超出以下几种字段:
Accept,Accept-Language,Content-Language,Content-Type(且Content-Type的值仅限于application/x-www-form-urlencoded,multipart/form-data,text/plain)。
对于简单请求,浏览器会直接发送请求,并在请求头中加入一个 Origin 字段,表明请求来自哪个源。
JavaScript
// 前端代码 (Fetch API)
fetch('http://api.example.com/data')
.then(response => response.json())
.then(data => console.log(data));
服务器收到请求后,如果 Origin 字段的值在许可范围内,就在响应头中加入一个关键字段:
Access-Control-Allow-Origin: http://www.example.com
或者,允许任何源:
Access-Control-Allow-Origin: *
浏览器看到这个响应头,就知道这个跨域请求是被允许的,从而将数据交给你的 JavaScript。
后端 Node.js (Express) 示例:
JavaScript
const express = require('express');
const app = express();
app.get('/data', (req, res) => {
// 设置允许来自 http://www.example.com 的请求
res.setHeader('Access-Control-Allow-Origin', 'http://www.example.com');
res.json({ message: 'Hello from CORS-enabled server!' });
});
app.listen(3000, () => console.log('API server listening on port 3000'));
2. 非简单请求(预检请求)
不满足简单请求条件的都是非简单请求,例如请求方法是 PUT, DELETE,或者 Content-Type 是 application/json,或者带有自定义的请求头。
对于非简单请求,浏览器会先发送一个“预检”请求 (Preflight Request)。这是一个 OPTIONS 方法的请求,用于向服务器“咨询”后续的实际请求是否安全。
预检请求头会包含以下关键信息:
Origin: 请求源Access-Control-Request-Method: 实际请求将使用的方法 (如PUT)Access-Control-Request-Headers: 实际请求将携带的自定义头 (如X-Custom-Header)
服务器必须正确响应这个 OPTIONS 请求,返回以下响应头,才能让浏览器继续发送实际的请求:
Access-Control-Allow-Origin: 允许的源Access-Control-Allow-Methods: 允许的请求方法 (如GET, POST, PUT, DELETE)Access-Control-Allow-Headers: 允许的请求头Access-Control-Max-Age: 预检请求的有效期(秒),在此期间内无需再发预检请求。
后端 Node.js (Express) 示例(更完整的 CORS 配置):
JavaScript
const express = require('express');
const app = express();
const cors = require('cors'); // 使用流行的 cors 中间件更方便
// 简单的用法
// app.use(cors());
// 精细化配置
app.use(cors({
origin: 'http://www.example.com', // 允许的源
methods: ['GET', 'POST', 'PUT', 'DELETE'], // 允许的方法
allowedHeaders: ['Content-Type', 'Authorization'], // 允许的头
credentials: true // 如果需要携带 cookie
}));
app.put('/update', (req, res) => {
res.json({ message: 'Data updated successfully!' });
});
app.listen(3000, () => console.log('API server listening on port 3000'));
优点:W3C 标准,功能强大,支持所有 HTTP 方法,是现代 Web 开发的基石。
缺点:需要后端配合进行配置,对于不了解的开发者来说,预检请求可能会带来一些困惑。
方案二:JSONP (JSON with Padding) - 古老但巧妙的“黑客”
在 CORS 出现之前,JSONP 是跨域的流行解决方案。它利用了 <script> 标签不受同源策略限制的“漏洞”。
原理:
前端定义一个全局的回调函数(例如
handleResponse)。通过动态创建一个
<script>标签,其src指向后端的 API 地址,并通过 URL 参数将回调函数的名字传给后端(例如?callback=handleResponse)。后端收到请求后,不再返回纯粹的 JSON 数据,而是返回一段调用这个回调函数的 JavaScript 代码,并将 JSON 数据作为参数传入。例如
handleResponse({"name": "Alice", "age": 30})。当
<script>标签加载并执行这段代码时,前端定义好的回调函数就被调用,从而拿到了数据。
前端实现:
JavaScript
// 1. 定义回调函数
function handleResponse(data) {
console.log('Received data:', data);
}
// 2. 创建并插入 script 标签
const script = document.createElement('script');
script.src = 'http://api.example.com/jsonp-data?callback=handleResponse';
document.body.appendChild(script);
// 3. 用完后清理
script.onload = () => {
document.body.removeChild(script);
};
后端 Node.js (Express) 示例:
JavaScript
app.get('/jsonp-data', (req, res) => {
const callbackName = req.query.callback;
const data = { name: 'Alice', age: 30 };
// 返回一段 JS 代码
res.send(`${callbackName}(${JSON.stringify(data)})`);
});
优点:兼容性极好,能支持非常古老的浏览器。
缺点:
只支持 GET 请求,因为
<script>标签只能发送 GET 请求。安全性差,如果提供 JSONP 服务的网站存在恶意代码,会直接在你的页面执行。
错误处理机制不完善。
现在基本已被 CORS 取代。
方案三:代理 (Proxy) - 釜底抽薪,瞒天过海
代理是解决跨域问题最可靠、最灵活的方案之一,尤其是在生产环境中。其核心思想是:让同源的服务器去请求非同源的服务器,然后将结果返回给前端。
由于服务器之间的通信不受浏览器同源策略的限制,这个方法完美地绕过了问题。
1. Nginx 反向代理(生产环境首选)
在生产环境中,通常使用 Nginx 作为 Web 服务器。我们可以配置 Nginx,让它将所有发往特定路径(如 /api)的请求,转发到真正的后端 API 服务器上。
对于前端来说,它请求的是 http://www.example.com/api/users,这是一个同源请求。Nginx 接收到这个请求后,内部将其转发到 http://api.internal.com/users,拿到数据后再返回给浏览器。
Nginx 配置示例 (nginx.conf):
Nginx
server {
listen 80;
server_name www.example.com;
# 静态资源
location / {
root /path/to/your/frontend/dist;
index index.html;
}
# API 代理
location /api/ {
# proxy_pass 指向你的后端 API 地址
proxy_pass http://api.example.com/;
# 可选:重写路径,去掉 /api
# rewrite ^/api/(.*)$ /$1 break;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
优点:无需修改任何前端或后端代码,对开发者透明;安全、稳定、高性能,是生产环境的最佳实践。
缺点:需要额外的服务器配置,依赖运维知识。
2. Node.js 中间件代理(开发环境利器)
在现代前端开发中,我们通常会使用 Webpack、Vite 等构建工具,它们自带的开发服务器(devServer)都内置了代理功能。
这使得我们在开发时可以轻松地模拟生产环境的 Nginx 代理。
Vite 配置示例 (vite.config.js):
JavaScript
import { defineConfig } from 'vite';
export default defineConfig({
server: {
proxy: {
// 字符串简写写法
'/foo': 'http://localhost:4567',
// 选项写法
'/api': {
target: 'http://jsonplaceholder.typicode.com', // 目标 API 地址
changeOrigin: true, // 必须设置为 true,否则会失败
rewrite: (path) => path.replace(/^\/api/, ''), // 重写路径,去掉 /api
},
}
}
});
配置好后,在你的前端代码里,直接请求 /api/todos/1 即可,Vite 的开发服务器会自动帮你转发到 http://jsonplaceholder.typicode.com/todos/1。
优点:配置简单,与现代前端工作流无缝集成,是开发环境解决跨域问题的最佳方式。
缺点:主要用于开发环境。
其他方案
除了上述三种主流方案,还有一些适用于特定场景的技术:
postMessageAPI:用于window对象之间(如页面与iframe、页面与新打开的窗口)的安全通信,可以跨源。WebSocket:WebSocket 协议本身不受同源策略限制,但它在建立连接时需要一个 HTTP 握手,这个握手过程依然受同源策略影响。通常服务器端会做来源验证。