浏览器篇 | 同源与跨域
前言
你写了一个前端页面,部署在 https://app.example.com,后端 API 在 https://api.example.com。打开浏览器一调试,控制台里赫然一行红字:
Access to XMLHttpRequest at ‘https://api.example.com/data’ from origin ‘https://app.example.com’ has been blocked by CORS policy.
对大多数前端开发者来说,这大概是职业生涯中遇到的第一个”灵魂拷问级”报错。然后你搜了一堆解决方案,在后端加了个 Access-Control-Allow-Origin: *,报错消失了,功能正常了——但你真的理解背后发生了什么吗?
面试中,跨域是一个出现频率极高的考点。面试官的追问往往是这样的:
- 同源策略到底限制了什么?哪些东西不受限制?
- CORS 的”简单请求”和”预检请求”怎么区分?
Access-Control-Allow-Origin: *为什么在某些场景下不能用?- JSONP 是怎么绕过同源策略的?它有什么局限?
- 除了 CORS,还有哪些跨域方案?
本章我们就从同源策略的本质出发,逐一拆解各种跨域方案的原理和适用场景。读完之后,你不仅能在面试中把跨域讲清楚,也能在实际项目中做出更合理的选择。
诊断自测
Q1:以下哪些 URL 与 https://www.example.com:443/page 是同源的?
- A.
https://www.example.com/api - B.
http://www.example.com/page - C.
https://api.example.com/page - D.
https://www.example.com:8080/page
点击查看答案
只有 A 同源。同源要求协议 + 域名 + 端口完全一致。A 和原 URL 的协议(https)、域名(www.example.com)、端口(443,https 默认端口)都相同。B 协议不同(http vs https);C 域名不同(api vs www);D 端口不同(8080 vs 443)。
Q2:CORS 中,什么情况下浏览器会先发一个 OPTIONS 预检请求?
点击查看答案
当请求不满足”简单请求”条件时,浏览器会先发一个 OPTIONS 预检请求。不满足的常见情况:方法不是 GET/HEAD/POST;Content-Type 不是 text/plain、multipart/form-data、application/x-www-form-urlencoded 之一(比如 application/json);请求头中包含自定义 Header(比如 Authorization)。
Q3:JSONP 为什么只能发 GET 请求?
点击查看答案
因为 JSONP 的本质是通过动态创建 <script> 标签来加载资源,而 <script> 标签的 src 属性发起的请求只能是 GET 请求。这是 HTML 标签的限制,不是 JSONP 方案本身可以控制的。
一、同源策略:浏览器安全的基石
1.1 什么是”同源”?
两个 URL 的协议(protocol)、域名(host)、端口(port) 完全一致,就称为”同源”。
https://www.example.com:443/page
协议: https
域名: www.example.com
端口: 443
任何一项不同,就是”不同源”(也叫”跨域”):
| URL | 与上面同源? | 原因 |
|---|---|---|
https://www.example.com/other | ✅ 是 | 路径不同不影响同源 |
http://www.example.com/page | ❌ 否 | 协议不同 |
https://api.example.com/page | ❌ 否 | 域名不同(子域也不同) |
https://www.example.com:8080 | ❌ 否 | 端口不同 |
1.2 同源策略限制了什么?
同源策略(Same-Origin Policy)是浏览器的一项安全机制,它限制了来自不同源的文档或脚本之间的交互。具体来说,它主要限制以下三个方面:
1. DOM 访问
不同源的页面之间不能通过 JavaScript 操作对方的 DOM。比如,一个 iframe 嵌入了不同源的页面,父页面不能读取 iframe 内部的 DOM 内容。
// 父页面 https://a.com
const iframe = document.getElementById('myIframe');
iframe.contentWindow.document; // ❌ 跨域访问,被阻止
2. Cookie / localStorage / IndexedDB
不同源的页面不能读取对方的 Cookie、localStorage、IndexedDB 等存储数据。
3. AJAX 请求
不同源的 AJAX 请求(XMLHttpRequest 和 Fetch)会被浏览器拦截。注意:请求实际上是发出去了的,服务器也可能正常处理并返回了响应,但浏览器在收到响应后发现不符合同源策略,就会把响应拦截掉,不让 JavaScript 读取。
1.3 哪些东西不受同源策略限制?
这个反向知识点在面试中也经常被问到:
<img src="...">—— 图片可以跨域加载<link href="...">—— CSS 可以跨域加载<script src="...">—— 脚本可以跨域加载(JSONP 就是利用了这一点)<video>/<audio>—— 媒体资源可以跨域加载<iframe>—— 可以跨域嵌入,但不能操作内部 DOM
这些标签发出的请求不受同源策略限制,但通过 JavaScript 去读取这些资源的内容时,仍然会受到限制。比如你可以在 <canvas> 中绘制一张跨域图片,但如果没有 CORS 头,你无法用 canvas.toDataURL() 读取画布内容(会被标记为”被污染的画布”)。
二、CORS:现代跨域的标准方案
CORS(Cross-Origin Resource Sharing,跨源资源共享)是 W3C 标准,也是目前最主流的跨域解决方案。它的核心思想是:由服务器通过响应头来告诉浏览器,“我允许某某源来访问我的资源”。
2.1 简单请求 vs 预检请求
CORS 把请求分成两类:简单请求和非简单请求(需要预检的请求)。
简单请求需要同时满足以下条件:
- 方法是
GET、HEAD或POST之一 - 请求头只包含以下安全头部:
Accept、Accept-Language、Content-Language、Content-Type Content-Type的值只能是以下三种之一:text/plainmultipart/form-dataapplication/x-www-form-urlencoded
不满足以上任一条件的,就是非简单请求。 最常见的触发预检的场景:
- 使用
PUT、DELETE、PATCH等方法 Content-Type为application/json- 请求头中包含自定义 Header(如
Authorization、X-Custom-Header)
2.2 简单请求的流程
对于简单请求,浏览器直接发送请求,同时在请求头中自动加上 Origin 字段:
GET /api/data HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
服务器收到请求后,如果允许该 Origin 访问,就在响应头中返回:
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://app.example.com
浏览器检查响应头中的 Access-Control-Allow-Origin 是否匹配当前 Origin,如果匹配就正常处理响应,否则拦截。
2.3 预检请求的流程
对于非简单请求,浏览器会在真实请求之前先发一个 OPTIONS 方法的”预检请求”(Preflight Request):
// 第一步:预检请求
OPTIONS /api/data HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: Content-Type, Authorization
服务器返回预检响应,说明允许的方法和头部:
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 86400
浏览器检查预检响应,如果通过了,才会发送真实请求:
// 第二步:真实请求
PUT /api/data HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
Content-Type: application/json
Authorization: Bearer xxx
{"name": "Alice"}
2.4 CORS 常用响应头
| 响应头 | 作用 |
|---|---|
Access-Control-Allow-Origin | 允许访问的源,* 表示任意源 |
Access-Control-Allow-Methods | 允许的 HTTP 方法 |
Access-Control-Allow-Headers | 允许的自定义请求头 |
Access-Control-Allow-Credentials | 是否允许携带 Cookie(值为 true) |
Access-Control-Max-Age | 预检请求的缓存时间(秒),避免每次都预检 |
Access-Control-Expose-Headers | 允许 JS 读取的非标准响应头 |
2.5 携带 Cookie 的跨域请求
默认情况下,跨域请求不会携带 Cookie。如果需要携带,前后端都需要做配置:
前端(以 Fetch 为例):
fetch('https://api.example.com/data', {
credentials: 'include' // 关键配置
});
后端响应头:
Access-Control-Allow-Origin: https://app.example.com // ⚠️ 不能是 *
Access-Control-Allow-Credentials: true
注意:当 Access-Control-Allow-Credentials: true 时,Access-Control-Allow-Origin 不能设为 *,必须指定具体的源。这是浏览器的安全限制——如果允许携带 Cookie,就不能对任意源开放。
这是面试中的高频考点。很多人知道要加 credentials: 'include',但不知道对应的后端限制。
三、JSONP:古老但面试常考的方案
JSONP(JSON with Padding)是 CORS 出现之前最常用的跨域方案。虽然现在已经很少在新项目中使用,但面试中依然经常考,因为它的原理非常巧妙。
3.1 原理
JSONP 利用了 <script> 标签不受同源策略限制的特性。它的工作流程是:
- 前端定义一个回调函数
- 动态创建
<script>标签,src 指向跨域的接口,并把回调函数名作为参数传过去 - 服务器收到请求后,把数据包裹在回调函数调用中返回
- 浏览器执行返回的脚本,回调函数被调用,拿到数据
// 前端
function handleData(data) {
console.log('收到数据:', data);
}
const script = document.createElement('script');
script.src = 'https://api.example.com/data?callback=handleData';
document.body.appendChild(script);
// 服务器返回的内容(不是 JSON,而是一段 JS 代码):
// handleData({"name": "Alice", "age": 25})
浏览器会把服务器返回的内容当作 JavaScript 来执行,于是 handleData 函数就被调用了,参数就是我们需要的数据。
3.2 封装一个简单的 JSONP 函数
function jsonp(url, callbackName = 'callback') {
return new Promise((resolve, reject) => {
// 生成唯一的回调函数名
const fnName = `jsonp_${Date.now()}_${Math.random().toString(36).slice(2)}`;
// 挂载到全局
window[fnName] = (data) => {
resolve(data);
// 清理
delete window[fnName];
document.head.removeChild(script);
};
const script = document.createElement('script');
script.src = `${url}${url.includes('?') ? '&' : '?'}${callbackName}=${fnName}`;
script.onerror = () => {
reject(new Error('JSONP request failed'));
delete window[fnName];
document.head.removeChild(script);
};
document.head.appendChild(script);
});
}
// 使用
const data = await jsonp('https://api.example.com/data');
3.3 JSONP 的局限
- 只支持 GET 请求——因为
<script>标签只能发 GET - 安全性差——服务器返回的是可执行的 JS 代码,如果服务器被劫持,可以注入恶意代码
- 没有标准的错误处理——
<script>标签的onerror事件兼容性不够好,HTTP 状态码也无法获取 - 需要服务端配合——服务端必须支持 JSONP 格式的响应
在现代项目中,CORS 几乎完全替代了 JSONP。但在面试中,手写 JSONP 仍然是一道经典题目。
四、其他跨域方案
除了 CORS 和 JSONP,还有一些在特定场景下使用的跨域方案。
4.1 postMessage:跨窗口通信
window.postMessage 是 HTML5 提供的 API,允许来自不同源的窗口之间安全地传递消息。典型场景:父页面和 iframe 之间的通信。
// 父页面 https://a.com
const iframe = document.getElementById('myIframe');
iframe.contentWindow.postMessage(
{ type: 'greeting', data: 'Hello from parent!' },
'https://b.com' // 指定目标源,确保安全
);
// iframe 页面 https://b.com
window.addEventListener('message', (e) => {
// ⚠️ 一定要验证来源
if (e.origin !== 'https://a.com') return;
console.log('收到消息:', e.data);
// 回复消息
e.source.postMessage(
{ type: 'reply', data: 'Hello from iframe!' },
e.origin
);
});
安全注意事项:
- 发送时要指定目标 origin(不要用
*,除非你确定不在乎谁接收) - 接收时一定要校验
event.origin,防止恶意窗口发来的消息
4.2 Nginx 反向代理
同源策略是浏览器的限制,服务器之间的通信不受此限制。所以一种常见的方案是:让前端请求自己的同源服务器,由服务器转发给目标服务器。
浏览器 (https://app.example.com)
│
│ 请求: /api/data(同源,不跨域)
▼
Nginx (app.example.com)
│
│ 代理转发: proxy_pass https://api.example.com/data
▼
API 服务器 (api.example.com)
Nginx 配置示例:
server {
listen 80;
server_name app.example.com;
# 前端静态资源
location / {
root /var/www/html;
index index.html;
}
# API 代理
location /api/ {
proxy_pass https://api.example.com/;
proxy_set_header Host api.example.com;
proxy_set_header X-Real-IP $remote_addr;
}
}
这样前端请求 /api/data 实际会被 Nginx 转发到 https://api.example.com/data,对浏览器来说请求是同源的,不存在跨域问题。
开发环境中的等价方案: webpack-dev-server、Vite 的 proxy 配置,原理一样——开发服务器充当反向代理。
// vite.config.js
export default {
server: {
proxy: {
'/api': {
target: 'https://api.example.com',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, '')
}
}
}
};
4.3 WebSocket
WebSocket 协议本身不受同源策略限制。WebSocket 建立连接时使用的是 HTTP 的 Upgrade 机制,一旦连接建立,后续的通信就走的是 WebSocket 协议,不再受浏览器的同源策略约束。
// 前端 https://app.example.com
const ws = new WebSocket('wss://api.example.com/ws');
ws.onopen = () => {
ws.send(JSON.stringify({ type: 'hello' }));
};
ws.onmessage = (e) => {
console.log('收到:', JSON.parse(e.data));
};
不过要注意,虽然 WebSocket 不受同源策略限制,但服务端仍然应该校验 Origin 头来防止未授权的连接。
4.4 document.domain(已废弃)
在以前,两个子域名相同主域的页面(比如 a.example.com 和 b.example.com)可以通过设置 document.domain = 'example.com' 来实现跨域通信。但这种方法已经被标记为废弃,现代浏览器正在逐步移除支持。了解即可,不建议使用。
五、各方案对比
| 方案 | 适用场景 | 请求方法 | 需要服务端配合 | 安全性 |
|---|---|---|---|---|
| CORS | 通用的跨域 AJAX | 所有方法 | 需要(设置响应头) | 高 |
| JSONP | 兼容老浏览器、简单 GET | 仅 GET | 需要(返回 JSONP 格式) | 低 |
| Nginx 反向代理 | 开发/部署时统一域名 | 所有方法 | 需要(配置代理) | 高 |
| postMessage | 跨窗口/iframe 通信 | N/A(消息传递) | 不需要 | 中(需校验 origin) |
| WebSocket | 实时双向通信 | N/A(WebSocket) | 需要(WS 服务器) | 中(需校验 origin) |
实际项目中的最佳实践:
- 生产环境:优先用 CORS,它是标准方案、配置灵活、安全性好
- 开发环境:用 dev-server 的 proxy 功能,避免开发时手动配置 CORS
- 跨窗口通信:用 postMessage
- 实时通信:用 WebSocket(跨域只是它的附带好处)
- 老项目兼容:JSONP 作为 fallback
常见误区
误区一:“跨域请求被服务器拒绝了”
不是服务器拒绝的,是浏览器拦截的。跨域请求实际上已经发出去了,服务器也收到并处理了,响应也返回了。但浏览器在收到响应后检查了 CORS 头,发现不匹配,于是把响应拦截了,不让 JavaScript 读取。这就是为什么你在 Network 面板里能看到请求状态是 200,但 JS 代码里却报跨域错误。
误区二:“加了 Access-Control-Allow-Origin: * 就万事大吉”
* 表示允许任意源访问,看起来最简单,但有两个问题:一是当需要携带 Cookie 时(credentials: 'include'),Access-Control-Allow-Origin 不能是 *,必须指定具体的源;二是从安全角度,生产环境对外的 API 应该限制允许的源,而不是对所有人开放。
误区三:“CORS 是前端的事,后端不用管”
恰恰相反。CORS 的核心配置在服务端——是服务端通过响应头来告诉浏览器”我允许谁来访问”。前端能做的只是在请求中设置 credentials、自定义 Header 等,而决定权在服务端。很多跨域问题的根源都是后端没有正确配置 CORS 响应头。
误区四:“JSONP 就是 JSON 的一种格式”
不是。JSONP(JSON with Padding)和 JSON 是完全不同的东西。JSON 是一种数据格式,JSONP 是一种跨域方案。JSONP 返回的内容是一段可执行的 JavaScript 代码(回调函数调用),而不是纯 JSON 数据。名字中的”Padding”指的就是用回调函数名”填充”在数据外面。
小结
本章我们从同源策略的定义出发,系统梳理了浏览器跨域的方方面面。
核心要点
- 同源 = 协议 + 域名 + 端口 完全一致;同源策略限制 DOM 访问、存储读取、AJAX 请求
- CORS 是标准方案:通过服务端响应头控制跨域访问权限
- 简单请求直接发,非简单请求先发 OPTIONS 预检;触发预检的常见原因是
application/json、自定义 Header、非 GET/HEAD/POST 方法 - 携带 Cookie 的跨域请求需要前端
credentials: 'include'+ 后端Allow-Credentials: true+ 非*的 Origin - JSONP 利用
<script>标签不受同源限制的特性,只能 GET、安全性差,面试手写经典 - Nginx 反向代理是开发和部署中常用的跨域方案,本质是让浏览器只和同源服务器通信
- postMessage 用于跨窗口/iframe 通信,注意校验 origin
- 跨域是浏览器的限制,服务器之间通信不存在跨域问题
本章思维导图
- 同源策略
- 定义:协议 + 域名 + 端口一致
- 限制范围
- DOM 访问
- Cookie / Storage 读取
- AJAX 请求
- 不受限制的
- <img> / <link> / <script> / <video> 等标签
- <iframe> 可嵌入但不能操作 DOM
- CORS(标准方案)
- 简单请求
- GET / HEAD / POST
- Content-Type 限三种
- 浏览器自动加 Origin 头
- 预检请求(OPTIONS)
- 触发条件:非简单请求
- 预检响应头:Allow-Methods / Allow-Headers / Max-Age
- 常用响应头
- Access-Control-Allow-Origin
- Access-Control-Allow-Credentials
- Access-Control-Expose-Headers
- 携带 Cookie
- 前端:credentials: 'include'
- 后端:Allow-Credentials: true + 具体 Origin
- 简单请求
- JSONP
- 原理:<script> 标签 + 回调函数
- 局限:仅 GET、安全性差、无错误处理
- 其他方案
- postMessage:跨窗口通信
- Nginx 反向代理:统一域名
- WebSocket:不受同源限制
- document.domain:已废弃
练习挑战
第一题 ⭐(基础):判断请求类型
以下请求是”简单请求”还是”需要预检的请求”?
// 请求 A
fetch('https://api.example.com/data', {
method: 'GET'
});
// 请求 B
fetch('https://api.example.com/data', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Alice' })
});
// 请求 C
fetch('https://api.example.com/data', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'name=Alice'
});
// 请求 D
fetch('https://api.example.com/data', {
method: 'DELETE'
});
点击查看答案
- A:简单请求。 GET 方法,无自定义头部。
- B:需要预检。 虽然是 POST 方法,但
Content-Type是application/json,不在简单请求允许的三种类型中。 - C:简单请求。 POST 方法 +
application/x-www-form-urlencoded,满足简单请求条件。 - D:需要预检。 DELETE 方法不在简单请求允许的方法(GET/HEAD/POST)中。
第二题 ⭐⭐(进阶):手写一个简单的 JSONP 函数
要求:
- 返回 Promise
- 支持超时处理
- 完成后清理全局回调函数和 script 标签
点击查看答案
function jsonp(url, { callbackParam = 'callback', timeout = 5000 } = {}) {
return new Promise((resolve, reject) => {
const fnName = `jsonp_${Date.now()}_${Math.random().toString(36).slice(2)}`;
const script = document.createElement('script');
let timer = null;
const cleanup = () => {
if (timer) clearTimeout(timer);
delete window[fnName];
if (script.parentNode) {
script.parentNode.removeChild(script);
}
};
window[fnName] = (data) => {
cleanup();
resolve(data);
};
script.src = `${url}${url.includes('?') ? '&' : '?'}${callbackParam}=${fnName}`;
script.onerror = () => {
cleanup();
reject(new Error('JSONP request failed'));
};
if (timeout > 0) {
timer = setTimeout(() => {
cleanup();
reject(new Error('JSONP request timeout'));
}, timeout);
}
document.head.appendChild(script);
});
}
// 使用
try {
const data = await jsonp('https://api.example.com/data', { timeout: 3000 });
console.log(data);
} catch (e) {
console.error(e.message);
}
核心要点:1)生成唯一的全局回调函数名;2)通过 script 标签的 src 发起请求;3)设置超时定时器;4)无论成功、失败还是超时,都要清理全局变量和 DOM 节点。
第三题 ⭐⭐⭐(综合):设计一个安全的跨域通信方案
场景:你的主站 https://main.example.com 中嵌入了一个第三方支付 iframe https://pay.thirdparty.com。需要实现:
- 主站向 iframe 发送订单信息
- iframe 处理完支付后通知主站结果
- 要考虑安全性(防止恶意消息)
请写出关键代码。
点击查看答案
// ========== 主站 main.example.com ==========
const TRUSTED_ORIGIN = 'https://pay.thirdparty.com';
class PaymentBridge {
constructor(iframeId) {
this.iframe = document.getElementById(iframeId);
this.pendingCallbacks = new Map();
window.addEventListener('message', (e) => {
// 安全校验:只处理可信来源的消息
if (e.origin !== TRUSTED_ORIGIN) return;
// 消息格式校验
if (!e.data || !e.data.type) return;
this.handleMessage(e.data);
});
}
sendOrder(order) {
return new Promise((resolve, reject) => {
const messageId = `msg_${Date.now()}`;
this.pendingCallbacks.set(messageId, { resolve, reject });
this.iframe.contentWindow.postMessage({
type: 'CREATE_ORDER',
messageId,
payload: order
}, TRUSTED_ORIGIN); // 指定目标 origin
// 超时处理
setTimeout(() => {
if (this.pendingCallbacks.has(messageId)) {
this.pendingCallbacks.get(messageId).reject(new Error('Payment timeout'));
this.pendingCallbacks.delete(messageId);
}
}, 30000);
});
}
handleMessage(data) {
if (data.type === 'PAYMENT_RESULT' && data.messageId) {
const callback = this.pendingCallbacks.get(data.messageId);
if (callback) {
if (data.payload.success) {
callback.resolve(data.payload);
} else {
callback.reject(new Error(data.payload.error));
}
this.pendingCallbacks.delete(data.messageId);
}
}
}
}
// 使用
const bridge = new PaymentBridge('payIframe');
try {
const result = await bridge.sendOrder({ orderId: '12345', amount: 99.9 });
console.log('支付成功:', result);
} catch (e) {
console.error('支付失败:', e.message);
}
// ========== iframe pay.thirdparty.com ==========
const ALLOWED_ORIGIN = 'https://main.example.com';
window.addEventListener('message', (e) => {
if (e.origin !== ALLOWED_ORIGIN) return;
if (!e.data || e.data.type !== 'CREATE_ORDER') return;
// 处理支付逻辑...
processPayment(e.data.payload).then(result => {
e.source.postMessage({
type: 'PAYMENT_RESULT',
messageId: e.data.messageId,
payload: { success: true, transactionId: result.id }
}, ALLOWED_ORIGIN);
});
});
安全要点:1)发送和接收时都严格校验 origin;2)使用 messageId 关联请求和响应;3)设置超时处理;4)postMessage 的第二个参数指定目标 origin,不用 *。
自我检测
- 能准确说出”同源”的三个条件(协议 + 域名 + 端口),并判断给定 URL 是否同源
- 能列举同源策略限制的三个方面(DOM、存储、AJAX),以及不受限制的标签
- 能解释 CORS 简单请求和预检请求的区分条件,说出至少三种触发预检的情况
- 能说清楚跨域携带 Cookie 时前后端分别需要做什么配置,以及
Allow-Origin: *的限制 - 能手写一个基本的 JSONP 函数并解释其原理
- 能说出 JSONP 的至少三个局限(仅 GET、安全性差、无错误处理、需服务端配合)
- 能描述 Nginx 反向代理解决跨域的原理
- 能使用 postMessage 实现跨窗口通信,并说出安全注意事项
- 能在面试中解释”跨域是浏览器的限制,请求实际上发出去了”这个关键概念
购买课程解锁全部内容
大厂前端面试通关:71 篇构建完整知识体系
¥89.90