跨域通信解决方案完全指南

HTMLPAGE 团队
18分钟 分钟阅读

深入解析浏览器同源策略与跨域通信机制,涵盖 CORS 配置、代理转发、JSONP、postMessage、WebSocket 等方案,以及在 Nuxt/Node.js 中的实战配置。

#跨域 #CORS #同源策略 #代理 #前端安全

跨域通信解决方案完全指南

「No 'Access-Control-Allow-Origin' header is present on the requested resource.」——几乎每个前端开发者都见过这个令人头疼的错误。

跨域问题源于浏览器的同源策略,这是一种重要的安全机制。理解它的原理和各种解决方案,是前端开发的必备技能。

本文将系统讲解跨域的本质、各种解决方案的原理和适用场景,以及在实际项目中的配置方法。


理解同源策略

什么是同源

同源是指两个 URL 的协议、域名、端口完全相同。

https://example.com:443/page

协议    域名          端口  路径

同源判断示例:

URLhttps://example.com 比较结果
https://example.com/page同源
https://example.com:443/api同源(443 是 https 默认端口)
http://example.com协议不同
https://api.example.com域名不同(子域名也算)
https://example.com:8080端口不同
https://example.org域名不同

同源策略限制什么

受限制的操作:

  1. AJAX 请求:不能读取跨域响应
  2. DOM 访问:不能操作跨域 iframe 的 DOM
  3. Cookie/Storage:不能读取跨域的存储数据

不受限制的资源加载:

  • <script src="...">
  • <link href="...">
  • <img src="...">
  • <video><audio>
  • <iframe>(可加载,但不能访问其 DOM)

为什么需要同源策略

防止恶意网站窃取数据:

假设没有同源策略:

  1. 你登录了银行网站 bank.com,有一个有效的 session
  2. 你访问了恶意网站 evil.com
  3. evil.com 的 JS 可以直接请求 bank.com/api/account
  4. 请求会自动携带你的 cookie
  5. 恶意网站获取了你的银行账户信息

同源策略阻止了第 3 步的跨域请求,保护了用户数据安全。


CORS:标准跨域解决方案

CORS 的工作原理

CORS(Cross-Origin Resource Sharing) 是 W3C 标准,允许服务器声明哪些源可以访问其资源。

简单请求流程:

浏览器                           服务器
  |                               |
  | -------- 请求 -------->       |
  | Origin: https://example.com   |
  |                               |
  | <-------- 响应 --------       |
  | Access-Control-Allow-Origin:  |
  | https://example.com           |
  |                               |
浏览器检查响应头,允许则正常处理

预检请求流程(复杂请求):

浏览器                           服务器
  |                               |
  | ------ OPTIONS 预检 ---->     |
  | Origin: https://example.com   |
  | Access-Control-Request-Method:|
  | POST                          |
  | Access-Control-Request-Headers:|
  | Content-Type, Authorization   |
  |                               |
  | <------ 预检响应 -------      |
  | Access-Control-Allow-Origin:  |
  | https://example.com           |
  | Access-Control-Allow-Methods: |
  | GET, POST, PUT                |
  | Access-Control-Allow-Headers: |
  | Content-Type, Authorization   |
  | Access-Control-Max-Age: 86400 |
  |                               |
预检通过后,发送实际请求
  |                               |
  | -------- 实际请求 ----->      |
  | <-------- 实际响应 -----      |

简单请求 vs 预检请求

简单请求条件(必须同时满足):

  1. 方法:GETHEADPOST
  2. Content-Type:
    • text/plain
    • multipart/form-data
    • application/x-www-form-urlencoded
  3. 不包含自定义请求头

触发预检的情况:

  • 使用 PUTDELETEPATCH 方法
  • Content-Type 为 application/json
  • 包含自定义请求头(如 Authorization

CORS 响应头详解

响应头说明示例值
Access-Control-Allow-Origin允许的源https://example.com*
Access-Control-Allow-Methods允许的方法GET, POST, PUT, DELETE
Access-Control-Allow-Headers允许的请求头Content-Type, Authorization
Access-Control-Allow-Credentials是否允许携带凭证true
Access-Control-Expose-Headers允许前端访问的响应头X-Custom-Header
Access-Control-Max-Age预检结果缓存时间(秒)86400

服务端 CORS 配置

Node.js (Express):

const cors = require('cors')

// 简单配置:允许所有源
app.use(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(cors({
  origin: (origin, callback) => {
    const allowedOrigins = ['https://example.com', 'https://app.example.com']
    
    // 允许无 origin 的请求(如本地文件、Postman)
    if (!origin) return callback(null, true)
    
    if (allowedOrigins.includes(origin)) {
      callback(null, true)
    } else {
      callback(new Error('Not allowed by CORS'))
    }
  }
}))

Nginx:

server {
    location /api/ {
        # 设置 CORS 头
        add_header 'Access-Control-Allow-Origin' 'https://example.com' always;
        add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS' always;
        add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization' always;
        add_header 'Access-Control-Allow-Credentials' 'true' always;
        add_header 'Access-Control-Max-Age' '86400' always;
        
        # 处理预检请求
        if ($request_method = 'OPTIONS') {
            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-Max-Age' '86400';
            add_header 'Content-Length' '0';
            add_header 'Content-Type' 'text/plain charset=UTF-8';
            return 204;
        }
        
        proxy_pass http://backend;
    }
}

Nuxt 3 (Nitro):

// server/middleware/cors.ts
export default defineEventHandler((event) => {
  const origin = getHeader(event, 'origin')
  const allowedOrigins = ['https://example.com', 'https://app.example.com']
  
  if (origin && allowedOrigins.includes(origin)) {
    setHeader(event, 'Access-Control-Allow-Origin', origin)
    setHeader(event, 'Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS')
    setHeader(event, 'Access-Control-Allow-Headers', 'Content-Type, Authorization')
    setHeader(event, 'Access-Control-Allow-Credentials', 'true')
    setHeader(event, 'Access-Control-Max-Age', '86400')
  }
  
  // 处理预检请求
  if (event.method === 'OPTIONS') {
    event.node.res.statusCode = 204
    event.node.res.end()
    return
  }
})

代理转发

当你无法控制后端 CORS 配置时,可以通过代理绕过跨域限制。

原理

浏览器 ---> 同源代理服务器 ---> 目标 API 服务器
        (无跨域问题)      (服务器间无同源限制)

Vite 开发代理

// vite.config.ts
export default defineConfig({
  server: {
    proxy: {
      '/api': {
        target: 'https://api.example.com',
        changeOrigin: true,
        rewrite: (path) => path.replace(/^\/api/, '')
      },
      // 多个代理
      '/auth': {
        target: 'https://auth.example.com',
        changeOrigin: true
      }
    }
  }
})

前端请求:

// 实际发送到 https://api.example.com/users
fetch('/api/users')

// 实际发送到 https://auth.example.com/login
fetch('/auth/login')

Nuxt 代理配置

开发环境代理:

// nuxt.config.ts
export default defineNuxtConfig({
  nitro: {
    devProxy: {
      '/api/': {
        target: 'https://api.example.com',
        changeOrigin: true
      }
    }
  }
})

生产环境代理(通过路由):

// server/api/proxy/[...path].ts
export default defineEventHandler(async (event) => {
  const path = getRouterParam(event, 'path')
  const targetUrl = `https://api.example.com/${path}`
  
  // 转发请求
  return proxyRequest(event, targetUrl, {
    headers: {
      'X-Forwarded-For': getHeader(event, 'x-forwarded-for') || ''
    }
  })
})

Nginx 反向代理

server {
    listen 80;
    server_name example.com;
    
    # 前端静态文件
    location / {
        root /var/www/html;
        try_files $uri $uri/ /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;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

postMessage:跨文档通信

window.postMessage() 用于在不同源的窗口间安全通信。

使用场景

  • iframe 与父页面通信
  • 主窗口与弹出窗口通信
  • 不同标签页间通信(需通过 SharedWorker 或 localStorage)

基本用法

发送消息:

// 向 iframe 发送消息
const iframe = document.getElementById('myIframe')
iframe.contentWindow.postMessage(
  { type: 'greeting', data: 'Hello from parent' },
  'https://child.example.com'  // 指定目标源,安全考虑
)

// 向父窗口发送消息(在 iframe 内)
window.parent.postMessage(
  { type: 'response', data: 'Hello from child' },
  'https://parent.example.com'
)

接收消息:

window.addEventListener('message', (event) => {
  // 安全检查:验证消息来源
  if (event.origin !== 'https://trusted.example.com') {
    console.warn('Untrusted origin:', event.origin)
    return
  }
  
  // 处理消息
  const { type, data } = event.data
  
  switch (type) {
    case 'greeting':
      console.log('Received greeting:', data)
      break
    case 'request':
      handleRequest(data, event.source)
      break
  }
})

function handleRequest(data, source) {
  // 处理请求并回复
  const result = processData(data)
  source.postMessage(
    { type: 'response', data: result },
    event.origin
  )
}

封装通信模块

// utils/postMessage.ts
interface MessagePayload {
  type: string
  id: string
  data: any
}

type MessageHandler = (data: any) => any | Promise<any>

class CrossOriginMessenger {
  private handlers = new Map<string, MessageHandler>()
  private pendingRequests = new Map<string, {
    resolve: (value: any) => void
    reject: (error: any) => void
  }>()
  private targetOrigin: string
  private targetWindow: Window
  
  constructor(targetWindow: Window, targetOrigin: string) {
    this.targetWindow = targetWindow
    this.targetOrigin = targetOrigin
    
    window.addEventListener('message', this.handleMessage.bind(this))
  }
  
  // 注册消息处理器
  on(type: string, handler: MessageHandler) {
    this.handlers.set(type, handler)
  }
  
  // 发送消息(无需响应)
  send(type: string, data: any) {
    const payload: MessagePayload = {
      type,
      id: this.generateId(),
      data
    }
    this.targetWindow.postMessage(payload, this.targetOrigin)
  }
  
  // 发送请求(等待响应)
  async request<T>(type: string, data: any): Promise<T> {
    const id = this.generateId()
    const payload: MessagePayload = { type, id, data }
    
    return new Promise((resolve, reject) => {
      this.pendingRequests.set(id, { resolve, reject })
      this.targetWindow.postMessage(payload, this.targetOrigin)
      
      // 超时处理
      setTimeout(() => {
        if (this.pendingRequests.has(id)) {
          this.pendingRequests.delete(id)
          reject(new Error('Request timeout'))
        }
      }, 30000)
    })
  }
  
  private async handleMessage(event: MessageEvent) {
    if (event.origin !== this.targetOrigin) return
    
    const { type, id, data } = event.data as MessagePayload
    
    // 处理响应
    if (type === 'response' && this.pendingRequests.has(id)) {
      const { resolve } = this.pendingRequests.get(id)!
      this.pendingRequests.delete(id)
      resolve(data)
      return
    }
    
    // 处理请求
    const handler = this.handlers.get(type)
    if (handler) {
      try {
        const result = await handler(data)
        event.source?.postMessage(
          { type: 'response', id, data: result },
          { targetOrigin: event.origin }
        )
      } catch (error) {
        event.source?.postMessage(
          { type: 'response', id, data: { error: error.message } },
          { targetOrigin: event.origin }
        )
      }
    }
  }
  
  private generateId(): string {
    return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
  }
  
  destroy() {
    window.removeEventListener('message', this.handleMessage)
  }
}

export { CrossOriginMessenger }

使用示例:

// 父页面
const iframe = document.getElementById('myIframe') as HTMLIFrameElement
const messenger = new CrossOriginMessenger(
  iframe.contentWindow!,
  'https://child.example.com'
)

// 发送请求
const userInfo = await messenger.request('getUserInfo', { userId: 123 })

// 接收事件
messenger.on('childEvent', (data) => {
  console.log('Received from child:', data)
})

WebSocket 跨域

WebSocket 不受同源策略限制,但需要服务端配合。

WebSocket 连接

// 直接连接跨域 WebSocket
const ws = new WebSocket('wss://api.example.com/ws')

ws.onopen = () => {
  console.log('WebSocket connected')
  ws.send(JSON.stringify({ type: 'subscribe', channel: 'updates' }))
}

ws.onmessage = (event) => {
  const data = JSON.parse(event.data)
  console.log('Received:', data)
}

ws.onerror = (error) => {
  console.error('WebSocket error:', error)
}

服务端验证

虽然浏览器不阻止 WebSocket 跨域连接,但服务端应该验证 Origin:

// Node.js (ws 库)
const WebSocket = require('ws')

const wss = new WebSocket.Server({ port: 8080 })

wss.on('connection', (ws, request) => {
  const origin = request.headers.origin
  
  // 验证来源
  const allowedOrigins = ['https://example.com', 'https://app.example.com']
  if (!allowedOrigins.includes(origin)) {
    ws.close(1008, 'Origin not allowed')
    return
  }
  
  ws.on('message', (message) => {
    // 处理消息
  })
})

其他跨域方案

JSONP(已过时)

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

// 定义回调函数
window.handleResponse = (data) => {
  console.log('Received:', data)
  delete window.handleResponse
}

// 动态创建 script 标签
const script = document.createElement('script')
script.src = 'https://api.example.com/data?callback=handleResponse'
document.body.appendChild(script)

服务端返回:

handleResponse({"name": "John", "age": 30})

注意:JSONP 存在安全风险,现代应用应使用 CORS。

document.domain(同主域)

对于同一主域下的子域名,可以通过设置 document.domain 实现通信:

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

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

// 现在两个页面可以互相访问 DOM

限制:只能用于同主域的子域名。


携带凭证的跨域请求

默认情况下,跨域请求不会携带 Cookie。如需携带:

前端配置:

// Fetch
fetch('https://api.example.com/data', {
  credentials: 'include'  // 携带 cookie
})

// Axios
axios.get('https://api.example.com/data', {
  withCredentials: true
})

服务端配置:

// 必须设置 credentials 为 true
res.setHeader('Access-Control-Allow-Credentials', 'true')

// 注意:当 credentials 为 true 时,Origin 不能是 *
res.setHeader('Access-Control-Allow-Origin', 'https://example.com')

常见问题

问题 1:Origin 设置为 * 时无法携带凭证

错误:The value of the 'Access-Control-Allow-Origin' header must not be 
the wildcard '*' when the request's credentials mode is 'include'.

解决:使用具体的 Origin 值

问题 2:Cookie 未设置 SameSite

// 服务端设置 Cookie 时
res.cookie('token', value, {
  httpOnly: true,
  secure: true,
  sameSite: 'None'  // 跨站请求需要设置为 None
})

调试跨域问题

浏览器开发者工具

Network 面板检查点:

  1. 查看请求头中的 Origin
  2. 查看响应头中的 Access-Control-Allow-Origin
  3. 检查是否有 OPTIONS 预检请求
  4. 查看预检请求的响应状态

常见错误排查

错误 1:No 'Access-Control-Allow-Origin' header

原因:服务端未返回 CORS 头
检查:确认服务端已配置 CORS

错误 2:CORS header contains multiple values

原因:CORS 头被设置了多次
检查:Nginx 和后端是否都配置了 CORS
解决:只在一处配置

错误 3:Preflight response is not successful

原因:OPTIONS 预检请求失败
检查:
- 服务端是否处理 OPTIONS 请求
- 是否返回了正确的 CORS 头
- 防火墙/网关是否拦截了 OPTIONS

错误 4:Credential is not supported if CORS origin is '*'

原因:携带凭证时 Origin 不能为 *
解决:设置具体的 Origin 值

调试脚本

// 在控制台运行,测试 CORS 配置
async function testCors(url) {
  console.log('Testing CORS for:', url)
  
  try {
    // 简单请求
    const simpleResponse = await fetch(url)
    console.log('Simple request headers:', 
      [...simpleResponse.headers.entries()])
    
    // 预检请求
    const preflightResponse = await fetch(url, {
      method: 'OPTIONS',
      headers: {
        'Access-Control-Request-Method': 'POST',
        'Access-Control-Request-Headers': 'Content-Type'
      }
    })
    console.log('Preflight response headers:', 
      [...preflightResponse.headers.entries()])
    
  } catch (error) {
    console.error('CORS test failed:', error)
  }
}

testCors('https://api.example.com/test')

最佳实践

安全建议

  1. 明确指定允许的源
    // ❌ 避免
    res.setHeader('Access-Control-Allow-Origin', '*')
    

// ✅ 推荐 res.setHeader('Access-Control-Allow-Origin', 'https://example.com')


2. **限制允许的方法和头**
```javascript
// 只开放必要的方法
res.setHeader('Access-Control-Allow-Methods', 'GET, POST')

// 只开放必要的请求头
res.setHeader('Access-Control-Allow-Headers', 'Content-Type')
  1. 验证 postMessage 来源
    window.addEventListener('message', (event) => {
      if (event.origin !== 'https://trusted.example.com') {
        return
      }
      // ...
    })
    

方案选择指南

场景推荐方案
标准 API 调用CORS(服务端配置)
无法修改服务端代理转发
iframe 通信postMessage
实时通信WebSocket
开发环境Vite/Webpack 代理

检查清单

  • 确认是否真的需要跨域(同源请求无需处理)
  • 优先考虑 CORS(标准、安全)
  • 开发环境配置代理
  • 生产环境统一域名或配置 CORS
  • 携带凭证时使用具体 Origin
  • postMessage 验证消息来源
  • 定期审查 CORS 配置安全性

总结

跨域问题的本质是浏览器的安全策略。理解同源策略的目的,才能正确选择解决方案:

  1. CORS:标准方案,需要服务端配合
  2. 代理:无法修改服务端时的替代方案
  3. postMessage:跨文档通信的专用方案
  4. WebSocket:实时通信场景

核心原则:安全第一。不要为了方便而开放不必要的跨域权限,明确指定允许的源、方法和请求头,才能在保证功能的同时确保安全。