九种跨域方式实现原理(完整版)
Category(分类): NET Protocol Status: 优
前后端数据交互经常会遇到跨域问题。本文先介绍什么是跨域和同源策略,再梳理九种常见的跨域通信方式。
本文完整源代码可参考GitHub 博客。文中的部分方案属于历史兼容方案,新项目应优先使用 CORS、同源反向代理或 postMessage。
一、什么是跨域?
1. 什么是同源策略?
同源策略(Same-Origin Policy,SOP)是浏览器的重要安全机制,用于限制一个源的脚本读取另一个源的资源。一个源由以下三部分共同决定:
协议(scheme) + 主机(host) + 有效端口(port)
例如,下面两个地址不是同源:
http://example.com:80
https://example.com:443
因为协议不同。下面两个地址也不是同源:
http://a.example.com
http://b.example.com
因为主机名不同。即使两个主机名最终解析到了同一个 IP 地址,浏览器仍然按照源进行判断,而不会因为 IP 相同就认为同源。
http://example.com 和 http://example.com:80 使用默认端口时通常可以视为同源;端口是 Origin 的组成部分,但 Cookie 的域匹配规则不包含端口,因此 Cookie 与 LocalStorage 的隔离规则并不完全相同。

2. 同源策略限制了什么?
同源策略的限制对象不是所有网络行为,而主要是跨源脚本的读取和访问权限,常见包括:
- LocalStorage、IndexedDB 等按源隔离的存储;
- 跨源窗口或 iframe 的 DOM 访问;
- Fetch、XMLHttpRequest 读取跨源响应的权限;
- Canvas 读取未经 CORS 授权的跨源图片像素的权限。
Cookie 的规则比较特殊:Cookie 主要根据域名和路径匹配,不按端口隔离,并且还会受到 Secure、HttpOnly、SameSite 等属性影响。
浏览器允许很多元素加载跨源资源,例如:
<img src="https://example.com/a.png" alt="">
<link rel="stylesheet" href="https://example.com/a.css">
<script src="https://example.com/a.js"></script>
但“可以加载”不等于“脚本可以读取全部内容”:跨源图片不能直接被脚本读取像素,跨源 CSS 的 CSSOM 访问受限,跨源脚本虽然可以执行,但会带来严重的脚本注入风险,因此不能把 <script> 当作安全的数据读取接口。
同源策略并不能单独防止 XSS 或 CSRF:XSS 是恶意脚本在某个源内执行,CSRF 则利用浏览器自动携带凭据发起请求。实际应用需要配合输出编码、CSP、CSRF Token、SameSite Cookie 等安全措施。
3. 常见跨域场景
当两个 URL 的协议、主机或有效端口中任意一项不同时,通常就属于不同源。常见场景如下:
| 页面源 | 请求或资源源 | 是否同源 | 原因 |
|---|---|---|---|
http://localhost:3000 | http://localhost:4000 | 否 | 端口不同 |
http://a.example.com | http://b.example.com | 否 | 主机名不同 |
http://example.com | https://example.com | 否 | 协议不同 |
http://example.com | http://example.com/api | 是 | 路径不同不影响源 |

需要特别说明:
- 不能只根据域名对应的 IP 地址判断同源;
- “URL 的首部”不是规范术语,更准确的说法是比较 URL 的 scheme、host 和有效 port;
- 通过浏览器脚本不能自行放宽协议或端口限制,通常需要服务端 CORS、同源代理或跨窗口消息通信;
- 跨域请求不一定代表请求没有发出去。
4. 跨域请求到底发出了吗?
对于简单 CORS 请求,浏览器通常会把请求发送到服务器,但如果响应中没有允许当前 Origin 的 CORS 响应头,浏览器会阻止脚本读取响应。
对于需要预检的请求,浏览器会先发送 OPTIONS 预检请求。如果预检失败,真正的请求可能不会发送。表单提交、图片加载等行为也可能发出跨源请求,但页面脚本通常不能读取对方响应;因此仅依靠同源策略不能完全阻止 CSRF。
二、跨域解决方案
1. JSONP
1.1 JSONP 原理
JSONP(JSON with Padding)利用 <script> 元素可以加载跨源脚本的特性,让服务端返回一段 JavaScript,而不是纯 JSON:
show({ message: 'hello' })
浏览器加载并执行这段脚本后,就会调用页面中预先定义的 show 函数。JSONP 必须得到目标服务器的配合,不能访问任意不支持 JSONP 的网站。
1.2 JSONP 与 AJAX 的区别
AJAX 通常指通过 XMLHttpRequest 或 Fetch 异步请求数据。它本身遵循浏览器的同源策略,但可以通过服务端 CORS 获得跨源读取权限。
JSONP 不是 AJAX 的一种跨域版本,而是利用脚本加载机制完成跨源数据传递。JSONP 只能发起 GET 请求,不能安全地实现 POST、PUT、DELETE 等方法,也不适合传输敏感数据。
1.3 JSONP 的优缺点
优点:
- 实现简单;
- 对较老的浏览器兼容性较好;
- 不需要 XMLHttpRequest 的 CORS 支持。
缺点:
- 只能使用 GET;
- 响应是可执行 JavaScript,服务端或回调参数被利用时可能造成 XSS;
- 没有标准的跨域错误处理机制;
- 受 URL 长度和缓存策略影响;
- 目标服务必须专门支持 JSONP。
1.4 JSONP 的实现流程
- 页面声明回调函数;
- 创建
<script>元素,并将接口地址设置为src; - 通过查询参数把回调函数名传给服务器;
- 服务器校验回调名后返回
callback(data); - 浏览器执行脚本并调用回调函数;
- 请求完成后删除脚本和全局回调,避免污染页面。
示例客户端代码:
function jsonp({ url, params = {}, callback = 'jsonpCallback', timeout = 5000 }) {
return new Promise((resolve, reject) => {
if (!/^[A-Za-z_$][\w$]*$/.test(callback)) {
reject(new Error('非法的 JSONP 回调名'))
return
}
const script = document.createElement('script')
const query = new URLSearchParams({ ...params, callback })
let timer
function cleanup() {
clearTimeout(timer)
delete window[callback]
script.remove()
}
window[callback] = data => {
cleanup()
resolve(data)
}
script.onerror = () => {
cleanup()
reject(new Error('JSONP 请求失败'))
}
script.src = `${url}${url.includes('?') ? '&' : '?'}${query}`
document.head.appendChild(script)
timer = setTimeout(() => {
cleanup()
reject(new Error('JSONP 请求超时'))
}, timeout)
})
}
jsonp({
url: 'http://localhost:3000/say',
params: { wd: 'Iloveyou' },
callback: 'show'
}).then(data => {
console.log(data)
})
上面的代码会请求:
http://localhost:3000/say?wd=Iloveyou&callback=show
服务端示例:
const express = require('express')
const app = express()
app.get('/say', (req, res) => {
const { wd, callback } = req.query
// 生产环境必须严格校验回调函数名,不能直接拼接任意用户输入
if (!/^[A-Za-z_$][\w$]*$/.test(callback || '')) {
res.status(400).send('invalid callback')
return
}
console.log(wd)
res.type('js').send(`${callback}(${JSON.stringify({ message: '我不爱你' })})`)
})
app.listen(3000)
1.5 jQuery 的 JSONP
$.ajax({
url: 'http://crossdomain.com/jsonServerResponse',
dataType: 'jsonp',
type: 'GET',
jsonpCallback: 'show',
jsonp: 'callback',
success(data) {
console.log(data)
},
error() {
console.error('JSONP 请求失败')
}
})
JSONP 通过动态脚本加载实现,通常是异步 GET 请求。所谓“清除缓存”通常是追加时间戳查询参数进行缓存绕过,并不是清空浏览器缓存。新项目优先使用 CORS 或同源代理。
2. CORS
CORS(Cross-Origin Resource Sharing,跨源资源共享)需要浏览器和服务器共同支持。浏览器负责按照 CORS 规则发送预检和检查响应,服务器负责返回允许的 Origin、方法、请求头和凭据策略。
服务端可以返回:
Access-Control-Allow-Origin: https://app.example.com
表示允许指定源读取响应。* 可以允许任意源进行非凭据跨源读取,但不能和 Access-Control-Allow-Credentials: true 一起使用。
CORS 不是“服务器允许所有请求”的开关,而是浏览器是否允许当前页面脚本读取跨源响应的机制。服务器仍然需要进行身份认证和权限校验。
2.1 简单请求
“简单请求”是历史上常用的称呼。通常需要同时满足:
- 方法是
GET、HEAD或POST; Content-Type是以下之一:application/x-www-form-urlencoded;multipart/form-data;text/plain;
- 手动设置的请求头满足 CORS safelisted request-header 的限制;
- XMLHttpRequest 的上传对象没有注册会触发预检的事件监听器。
简单请求通常可以直接发送,但服务端仍必须返回正确的 Access-Control-Allow-Origin,浏览器才会允许脚本读取响应。
2.2 需要预检的请求
不满足简单请求条件的请求通常称为需要预检的请求。浏览器会先发送 OPTIONS 请求,并携带:
Origin;Access-Control-Request-Method;Access-Control-Request-Headers。
服务器需要通过 Access-Control-Allow-Methods、Access-Control-Allow-Headers 和 Access-Control-Allow-Origin 等响应头明确允许该请求。预检通过后,浏览器才会发送真正的请求。
例如,使用 PUT 方法并携带自定义 name 请求头时,服务端可以这样配置:
const express = require('express')
const app = express()
const allowList = new Set(['http://localhost:3000'])
app.use((req, res, next) => {
const origin = req.headers.origin
if (origin && allowList.has(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin)
res.setHeader('Vary', 'Origin')
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, name')
res.setHeader('Access-Control-Allow-Methods', 'GET, PUT, OPTIONS')
res.setHeader('Access-Control-Allow-Credentials', 'true')
res.setHeader('Access-Control-Expose-Headers', 'name')
res.setHeader('Access-Control-Max-Age', '6')
}
if (req.method === 'OPTIONS') {
res.sendStatus(204)
return
}
next()
})
app.put('/getData', (req, res) => {
res.setHeader('name', 'jw')
res.send('我不爱你')
})
app.listen(4000)
2.3 带凭据的 CORS 请求
客户端可以使用 withCredentials 或 Fetch 的 credentials: 'include' 请求携带 Cookie:
const xhr = new XMLHttpRequest()
xhr.withCredentials = true
xhr.open('PUT', 'http://localhost:4000/getData', true)
xhr.setRequestHeader('name', 'xiamen')
xhr.onreadystatechange = () => {
if (xhr.readyState === 4 && xhr.status >= 200 && xhr.status < 300) {
console.log(xhr.responseText)
// name 不是 CORS safelisted response header,服务端需要设置 Expose-Headers
console.log(xhr.getResponseHeader('name'))
}
}
xhr.send()
服务端必须返回具体的 Access-Control-Allow-Origin,不能返回 *,并且需要返回字符串形式的:
Access-Control-Allow-Credentials: true
Cookie 是否发送还要受到域名、路径、SameSite、Secure 和过期时间等规则影响。localhost:3000 与 localhost:4000 虽然不同源,但 Cookie 的域匹配不包含端口,因此不能简单写成“Cookie 不能跨域”。
3. postMessage
postMessage 是 Window 对象提供的跨文档消息通信 API,不是 XMLHttpRequest Level 2 的 API。它可以用于:
- 页面与新窗口之间通信;
- 多窗口之间通信;
- 页面与 iframe 之间通信;
- 以上场景中的跨源通信。
基本形式:
otherWindow.postMessage(message, targetOrigin, transfer)
message:通过结构化克隆算法传递的数据;targetOrigin:允许接收消息的目标源,应尽量使用明确的 Origin,不要随意使用*;transfer:可选的 Transferable 对象数组,转移所有权后发送方可能不再持有这些对象。
发送方示例:
<iframe src="http://localhost:4000/b.html" id="frame"></iframe>
<script>
const frame = document.getElementById('frame')
const targetOrigin = 'http://localhost:4000'
window.addEventListener('message', event => {
if (event.origin !== targetOrigin || event.source !== frame.contentWindow) {
return
}
console.log(event.data)
})
frame.addEventListener('load', () => {
frame.contentWindow.postMessage('我爱你', targetOrigin)
})
</script>
接收方示例:
const allowedOrigin = 'http://localhost:3000'
window.addEventListener('message', event => {
if (event.origin !== allowedOrigin || !event.source) {
return
}
console.log(event.data)
event.source.postMessage('我不爱你', allowedOrigin)
})
接收消息时必须验证 event.origin,必要时还要验证 event.source,否则任意窗口都可能发送伪造消息。
4. WebSocket
WebSocket 是一种全双工通信协议和浏览器 API。它通常先通过 HTTP Upgrade 完成握手,握手成功后切换为 WebSocket 帧通信,不再使用普通 HTTP 请求-响应模式。
WebSocket 跨源连接不依赖普通 CORS 响应头,但服务端应检查握手中的 Origin,并进行身份认证和权限控制。生产环境通常使用 wss://,即运行在 TLS 之上的 WebSocket。
客户端示例:
<script>
const socket = new WebSocket('ws://localhost:3000')
socket.onopen = () => {
socket.send('我爱你')
}
socket.onmessage = event => {
console.log(event.data)
}
</script>
服务端示例:
const WebSocket = require('ws')
const wss = new WebSocket.Server({ port: 3000 })
const allowedOrigin = 'http://localhost:5500'
wss.on('connection', (socket, request) => {
if (request.headers.origin !== allowedOrigin) {
socket.close(1008, 'origin not allowed')
return
}
socket.on('message', data => {
console.log(data.toString())
socket.send('我不爱你')
})
})
Socket.IO 不只是原生 WebSocket 的简单封装,它还包含连接管理、心跳、重连以及可能的长轮询降级机制,客户端和服务端需要使用兼容版本。
5. Node 中间件代理
实现原理是:浏览器只请求自己的同源代理服务器,代理服务器再以服务端身份请求目标服务器。服务器到服务器的请求不受浏览器同源策略限制,因此这不是严格意义上的“两次浏览器跨域”。
代理服务器通常需要:
- 接收客户端请求;
- 将请求方法、路径和请求体转发给目标服务器;
- 接收目标服务器响应;
- 转发状态码、响应头和响应体;
- 根据需要处理 CORS,并限制可代理的目标,避免 SSRF。

客户端示例:
<script>
fetch('http://localhost:3000/', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ name: 'xiamen', password: '123456' })
})
.then(response => response.json())
.then(result => console.log(result))
</script>
代理服务器示例:
const http = require('http')
const server = http.createServer((request, response) => {
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type'
}
if (request.method === 'OPTIONS') {
response.writeHead(204, corsHeaders)
response.end()
return
}
const proxyRequest = http.request(
{
hostname: '127.0.0.1',
port: 4000,
path: request.url,
method: request.method,
headers: {
...request.headers,
host: '127.0.0.1:4000'
}
},
proxyResponse => {
response.writeHead(proxyResponse.statusCode || 502, {
...proxyResponse.headers,
...corsHeaders
})
proxyResponse.pipe(response)
}
)
proxyRequest.on('error', error => {
console.error(error)
if (!response.headersSent) {
response.writeHead(502, corsHeaders)
}
response.end('Bad Gateway')
})
// 转发客户端请求体,不能直接调用 proxyRequest.end() 丢弃 POST 数据
request.pipe(proxyRequest)
})
server.listen(3000, () => {
console.log('The proxy server is running at http://localhost:3000')
})
目标服务器示例:
const http = require('http')
const data = { title: 'frontend', password: '123456' }
const server = http.createServer((request, response) => {
if (request.url === '/') {
response.setHeader('Content-Type', 'application/json; charset=utf-8')
response.end(JSON.stringify(data))
return
}
response.statusCode = 404
response.end('Not Found')
})
server.listen(4000, () => {
console.log('The target server is running at http://localhost:4000')
})
生产环境中还需要正确处理响应状态码、二进制数据、超时、连接复用、Hop-by-Hop Headers、请求体大小以及目标地址白名单。
6. Nginx 反向代理
Nginx 反向代理与 Node 代理的原理类似。最理想的情况是让前端和 Nginx 使用相同的协议、主机和端口,再通过路径转发到后端,这样浏览器看到的是同源请求,不需要额外 CORS。
如果前端访问 www.domain1.com,代理却使用 www.domain1.com:81,由于端口不同,仍然是跨源请求,需要正确配置 CORS。反向代理会增加一跳转发,并不能保证“完全没有性能影响”。
示例配置:
server {
listen 81;
server_name www.domain1.com;
location /api/ {
proxy_pass http://www.domain2.com:8080;
proxy_set_header Host www.domain2.com;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# 将后端 Cookie 的 Domain 改写为前端域名
proxy_cookie_domain www.domain2.com www.domain1.com;
# 只有在确实需要跨源时才配置 CORS
add_header Access-Control-Allow-Origin "http://www.domain1.com" always;
add_header Access-Control-Allow-Credentials "true" always;
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" always;
add_header Access-Control-Allow-Headers "Content-Type, Authorization" always;
}
}
proxy_cookie_domain 只修改 Cookie 的 Domain,不会自动解决 SameSite、Secure、路径和认证策略问题。Cookie 不包含端口,因此不能通过修改 Cookie Domain 来解决所有跨源问题。
修改配置后可以使用:
nginx -t
nginx -s reload
其中 nginx -s reload 用于重新加载已经运行的 Nginx;首次启动需要先启动 Nginx 进程。
7. window.name + iframe
历史上,window.name 在同一个浏览上下文导航到其他页面后仍可能保留,因此有人让外域页面先把数据写入 window.name,再导航到同源中间页,最后由父页面读取。
这种方式依赖浏览器历史行为,容量也没有统一的 2 MB 标准,现代浏览器还可能对跨站 window.name 进行隔离或重置,不建议用于新项目。跨窗口通信应优先使用 postMessage。
示意代码:
<!-- a.html,http://localhost:3000/a.html -->
<iframe
src="http://localhost:4000/c.html"
id="iframe"
onload="load()"
></iframe>
<script>
let first = true
const iframe = document.getElementById('iframe')
function load() {
if (first) {
// 第一次加载外域页面,外域页面把数据写入 window.name
first = false
iframe.src = 'http://localhost:3000/b.html'
} else {
// 第二次加载同源页面后,父页面才能读取 iframe 的 window.name
console.log(iframe.contentWindow.name)
}
}
</script>
<!-- c.html,http://localhost:4000/c.html -->
<script>
window.name = '我不爱你'
</script>
8. location.hash + iframe
location.hash 是 URL 片段标识符。跨源页面不能直接读取彼此的 DOM,但可以在一定条件下设置其他窗口的位置;通过中间 iframe,可以把少量数据放入 hash,再由同源页面读取。
这种方式存在 URL 长度限制,数据会出现在地址栏和浏览历史中,不适合传递密码、令牌等敏感信息。实际使用时还应进行 URL 编码,并防止把 hash 直接拼接进 HTML。
示意代码:
<!-- a.html,http://localhost:3000/a.html -->
<iframe src="http://localhost:4000/c.html#iloveyou"></iframe>
<script>
window.addEventListener('hashchange', () => {
console.log(decodeURIComponent(location.hash.slice(1)))
})
</script>
<!-- c.html,http://localhost:4000/c.html -->
<script>
const iframe = document.createElement('iframe')
iframe.src = 'http://localhost:3000/b.html#idontloveyou'
document.body.appendChild(iframe)
</script>
<!-- b.html,与 a.html 同源 -->
<script>
// b.html 可以把结果写入顶层页面的 hash,但不能读取跨源页面的 DOM
window.parent.parent.location.hash = location.hash
</script>
现代项目更推荐使用 postMessage,因为它可以明确指定目标 Origin,并且不需要把数据放进 URL。
9. document.domain + iframe
document.domain 曾用于放宽同一注册域名下页面的同源限制,例如:
a.example.com
b.example.com
两个页面都设置相同的 document.domain 后,历史上可以访问部分 DOM:
document.domain = 'example.com'
但 document.domain 已被现代 Web 平台废弃,不建议用于新项目。它只能用于满足特定条件的相关域名,不能跨协议访问任意域名,也要求两个页面都进行兼容设置。设置后还可能改变端口相关的 Origin 行为,带来额外的安全风险。
历史示例:
<!-- a.html,http://a.zf1.cn:3000/a.html -->
<body>
helloa
<iframe
src="http://b.zf1.cn:3000/b.html"
id="frame"
onload="load()"
></iframe>
<script>
document.domain = 'zf1.cn'
const frame = document.getElementById('frame')
function load() {
console.log(frame.contentWindow.a)
}
</script>
</body>
<!-- b.html,http://b.zf1.cn:3000/b.html -->
<body>
hellob
<script>
document.domain = 'zf1.cn'
window.a = 100
</script>
</body>
新项目应优先使用 CORS、同源反向代理或 postMessage。
三、总结
- CORS 是浏览器跨源读取 HTTP 响应的标准方案,但需要服务端明确配置 Origin、方法、请求头和凭据,不能简单说“支持所有类型请求”。
- JSONP 只能使用 GET,并且必须由目标服务器主动支持;它返回可执行脚本,存在 XSS 风险。
- Node 中间件代理和 Nginx 反向代理利用的是服务器端不受浏览器同源策略限制的特点,生产环境还需要处理认证、Cookie、超时、状态码和安全白名单。
postMessage适合跨窗口和 iframe 通信,但接收方必须校验event.origin和event.source。- WebSocket 不依赖普通 CORS,但服务端应校验
Origin并进行身份认证。 window.name、Hash iframe 和document.domain属于历史兼容方案,新项目一般不建议使用。
作者:浪里行舟
原文链接:https://juejin.cn/post/6844903767226351623
来源:稀土掘金。著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。