跳到主要内容

同源策略,跨域问题与解决方案

问题

什么是跨域?为什么会有跨域限制?有哪些跨域解决方案?

答案

跨域是浏览器的同源策略导致的安全限制。当协议、域名、端口任一不同时,就会产生跨域问题。

同源策略

什么是同源?

同源是指协议域名端口都相同:

https://example.com:443/path
│ │ │
协议 域名 端口
URLhttps://example.com 是否同源原因
https://example.com/page✅ 同源完全相同
http://example.com❌ 跨域协议不同
https://api.example.com❌ 跨域子域名不同
https://example.com:8080❌ 跨域端口不同
https://other.com❌ 跨域域名不同

同源策略限制什么?

行为是否限制说明
Cookie、LocalStorage✅ 限制无法读取跨域的存储
DOM 访问✅ 限制无法操作跨域 iframe 的 DOM
AJAX 请求✅ 限制无法发送跨域请求(或无法读取响应)
<script> 加载❌ 不限制可以加载跨域脚本
<img> 加载❌ 不限制可以加载跨域图片
<link> 加载❌ 不限制可以加载跨域样式
为什么需要同源策略?

同源策略是浏览器的安全机制,防止恶意网站:

  1. 窃取其他网站的用户数据
  2. 冒充用户发送请求(CSRF)
  3. 读取用户的敏感信息

CORS(跨域资源共享)

CORS(Cross-Origin Resource Sharing)是现代浏览器的标准跨域解决方案。

简单请求

满足以下条件的请求是简单请求,不会触发预检:

// 简单请求条件
const isSimpleRequest =
// 1. 方法是 GET、HEAD、POST 之一
['GET', 'HEAD', 'POST'].includes(method) &&
// 2. 只有安全的请求头
headers只包含Accept、AcceptLanguage、ContentLanguage、ContentType &&
// 3. Content-Type 只能是以下三种
['text/plain', 'multipart/form-data', 'application/x-www-form-urlencoded']
.includes(contentType);

预检请求(Preflight)

非简单请求会先发送 OPTIONS 预检请求

// 会触发预检的请求
fetch('https://api.example.com/data', {
method: 'PUT', // 非简单方法
headers: {
'Content-Type': 'application/json', // 非简单 Content-Type
'Authorization': 'Bearer token', // 自定义头
},
body: JSON.stringify({ name: 'test' }),
});

CORS 响应头

响应头说明示例
Access-Control-Allow-Origin允许的源* 或具体域名
Access-Control-Allow-Methods允许的方法GET, POST, PUT, DELETE
Access-Control-Allow-Headers允许的请求头Content-Type, Authorization
Access-Control-Allow-Credentials是否允许携带凭证true
Access-Control-Max-Age预检结果缓存时间(秒)86400
Access-Control-Expose-Headers暴露给 JS 的响应头X-Custom-Header

服务端配置示例

// Node.js + Express
import cors from 'cors';

app.use(cors({
origin: ['https://example.com', 'https://app.example.com'],
methods: ['GET', 'POST', 'PUT', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization'],
credentials: true,
maxAge: 86400,
}));

// 或手动设置
app.use((req, res, next) => {
res.header('Access-Control-Allow-Origin', 'https://example.com');
res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE');
res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization');
res.header('Access-Control-Allow-Credentials', 'true');

if (req.method === 'OPTIONS') {
res.header('Access-Control-Max-Age', '86400');
return res.sendStatus(204);
}
next();
});
// Nginx 配置
/*
location /api {
add_header 'Access-Control-Allow-Origin' 'https://example.com';
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS';
add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization';
add_header 'Access-Control-Allow-Credentials' 'true';

if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Max-Age' 86400;
return 204;
}
}
*/

携带凭证(Cookies)

// 前端:需要设置 credentials
fetch('https://api.example.com/user', {
credentials: 'include', // 携带 cookies
});

// 或 axios
axios.defaults.withCredentials = true;
凭证模式限制

credentials: 'include' 时:

  1. Access-Control-Allow-Origin 不能*,必须是具体域名
  2. Access-Control-Allow-Credentials 必须是 true

JSONP

JSONP 利用 <script> 标签不受同源限制的特性,只支持 GET 请求

// JSONP 实现
function jsonp<T>(url: string, callbackName: string): Promise<T> {
return new Promise((resolve, reject) => {
// 创建全局回调函数
const callback = `jsonp_${Date.now()}`;
(window as any)[callback] = (data: T) => {
resolve(data);
document.head.removeChild(script);
delete (window as any)[callback];
};

// 创建 script 标签
const script = document.createElement('script');
script.src = `${url}?callback=${callback}`;
script.onerror = () => reject(new Error('JSONP request failed'));
document.head.appendChild(script);
});
}

// 使用
const data = await jsonp<{ name: string }>(
'https://api.example.com/user',
'callback'
);
// 服务端返回
// https://api.example.com/user?callback=jsonp_123456
// 响应:jsonp_123456({ "name": "Alice" })
JSONP 的缺点
  1. 只支持 GET 请求
  2. 安全性差:容易被 XSS 攻击
  3. 难以处理错误:无法获取 HTTP 状态码
  4. 已过时:现代应用推荐使用 CORS

代理服务器

通过同源的代理服务器转发请求,绕过浏览器限制:

开发环境代理

// Vite 配置
// vite.config.ts
export default defineConfig({
server: {
proxy: {
'/api': {
target: 'https://api.example.com',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ''),
},
},
},
});

// Webpack devServer
// webpack.config.js
module.exports = {
devServer: {
proxy: {
'/api': {
target: 'https://api.example.com',
changeOrigin: true,
pathRewrite: { '^/api': '' },
},
},
},
};

生产环境代理

# Nginx 反向代理
server {
listen 80;
server_name example.com;

location /api {
proxy_pass https://api.other.com;
proxy_set_header Host api.other.com;
proxy_set_header X-Real-IP $remote_addr;
}
}

postMessage

postMessage 用于跨窗口通信(iframe、window.open):

// 父页面发送消息
const iframe = document.getElementById('myIframe') as HTMLIFrameElement;
iframe.contentWindow?.postMessage(
{ type: 'greeting', data: 'Hello' },
'https://child.example.com' // 目标源,* 表示任意
);

// 父页面接收消息
window.addEventListener('message', (event) => {
// 验证来源!
if (event.origin !== 'https://child.example.com') return;

console.log('收到消息:', event.data);
});

// ---

// 子页面(iframe)接收消息
window.addEventListener('message', (event) => {
if (event.origin !== 'https://parent.example.com') return;

console.log('子页面收到:', event.data);

// 回复消息
event.source?.postMessage(
{ type: 'reply', data: 'Hi back!' },
{ targetOrigin: event.origin }
);
});
安全注意
  1. 始终验证 event.origin:防止接收恶意消息
  2. 指定目标源:避免使用 *,除非确实需要广播
  3. 验证消息格式:检查 event.data 的结构

document.domain

同一主域下的子域可以通过设置 document.domain 共享:

// https://a.example.com
document.domain = 'example.com';

// https://b.example.com
document.domain = 'example.com';

// 此时两个页面可以互相访问
已废弃

document.domain 已被标记为废弃,现代浏览器正在移除支持。请使用 postMessage 替代。

跨域方案对比

方案适用场景优点缺点
CORS所有跨域请求标准、安全、功能完整需要服务端配置
代理开发/生产环境前端无感知需要部署代理服务
JSONP兼容旧浏览器兼容性好只支持 GET、不安全
postMessage窗口通信安全、灵活仅限窗口间通信
WebSocket实时通信全双工、无跨域限制需要 WebSocket 服务

常见面试问题

Q1: 什么是跨域?为什么会产生跨域?

答案

跨域是由浏览器的同源策略导致的。当请求的 URL 与当前页面的协议、域名、端口任一不同时,就会产生跨域。

同源策略的目的是保护用户安全:

  1. 防止恶意网站读取其他网站的数据
  2. 防止 CSRF 攻击
  3. 保护用户隐私
// 当前页面:https://example.com
fetch('https://api.other.com/data'); // 跨域!
fetch('http://example.com/data'); // 跨域!协议不同
fetch('https://example.com:8080/data'); // 跨域!端口不同

Q2: CORS 的简单请求和预检请求有什么区别?

答案

特性简单请求预检请求
请求数量1 次2 次(OPTIONS + 实际请求)
触发条件GET/HEAD/POST + 简单头其他方法或自定义头
Content-Type三种简单类型application/json

简单请求条件

// 1. 方法是 GET、HEAD、POST
// 2. 头只有:Accept, Accept-Language, Content-Language, Content-Type
// 3. Content-Type 是:text/plain, multipart/form-data, application/x-www-form-urlencoded

Q3: 跨域请求能发出去吗?

答案

能发出去! 跨域请求的限制是浏览器不让你读取响应,而不是阻止请求发送。

这也是为什么 CSRF 攻击能成功——请求确实发送了,服务端也处理了。

答案

// 前端
fetch(url, { credentials: 'include' });

// 服务端必须设置
res.header('Access-Control-Allow-Origin', 'https://example.com'); // 不能是 *
res.header('Access-Control-Allow-Credentials', 'true');

注意Allow-Origin 不能是 *,必须是具体的域名。

Q5: JSONP 的原理是什么?有什么缺点?

答案

原理:利用 <script> 标签不受同源策略限制的特性,动态创建 script 标签请求数据。

// 1. 创建回调函数
window.callback = (data) => console.log(data);

// 2. 创建 script 标签
const script = document.createElement('script');
script.src = 'https://api.com/data?callback=callback';
// 服务端返回:callback({ "name": "Alice" })

缺点

  1. 只支持 GET 请求
  2. 安全性差,容易被 XSS 攻击
  3. 无法获取 HTTP 状态码
  4. 已过时,推荐 CORS

Q6: 什么是简单请求和预检请求?触发预检请求的条件?

答案

浏览器将 CORS 请求分为简单请求预检请求两类,区别在于是否需要先发送一个 OPTIONS 请求进行"预检"。

简单请求必须同时满足以下所有条件:

条件要求
请求方法GETHEADPOST 之一
请求头仅包含 AcceptAccept-LanguageContent-LanguageContent-Type
Content-Type仅限 text/plainmultipart/form-dataapplication/x-www-form-urlencoded

只要不满足上述任一条件,浏览器就会自动发送预检请求(OPTIONS)。

// ✅ 简单请求 - 不会触发预检
fetch('https://api.example.com/data', {
method: 'GET',
});

fetch('https://api.example.com/form', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'name=test',
});

// ❌ 以下都会触发预检请求
// 原因 1:使用了非简单方法 PUT
fetch('https://api.example.com/data', {
method: 'PUT',
body: JSON.stringify({ name: 'test' }),
});

// 原因 2:Content-Type 是 application/json
fetch('https://api.example.com/data', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'test' }),
});

// 原因 3:包含自定义请求头
fetch('https://api.example.com/data', {
headers: { 'Authorization': 'Bearer token123' },
});

预检请求的完整流程

优化预检请求

通过 Access-Control-Max-Age 可以缓存预检结果,在缓存有效期内不会重复发送 OPTIONS 请求,从而减少一次网络往返。常见设置为 86400(24 小时)。

答案

默认情况下,跨域请求不会携带 Cookie。要让跨域请求携带 Cookie,需要前后端同时配置。

三种 credentials 模式

模式说明是否携带 Cookie
omit从不携带凭证
same-origin仅同源携带(默认值)同源 ✅ / 跨域 ❌
include始终携带凭证
// 前端设置
// 方式 1:fetch API
fetch('https://api.example.com/user', {
credentials: 'include', // 携带跨域 Cookie
});

// 方式 2:XMLHttpRequest
const xhr = new XMLHttpRequest();
xhr.withCredentials = true;
xhr.open('GET', 'https://api.example.com/user');
xhr.send();

// 方式 3:axios
import axios from 'axios';
axios.get('https://api.example.com/user', {
withCredentials: true,
});
// 或全局配置
axios.defaults.withCredentials = true;
// 服务端设置(Express 示例)
import cors from 'cors';

app.use(cors({
origin: 'https://app.example.com', // 不能是 *
credentials: true, // 必须为 true
}));
关键限制

credentials: 'include' 时,服务端有以下硬性限制

  1. Access-Control-Allow-Origin 不能是 *,必须指定具体域名
  2. Access-Control-Allow-Headers 不能是 *
  3. Access-Control-Allow-Methods 不能是 *
  4. Access-Control-Allow-Credentials 必须为 true

违反以上任一条件,浏览器都会拦截响应。

多域名场景的动态 Origin 处理

// 当需要支持多个域名时,根据请求的 Origin 动态设置
import { Request, Response, NextFunction } from 'express';

const allowedOrigins = [
'https://app.example.com',
'https://admin.example.com',
'https://m.example.com',
];

function corsMiddleware(req: Request, res: Response, next: NextFunction): void {
const origin = req.headers.origin;

if (origin && allowedOrigins.includes(origin)) {
res.header('Access-Control-Allow-Origin', origin);
res.header('Access-Control-Allow-Credentials', 'true');
res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE');
res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization');
}

if (req.method === 'OPTIONS') {
res.header('Access-Control-Max-Age', '86400');
res.sendStatus(204);
return;
}

next();
}

Q8: 开发环境和生产环境跨域方案的区别

答案

开发环境和生产环境的跨域解决方案有本质区别:

对比开发环境生产环境
核心方案开发服务器代理Nginx 反向代理 / CORS 配置
原理本地代理绕过浏览器限制服务端直接处理跨域
配置位置Vite/Webpack 配置文件Nginx/服务端代码
请求路径浏览器 → 本地代理 → API 服务器浏览器 → Nginx → API 服务器

开发环境方案 - 开发服务器代理

vite.config.ts
import { defineConfig } from 'vite';

export default defineConfig({
server: {
proxy: {
// 字符串简写
'/foo': 'http://localhost:4567',

// 完整配置
'/api': {
target: 'https://api.example.com',
changeOrigin: true, // 修改请求头中的 Host
rewrite: (path) => path.replace(/^\/api/, ''), // 重写路径
secure: false, // 接受无效证书(开发环境)
},

// WebSocket 代理
'/ws': {
target: 'ws://localhost:3001',
ws: true,
},
},
},
});
webpack.config.ts
import { Configuration } from 'webpack';

const config: Configuration = {
devServer: {
proxy: [
{
context: ['/api'],
target: 'https://api.example.com',
changeOrigin: true,
pathRewrite: { '^/api': '' },
},
],
},
};

export default config;
代理原理

开发服务器代理的本质是:浏览器请求的是本地 localhost(同源),然后由 Node.js 服务端代为转发请求到目标 API 服务器。因为服务端之间的 HTTP 请求不受同源策略限制,所以能绕过跨域。

生产环境方案 1 - Nginx 反向代理

nginx.conf
server {
listen 80;
server_name www.example.com;

# 前端静态资源
location / {
root /usr/share/nginx/html;
try_files $uri $uri/ /index.html;
}

# API 代理 - 浏览器请求 /api 会被转发到后端服务
location /api/ {
proxy_pass http://backend-server:3000/;
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;
}
}

生产环境方案 2 - 服务端配置 CORS

server.ts
import express from 'express';
import cors from 'cors';

const app = express();

// 生产环境 CORS 配置,严格限制 Origin
app.use(cors({
origin: (origin, callback) => {
const allowedOrigins = [
'https://www.example.com',
'https://app.example.com',
];

if (!origin || allowedOrigins.includes(origin)) {
callback(null, true);
} else {
callback(new Error('Not allowed by CORS'));
}
},
methods: ['GET', 'POST', 'PUT', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization'],
credentials: true,
maxAge: 86400,
}));

生产环境方案 3 - 同域部署(彻底避免跨域)

常见误区
  1. 不要在生产环境使用 Access-Control-Allow-Origin: *:这会允许任何网站访问你的 API,存在安全风险
  2. 开发环境的代理配置不会影响生产环境:Vite/Webpack 代理只在本地开发服务器中生效
  3. Nginx 反向代理和 CORS 二选一即可:如果用了 Nginx 代理,前后端同域,就不需要 CORS 配置

相关链接