文章

前端跨域方案完全解析

前端跨域方案完全解析

一句话概括

跨域是指不同源(协议/域名/端口任一不同)的页面或 API 之间进行资源访问时,受到浏览器的同源策略限制。前端开发者需要掌握至少 4 种跨域方案(CORS、JSONP、代理、postMessage),其中 CORS 是事实标准,其他方案用于特定场景的补充。

背景与意义

为什么要有同源策略?

如果没有同源策略,恶意网站可以:

1
2
3
4
// 假设你在 bank.com 登录了(有 Cookie)
// 你在浏览恶意网站 evil.com,它执行:
fetch('https://bank.com/api/transfer?amount=10000&to=evil')
  .then(() => alert('转账成功!'))

浏览器允许 evil.com 发起请求到 bank.com(所以 CSRF 攻击存在),但不允许 evil.com 读取 bank.com 的响应——这就是同源策略的核心:可以发送,不能读取

什么算是”同源”?

1
2
3
https://example.com:443/page1
└─────┬─────┘ └─┬──┘ └┬┘ └───┬──┘
   协议       域名   端口   路径
URL是否同源原因
https://example.com/page2同源协议/域名/端口相同
http://example.com/page❌ 不同源协议不同(https vs http)
https://api.example.com❌ 不同源域名不同(子域名不算同源)
https://example.com:8080❌ 不同源端口不同
https://www.example.com❌ 不同源域名不同(www 算子域名)

面试高频信号

跨域是前端面试必考,几乎 100% 会出现:

  • “什么是同源策略?为什么要有它?”
  • “跨域有哪些解决方案?”
  • “CORS 的简单请求和预检请求有什么区别?”
  • “CORS 相关的 HTTP 头部有哪些?”

概念与定义

方案概览

方案场景限制推荐度
CORS跨域 API 调用需要服务端配合⭐⭐⭐(首选)
JSONP只支持 GET 请求老旧 API/不想改服务端⭐⭐(过渡方案)
反向代理开发/同域部署需要代理服务器⭐⭐⭐⭐(工程化首选)
postMessage跨域 iframe 通信需要双方配合⭐⭐(iframe 场景)
WebSocket实时通信协议不同⭐⭐⭐(实时场景)
document.domain相同主域的子域已废弃

CORS(Cross-Origin Resource Sharing)

CORS 是 W3C 标准,通过 HTTP 头部让服务端声明”允许哪些来源访问我”。

1
2
3
4
5
6
# 服务端响应添加的头部
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-Max-Age: 86400

简单请求 vs 预检请求:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// ✅ 简单请求:直接发送(满足全部条件)
// 1. 方法:GET / HEAD / POST
// 2. 头部:只包含 CORS 安全头部(Accept / Accept-Language / Content-Language / Content-Type)
// 3. Content-Type 只能是:text/plain / multipart/form-data / application/x-www-form-urlencoded

fetch('https://api.example.com/data')
// → 直接发出请求,响应中检查 Access-Control-Allow-Origin

// ❌ 非简单请求:先发 OPTIONS 预检
fetch('https://api.example.com/data', {
  method: 'PUT',
  headers: { 'Content-Type': 'application/json' },  // 非简单 Content-Type
  credentials: 'include'  // 携带 Cookie
})
// → 浏览器先发一个 OPTIONS 请求(预检)
// → 预检通过后才发真正的 PUT 请求

最小示例

CORS 后端配置示例

1
2
3
4
5
6
7
8
9
10
11
12
13
// Node.js / Express
app.use((req, res, next) => {
  res.header('Access-Control-Allow-Origin', 'https://your-frontend.com');
  res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
  res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization');
  res.header('Access-Control-Allow-Credentials', 'true');
  
  // 预检请求直接返回 200
  if (req.method === 'OPTIONS') {
    return res.sendStatus(200);
  }
  next();
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# Nginx 反向代理配置(解决跨域)
location /api/ {
    proxy_pass https://api-backend.com/;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    
    # 添加 CORS 头部
    add_header Access-Control-Allow-Origin $http_origin always;
    add_header Access-Control-Allow-Methods 'GET, POST, OPTIONS' always;
    add_header Access-Control-Allow-Headers 'DNT,X-Mx-ReqToken,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Authorization' always;
    
    if ($request_method = 'OPTIONS') {
        return 204;
    }
}

核心知识点拆解

知识点 1:CORS 完整头部体系

请求头(客户端自动添加):

1
2
3
Origin: https://example.com           # 告诉服务端"我是谁"
Access-Control-Request-Method: PUT    # 预检时表明要用的 HTTP 方法
Access-Control-Request-Headers: content-type, authorization  # 预检时表明要用的自定义头部

响应头(服务端必须添加):

1
2
3
4
5
6
7
8
9
10
# 必选
Access-Control-Allow-Origin: https://example.com
  # 或通配符:*(但不能与 credentials: true 同时使用)

# 可选(根据场景)
Access-Control-Allow-Methods: GET, POST, PUT, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Allow-Credentials: true    # 允许携带 Cookie
Access-Control-Max-Age: 86400             # 预检结果缓存 24 小时
Access-Control-Expose-Headers: X-Total-Count  # 允许前端读取的额外响应头

知识点 2:JSONP 的原理与局限

1
2
3
4
5
6
7
8
9
10
11
<!-- JSONP:利用 <script> 标签没有跨域限制 -->
<script>
  function handleData(data) {
    console.log('收到数据:', data);
  }
</script>

<script src="https://api.example.com/user?callback=handleData"></script>

<!-- 服务端返回: -->
<script>handleData({ name: '张三', age: 25 })</script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 封装 JSONP
function jsonp(url, params, callbackName) {
  return new Promise((resolve, reject) => {
    const script = document.createElement('script');
    const callback = `jsonp_${Date.now()}`;
    
    window[callback] = (data) => {
      delete window[callback];
      document.body.removeChild(script);
      resolve(data);
    };
    
    const query = new URLSearchParams({ ...params, [callbackName]: callback });
    script.src = `${url}?${query}`;
    script.onerror = reject;
    document.body.appendChild(script);
  });
}

// 使用
jsonp('https://api.example.com/user', { id: 1 }, 'callback')
  .then(data => console.log(data));

JSONP 的局限

  1. ❌ 只支持 GET 请求
  2. ❌ 没有错误处理机制(只能检测 script 加载失败)
  3. ❌ 安全性低(可被 XSS 攻击利用)
  4. ❌ 无法设置自定义头部

知识点 3:代理方案

开发环境(Webpack/Vite):

1
2
3
4
5
6
7
8
9
10
11
12
// Vite 配置
export default defineConfig({
  server: {
    proxy: {
      '/api': {
        target: 'https://api-backend.com',
        changeOrigin: true,
        rewrite: path => path.replace(/^\/api/, '')
      }
    }
  }
})

生产环境(Nginx 反向代理):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 前端:https://example.com
# API:https://api.example.com
# 通过 Nginx 将 /api 转发到后端,前端请求自己域下的 /api

server {
    listen 443 ssl;
    server_name example.com;
    
    location / {
        root /var/www/frontend;
        try_files $uri $uri/ /index.html;
    }
    
    location /api/ {
        proxy_pass https://api-backend.com/;
        proxy_set_header Host $host;
    }
}

原理:浏览器 → 同域请求 → Nginx → 代理到后端 → 浏览器从 Nginx 接收响应。浏览器始终认为请求的是同域资源,没有跨域问题。

知识点 4:postMessage(iframe 通信)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 父页面:发送消息给 iframe
const iframe = document.querySelector('iframe');
iframe.contentWindow.postMessage({
  type: 'SET_TOKEN',
  payload: 'abc123'
}, 'https://iframe-origin.com');  // 指定接收源

// iframe 页面:监听消息
window.addEventListener('message', (event) => {
  // ⚠️ 重要:一定要验证来源
  if (event.origin !== 'https://parent-origin.com') return;
  
  if (event.data.type === 'SET_TOKEN') {
    localStorage.setItem('token', event.data.payload);
    // 回复父页面
    event.source.postMessage({ type: 'TOKEN_RECEIVED' }, event.origin);
  }
});

实战案例

案例一:生产环境完整 CORS 配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// Node.js Express 带白名单
const ALLOWED_ORIGINS = [
  'https://example.com',
  'https://www.example.com',
  'https://admin.example.com'
];

app.use((req, res, next) => {
  const origin = req.headers.origin;
  
  if (ALLOWED_ORIGINS.includes(origin)) {
    res.setHeader('Access-Control-Allow-Origin', origin);
    res.setHeader('Access-Control-Allow-Credentials', 'true');
    res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, PATCH, OPTIONS');
    res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Requested-With');
    res.setHeader('Access-Control-Max-Age', '86400');
  }
  
  if (req.method === 'OPTIONS') {
    return res.sendStatus(204);
  }
  
  next();
});

案例二:使用 cors 中间件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const cors = require('cors');

// 简单用法
app.use(cors({ origin: 'https://example.com' }));

// 动态白名单
const whitelist = ['https://site1.com', 'https://site2.com'];
app.use(cors({
  origin: (origin, callback) => {
    if (whitelist.includes(origin) || !origin) {  // !origin 允许服务端间调用
      callback(null, true);
    } else {
      callback(new Error('Not allowed by CORS'));
    }
  },
  credentials: true
}));

案例三:前端错误处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
async function fetchWithCors(url, options = {}) {
  try {
    const response = await fetch(url, {
      ...options,
      credentials: 'include',
      headers: {
        'Content-Type': 'application/json',
        ...options.headers
      }
    });
    
    if (!response.ok) {
      throw new Error(`HTTP ${response.status}`);
    }
    
    return await response.json();
  } catch (error) {
    if (error instanceof TypeError && error.message.includes('Failed to fetch')) {
      // 跨域错误或网络错误
      console.error('CORS 或网络错误:', url);
      // 可以尝试 fallback 方案
    }
    throw error;
  }
}

底层原理

浏览器处理 CORS 的完整流程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
浏览器发起 fetch('https://api.b.com/data')
    │
    ├─ 检查是否为简单请求?
    │   ├─ 是 → 直接发送请求
    │   └─ 否 → 先发 OPTIONS 预检请求
    │             │
    │             ├─ OPTIONS 返回 2xx + 正确头部 → 发送真正请求
    │             └─ OPTIONS 失败 → 抛出跨域错误
    │
    ├─ 发送真正请求
    │
    └─ 检查响应头 Access-Control-Allow-Origin
        ├─ 匹配请求 Origin → ✅ 正常接收数据
        └─ 不匹配或缺失  → ❌ 拦截响应,控制台报 CORS 错误
                           (请求已经到达服务器并返回了,但浏览器不交给前端代码)

💡 关键理解:CORS 错误意味着:

  1. 请求已经发出去了(服务器收到了请求,也返回了响应)
  2. 但浏览器拦截了响应,不让 JS 代码读取
  3. 这不是”请求失败”,而是”读取失败”

高频面试题解析

Q1:跨域有哪些解决方案?请逐一说明。

参考答案:

  1. CORS (首选):服务端设置 Access-Control-Allow-Origin 头部,支持所有 HTTP 方法
  2. JSONP:利用 <script> 无跨域限制,只支持 GET,安全性稍差
  3. 反向代理(Nginx / Vite Proxy):开发和生产环境都可用
  4. postMessage:iframe 跨域通信
  5. WebSocket:不受同源策略限制,适合实时通信
  6. document.domain:(已废弃)只能用于相同主域的子域之间

Q2:简单请求和预检请求的区别?

参考答案: 简单请求不需要预检,直接发送请求,响应中检查 Access-Control-Allow-Origin。 非简单请求先发 OPTIONS 预检,通过后才发真正请求。

触发预检的条件:

  • 使用 PUT / DELETE / PATCH 等方法
  • Content-Typeapplication/json 等非简单类型
  • 使用了自定义请求头(Authorization 等)

Q3:CORS 相关的 HTTP 头部有哪些?

参考答案: 请求头:Origin 预检请求头:Access-Control-Request-MethodAccess-Control-Request-Headers 响应头:Access-Control-Allow-Origin(必选)、Allow-MethodsAllow-HeadersAllow-CredentialsMax-AgeExpose-Headers

参考答案: 前端设置 credentials: 'include'(fetch)或 withCredentials: true(XHR) 服务端必须设置:

  • Access-Control-Allow-Origin 不能是 *(必须是具体源)
  • Access-Control-Allow-Credentials: true

总结与扩展

核心要点

  1. 同源策略 = 协议 + 域名 + 端口三者完全一致
  2. CORS 是标准方案,绝大多数场景都可以用
  3. JSONP 已过时,只在维护老旧系统时使用
  4. 反向代理 是工程化最佳实践(开发 Vite Proxy / 生产 Nginx)
  5. CORS 不是”服务器拒绝了请求”,而是”浏览器拦截了响应”

延伸学习方向

  • CORS 与 CSRF:CORS 是跨域访问控制,CSRF 是跨站请求伪造攻击
  • OAuth 2.0 中的跨域授权流程
  • CORS 与 Subresource Integrity(SRI) 的安全配合
  • crossorigin 属性<script crossorigin> 与 CORS 的关系

相关主题

本文由作者按照 CC BY 4.0 进行授权

© 独行的风. 保留部分权利。

本站采用 Jekyll 主题 Chirpy

本站总访问量 本站访客数 本文阅读量