跨域
约 2507 字大约 8 分钟
2026-02-27
跨域通常指浏览器环境下,页面源(协议 + 域名 + 端口)与目标资源源不一致时触发的安全限制
跨域方式总览
| 方式 | 是否主流 | 适用场景 | 核心限制 |
|---|---|---|---|
| CORS | 是 | 前后端 API 请求 | 服务端必须正确返回 CORS 响应头 |
| 前端代理 | 是 | 本地开发、联调 | 仅开发环境生效 |
| 反向代理(Nginx/BFF) | 是 | 统一网关、生产环境 | 需要运维或网关层支持 |
| JSONP | 否(历史) | 只读公开 GET 接口 | 仅支持 GET,安全性弱 |
| postMessage | 是 | iframe / 多窗口通信 | 必须校验 origin |
| WebSocket | 是 | 实时通信 | 需处理鉴权、心跳、重连 |
| document.domain | 否(历史) | 同主域子域通信 | 仅限同主域,现代场景很少用 |
| window.name + iframe | 否(历史) | 老项目兼容 | 实现复杂,可维护性差 |
| location.hash + iframe | 否(历史) | 老项目兼容 | 仅适合小量消息传递 |
| 资源标签跨域(img/script/link) | 部分 | 资源加载/CDN | 通常可加载不可读返回细节 |
主流方案
前端代理
开发环境下,前端页面(如 http://localhost:5173)与后端 API(如 http://localhost:3000)不同源,会触发 CORS
相关信息
开发用前端代理,生产用反向代理,原理相同,只是部署层不同。前端代理仅在开发环境生效,生产构建后需通过 Nginx 或 BFF 做反向代理
前端代理由 Vite、Webpack 等开发服务器将 /api 等路径转发到真实后端,浏览器只看到同源请求,从而规避跨域
import { defineConfig } from 'vite'
export default defineConfig({
server: {
proxy: {
// /api -> http://localhost:3000
'/api': {
target: 'http://localhost:3000',
changeOrigin: true, // 修改 Host 头为目标域名
},
// 正则匹配
'^/v2/': {
target: 'https://api.example.com',
changeOrigin: true,
},
},
},
})module.exports = {
devServer: {
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true,
},
},
},
}// 开发时请求同源 /api,由 dev server 转发到 http://localhost:3000
const res = await fetch('/api/user')
const data = await res.json()CORS
CORS(Cross-Origin Resource Sharing)是基于 HTTP1.1 的跨域解决方案,其思路是:浏览器会根据请求类型决定是否先发预检[+Preflight]。服务端返回允许策略后,浏览器才把响应交给 JS
1.简单请求
简单请求:满足以下条件时,浏览器不发预检,直接发起请求:
| 条件 | 允许范围 |
|---|---|
| 方法 | GET、HEAD、POST 之一 |
| Content-Type | text/plain、multipart/form-data、application/x-www-form-urlencoded 之一 |
| 其他请求头 | 仅限 CORS 安全列表(如 Accept、Accept-Language、Content-Language 等) |
2.预检请求
若使用 Content-Type: application/json 或自定义头(如 X-CSRF-Token),则属于非简单请求,会先发 OPTIONS 预检。该请求的目的就是询问服务器,是否允许后续的真是请求
预检请求没有请求体,只是包含了后续要真是请求要做的事情
OPTIONS /api/user HTTP/1.1
Host: localhost:3000
Origin: http://localhost:5173
Access-Control-Request-Method: GET
Access-Control-Request-Headers: Content-Type, X-CSRF-TokenHTTP/1.1 200 OK
Date: Fri, 27 Feb 2026 00:30:00 GMT
Access-Control-Allow-Origin: http://localhost:5173
Access-Control-Allow-Methods: GET,POST,PUT,DELETE,OPTIONS
Access-Control-Allow-Headers: Content-Type, X-CSRF-Token
Access-Control-Max-Age: 6003.携带身份凭证
当请求需要携带 Cookie 时,不论是简单请求还是预检请求,都会在请求头中添加 cookie 字段,且请求需要额外设置 credentials: 'include' 在服务器响应时,需要返回 Access-Control-Allow-Credentials: true 表示允许携带身份凭证
笔记
对于附带身份凭证的请求,服务器不得设置 Access-Control-Allow-Origin 为 *,必须指定具体的源,这是因为携带身份凭证的请求在跨域时会自动发送 cookie 字段,如果服务器返回 *,则浏览器会拒绝请求
Access-Control-Expose-Headers
默认情况下,跨域响应只有部分 "简单" 响应头对 JS 可见(如 Cache-Control、Content-Language、Content-Type、Expires、Last-Modified、Pragma)。自定义头(如 X-Request-Id、X-Total-Count)需通过 Access-Control-Expose-Headers 显式列出,前端才能用 response.getResponseHeader() 读取:
Access-Control-Expose-Headers: X-Request-Id, X-Total-Count面试考点: 如何判断请求
首先查看请求是否携带了 withCredentials 这个请求头,如果携带了,那么进行预检请求,且请求头中会携带 cookie 字段。响应头中也会携带 Access-Control-Allow-Credentials: true 表示允许携带身份凭证。如果不需要身份凭证都那么查看请求是否是简单请求,是则为简单请求,否则为预检请求
4.服务端具体配置示例
import http from 'node:http'
const ALLOW_ORIGIN = 'http://localhost:5173'
const server = http.createServer((req, res) => {
// 1) 允许的来源
res.setHeader('Access-Control-Allow-Origin', ALLOW_ORIGIN)
// 2) 允许携带凭证(Cookie)
res.setHeader('Access-Control-Allow-Credentials', 'true')
// 3) 允许的方法与头
res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,DELETE,OPTIONS')
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-CSRF-Token')
// 4) 预检结果缓存时间(秒)
res.setHeader('Access-Control-Max-Age', '600')
// 预检请求直接返回 204
if (req.method === 'OPTIONS') {
res.writeHead(204)
res.end()
return
}
if (req.url === '/api/user' && req.method === 'GET') {
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' })
res.end(JSON.stringify({ code: 0, data: { id: 1, name: 'zhaojisen' } }))
return
}
res.writeHead(404)
res.end('Not Found')
})
server.listen(3000, () => {
console.log('CORS API: http://localhost:3000')
})<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>CORS Demo</title>
</head>
<body>
<button id="btn">请求跨域 API</button>
<pre id="log"></pre>
<script>
const log = document.getElementById('log')
document.getElementById('btn').addEventListener('click', async () => {
try {
const res = await fetch('http://localhost:3000/api/user', {
method: 'GET',
credentials: 'include', // 需要 cookie 时必须带上
headers: {
'X-CSRF-Token': 'demo-token',
},
})
const data = await res.json()
log.textContent = JSON.stringify(data, null, 2)
} catch (err) {
log.textContent = String(err)
}
})
</script>
</body>
</html>反向代理
前端访问同源 /api,由 Nginx 或 BFF 转发到真实后端,这样浏览器视角就不是跨域。
server {
listen 80;
server_name demo.local;
# 前端静态资源
location / {
root /var/www/app;
try_files $uri /index.html;
}
# 关键:同源 /api 代理到真实后端
location /api/ {
proxy_pass http://127.0.0.1:3000/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}// 浏览器只看到同源 /api,不触发 CORS
const res = await fetch('/api/user')
const data = await res.json()
console.log(data)为什么很多公司线上更偏向“代理 + 网关”
- 安全策略集中在网关,减少每个服务重复配置 CORS。
- 便于统一鉴权、限流、日志追踪。
- 前端调用地址稳定,不暴露内部服务拓扑。
postMessage
适合窗口间通信,不是用来直接请求后端 API
<!doctype html>
<html lang="zh-CN">
<body>
<iframe id="child" src="http://localhost:4000/child.html"></iframe>
<script>
const frame = document.getElementById('child')
// 发送消息给子窗口
frame.onload = () => {
frame.contentWindow.postMessage(
{ type: 'PING', payload: 'hello child' },
'http://localhost:4000', // 必须写目标源,避免 *
)
}
// 接收子窗口消息
window.addEventListener('message', (event) => {
if (event.origin !== 'http://localhost:4000') return // 核心安全校验
console.log('from child:', event.data)
})
</script>
</body>
</html><!doctype html>
<html lang="zh-CN">
<body>
<script>
window.addEventListener('message', (event) => {
if (event.origin !== 'http://localhost:3000') return
// 回消息给父窗口
event.source.postMessage(
{ type: 'PONG', payload: `recv: ${event.data.payload}` },
event.origin,
)
})
</script>
</body>
</html>WebSocket
WebSocket 建连时仍有 Origin 概念,但规则不走 CORS 那套 Access-Control-*
import { WebSocketServer } from 'ws'
const wss = new WebSocketServer({ port: 3001 })
wss.on('connection', (socket, req) => {
// 跨源校验重点看 Origin
const origin = req.headers.origin || ''
if (origin !== 'http://localhost:5173') {
socket.close(1008, 'origin not allowed')
return
}
socket.on('message', (data) => {
// 回显消息
socket.send(`echo: ${data}`)
})
})const ws = new WebSocket('ws://localhost:3001')
ws.onopen = () => {
ws.send('hello')
}
ws.onmessage = (event) => {
console.log(event.data)
}历史方案
JSONP
在 CORS 出现之前,JSONP 是一种常见的跨域解决方案
JSONP 利用 <script> 标签不受 XHR 同源限制:服务器拿到请求后,响应一段 JS 代码,这段代码实际上是一个函数调用。调用的是客户端预先生成好的函数,函数会接收到一个参数,这个参数就是服务器返回的数据
相关信息
历史方案,仅 GET。有 XSS 风险
import http from 'node:http'
const server = http.createServer((req, res) => {
const url = new URL(req.url, 'http://localhost:3000')
if (url.pathname !== '/jsonp') {
res.writeHead(404)
res.end('Not Found')
return
}
const callback = url.searchParams.get('callback') || 'cb'
const payload = { code: 0, data: { name: 'jsonp-user' } }
res.writeHead(200, { 'Content-Type': 'application/javascript; charset=utf-8' })
// 返回可执行 JS:调用前端定义的全局函数
res.end(`${callback}(${JSON.stringify(payload)})`)
})
server.listen(3000)<!doctype html>
<html lang="zh-CN">
<body>
<pre id="log"></pre>
<script>
function handleJsonp(data) {
document.getElementById('log').textContent = JSON.stringify(data, null, 2)
}
const script = document.createElement('script')
script.src = 'http://localhost:3000/jsonp?callback=handleJsonp'
document.body.appendChild(script)
</script>
</body>
</html>document.domain
仅适用于同主域子域(如 a.example.com 与 b.example.com)。
<script>
// 两端都降到主域
document.domain = 'example.com'
function readChild() {
const iframe = document.getElementById('f')
// 现在可访问同主域子页面 DOM
console.log(iframe.contentWindow.document.body.innerText)
}
</script>
<iframe id="f" src="http://b.example.com/b.html"></iframe>
<button onclick="readChild()">读取子页面</button><script>
document.domain = 'example.com'
</script>
<div>hello from b.example.com</div>window.name + iframe
window.name 在一次页面跳转后仍保留,可用于跨域“中转读取”
<script>
// 跨域页面先把数据放进 window.name
window.name = JSON.stringify({ code: 0, data: ['A', 'B', 'C'] })
// 再跳到与父页面同源的中转页
location.href = 'http://app.com/proxy.html'
</script><iframe id="f" src="http://cross.com/data.html" style="display:none"></iframe>
<script>
const iframe = document.getElementById('f')
iframe.onload = () => {
// 第二次 onload 时,iframe 已跳到同源 proxy.html
// 此时父页面可读取 iframe.contentWindow.name
const text = iframe.contentWindow.name
const data = JSON.parse(text)
console.log(data)
}
</script><!-- 空白页即可,关键是与父页面同源 -->location.hash + iframe
通过 URL hash 传值,再由中转页回传
<iframe id="f" src="http://b.com/child.html#name=zhaojisen"></iframe>
<script>
window.addEventListener('hashchange', () => {
// 读取代理页回传的 hash
console.log('recv:', location.hash)
})
</script><script>
// 读取父页给的 hash
const payload = location.hash.slice(1)
// 跳到父页同源代理页并带上数据
location.href = `http://a.com/proxy.html#${payload}`
</script><script>
// 同源下可操作 parent,把 hash 回传给父页面
parent.location.hash = location.hash.slice(1)
</script>资源标签跨域加载
img/script/link/iframe 可跨域加载资源,但对响应数据可读性有边界
<!-- 可跨域加载图片 -->
<img src="https://cdn.example.com/logo.png" alt="logo" />
<!-- 可跨域加载脚本(脚本有执行风险) -->
<script src="https://cdn.example.com/app.js"></script>
<!-- 可跨域加载样式 -->
<link rel="stylesheet" href="https://cdn.example.com/app.css" />const img = new Image()
img.src = 'https://cdn.example.com/a.png'
img.onload = () => {
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
ctx.drawImage(img, 0, 0)
// 跨域图片未配置 CORS 时,这里会抛异常(污染画布)
console.log(canvas.toDataURL())
}