同源策略与跨域解决方案

浏览器同源策略的限制及 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.comb.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 已被废弃,不推荐使用