同源策略与跨域解决方案
浏览器同源策略的限制及 6 种跨域方案实现
问题
什么是浏览器的同源策略?有哪些跨域解决方案?
解答
同源策略
同源策略是浏览器的安全机制,要求协议、域名、端口三者完全相同才算同源。
http://example.com/a.html
http://example.com/b.html ✅ 同源
https://example.com/a.html ❌ 协议不同
http://api.example.com/a.html ❌ 域名不同
http://example.com:8080/a.html ❌ 端口不同
受限制的操作:
- AJAX 请求
- DOM 访问
- Cookie、LocalStorage、IndexedDB 读取
1. CORS(跨域资源共享)
最标准的跨域方案,需要服务端配合设置响应头。
// 前端:正常发请求
fetch('http://api.example.com/data', {
method: 'POST',
credentials: 'include', // 携带 cookie
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ name: 'test' })
})
// 服务端:Node.js Express
app.use((req, res, next) => {
// 允许的源,不能用 * 如果要携带 cookie
res.setHeader('Access-Control-Allow-Origin', 'http://example.com')
// 允许的方法
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE')
// 允许的自定义头
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization')
// 允许携带 cookie
res.setHeader('Access-Control-Allow-Credentials', 'true')
// 预检请求缓存时间
res.setHeader('Access-Control-Max-Age', '86400')
// 处理预检请求
if (req.method === 'OPTIONS') {
return res.sendStatus(204)
}
next()
})
简单请求 vs 预检请求:
// 简单请求:直接发送
// 条件:GET/HEAD/POST + 简单头 + Content-Type 为 text/plain、multipart/form-data、application/x-www-form-urlencoded
// 预检请求:先发 OPTIONS
// 触发条件:PUT/DELETE、自定义头、Content-Type 为 application/json 等
2. JSONP
利用 <script> 标签不受同源限制的特性,只支持 GET 请求。
// 封装 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)
// 清理
document.head.removeChild(script)
delete window[fnName]
}
// 创建 script 标签
const script = document.createElement('script')
script.src = `${url}?${callbackName}=${fnName}`
script.onerror = () => {
reject(new Error('JSONP request failed'))
document.head.removeChild(script)
delete window[fnName]
}
document.head.appendChild(script)
})
}
// 使用
jsonp('http://api.example.com/data')
.then(data => console.log(data))
// 服务端返回格式
// jsonp_xxx({ "name": "test", "age": 18 })
app.get('/data', (req, res) => {
const callback = req.query.callback
const data = { name: 'test', age: 18 }
res.send(`${callback}(${JSON.stringify(data)})`)
})
3. Nginx 反向代理
通过同源的 Nginx 服务器转发请求,前端无感知。
server {
listen 80;
server_name example.com;
# 前端静态资源
location / {
root /var/www/html;
index index.html;
}
# API 代理
location /api/ {
# 转发到后端服务
proxy_pass http://api.backend.com/;
# 传递真实 IP
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
// 前端请求同源地址
fetch('/api/users') // 实际转发到 http://api.backend.com/users
4. postMessage
用于窗口间通信,如 iframe、window.open 打开的页面。
<!-- 父页面:http://parent.com -->
<iframe id="child" src="http://child.com/page.html"></iframe>
<script>
const iframe = document.getElementById('child')
// 发送消息
iframe.onload = () => {
iframe.contentWindow.postMessage(
{ type: 'greeting', data: 'Hello from parent' },
'http://child.com' // 目标源,* 表示不限制
)
}
// 接收消息
window.addEventListener('message', (e) => {
// 验证来源
if (e.origin !== 'http://child.com') return
console.log('收到子页面消息:', e.data)
})
</script>
<!-- 子页面:http://child.com -->
<script>
// 接收消息
window.addEventListener('message', (e) => {
// 验证来源
if (e.origin !== 'http://parent.com') return
console.log('收到父页面消息:', e.data)
// 回复消息
e.source.postMessage(
{ type: 'reply', data: 'Hello from child' },
e.origin
)
})
</script>
5. WebSocket
WebSocket 协议不受同源策略限制。
// 前端
const ws = new WebSocket('ws://api.example.com/socket')
ws.onopen = () => {
console.log('连接建立')
ws.send(JSON.stringify({ type: 'subscribe', channel: 'news' }))
}
ws.onmessage = (e) => {
const data = JSON.parse(e.data)
console.log('收到消息:', data)
}
ws.onerror = (e) => console.error('连接错误:', e)
ws.onclose = () => console.log('连接关闭')
// 服务端:Node.js + ws
const WebSocket = require('ws')
const wss = new WebSocket.Server({ port: 8080 })
wss.on('connection', (ws, req) => {
// 可以检查 origin
const origin = req.headers.origin
console.log('客户端连接:', origin)
ws.on('message', (message) => {
const data = JSON.parse(message)
// 处理消息并回复
ws.send(JSON.stringify({ type: 'response', data: 'received' }))
})
})
6. document.domain
仅适用于主域相同的子域之间,如 a.example.com 和 b.example.com。
<!-- http://a.example.com/page.html -->
<iframe id="child" src="http://b.example.com/page.html"></iframe>
<script>
// 设置为主域
document.domain = 'example.com'
// 现在可以访问 iframe 的内容
const iframe = document.getElementById('child')
iframe.onload = () => {
// 直接访问 iframe 的 window 和 document
console.log(iframe.contentWindow.document.body)
}
</script>
<!-- http://b.example.com/page.html -->
<script>
// 子页面也要设置
document.domain = 'example.com'
</script>
方案对比
| 方案 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| CORS | 通用 API 请求 | 标准方案,支持所有请求类型 | 需要服务端配合 |
| JSONP | 兼容老浏览器 | 兼容性好 | 只支持 GET,有安全风险 |
| Nginx 反代 | 生产环境 | 前端无感知 | 需要运维配置 |
| postMessage | 窗口通信 | 安全可控 | 仅限窗口间 |
| WebSocket | 实时通信 | 全双工,不受限制 | 需要专门的服务端 |
| document.domain | 子域通信 | 简单 | 仅限主域相同,已废弃 |
关键点
- 同源 = 协议 + 域名 + 端口完全相同
- CORS 是标准方案,注意简单请求和预检请求的区别
- JSONP 利用 script 标签特性,只能 GET,有 XSS 风险
- postMessage 必须验证 origin 防止安全问题
- document.domain 已被废弃,不推荐使用
目录