使用 CORS 解决跨域问题

2025-07-23 前端

跨域问题是 Web 前端开发中最常见的拦路虎之一。你在本地开发环境调接口调得好好的,一上线就报错:

Access to fetch at 'https://api.example.com/data' from origin 'https://mysite.com'
has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present

这篇文章从同源策略的根源讲起,逐步深入到 CORS 的完整工作机制和实战配置。

一、为什么会跨域?

浏览器有个同源策略(Same-Origin Policy):只有协议 + 域名 + 端口完全相同的页面,才能共享 DOM、读取 Cookie 和发送跨域请求。

https://mysite.com/page.html
        ↓
https://mysite.com/api      ✅ 同源(协议、域名、端口都一致)
http://mysite.com/api       ❌ 协议不同(https vs http)
https://api.mysite.com      ❌ 域名不同
https://mysite.com:3000     ❌ 端口不同

同源策略保护了你:恶意网站不能通过脚本偷取你在银行页面的数据——因为跨域请求被浏览器拦截。

但实际开发中跨域是刚需:

场景 跨域原因
前后端分离 前端 localhost:3000,后端 localhost:8080
调用第三方 API 自己网站请求 api.weixin.qq.com
CDN 资源 自己的页面引用 cdn.example.com 上的字体/样式
微前端 多个子应用来自不同域名

二、CORS 是什么?

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

核心流程就一句话:浏览器发请求,服务器在响应头里告诉浏览器"这个源我允许",浏览器才把响应交给前端代码。

浏览器和服务器之间的 CORS 处理分两种情况:

三、简单请求(Simple Request)

满足以下所有条件的请求属于简单请求:

  • 方法:GET、HEAD、POST 之一
  • 请求头:只有 Accept、Accept-Language、Content-Language、Content-Type(且值只能是 application/x-www-form-urlencoded、multipart/form-data、text/plain)
  • 不使用 ReadableStream
  • 不使用 XMLHttpRequest.upload 事件监听

浏览器直接发送请求,通过响应头判断是否放行:

请求头(浏览器自动添加):

Origin: https://mysite.com

响应头(服务端必须返回):

Access-Control-Allow-Origin: https://mysite.com

前端代码:

fetch('https://api.example.com/data')
  .then(res => res.json())
  .then(console.log);

如果服务器返回了 Access-Control-Allow-Origin: * 或者匹配的源,浏览器就把响应交给前端;否则报 CORS 错误。

四、预检请求(Preflight Request)

当请求不满足"简单请求"条件时(比如使用 PUT/DELETE、Content-Type: application/json、携带自定义头),浏览器会先发一个 OPTIONS 预检请求,询问服务器是否允许实际请求。

OPTIONS /data HTTP/1.1
Origin: https://mysite.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: Content-Type, Authorization

服务器回应:

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://mysite.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 86400

预检通过后,浏览器才发送真正的 PUT 请求。

预检请求相当于浏览器替你的代码先去问一声:"老大,我能发这个请求吗?" 服务器说"行"才真的发出。

五、CORS 响应头详解

Access-Control-Allow-Origin

必需。指定允许访问的源。

Access-Control-Allow-Origin: https://mysite.com
Access-Control-Allow-Origin: *   # 开发环境常用,生产慎用

注意:当设置 * 时,不能同时使用 Access-Control-Allow-Credentials: true。

Access-Control-Allow-Methods

预检请求中必需。列出允许的 HTTP 方法。

Access-Control-Allow-Methods: GET, POST, PUT, DELETE, PATCH

Access-Control-Allow-Headers

预检请求中必需。列出允许的自定义请求头。

Access-Control-Allow-Headers: Content-Type, Authorization, X-Requested-With

Access-Control-Allow-Credentials

是否需要携带 Cookie/Authorization 等凭证信息。

Access-Control-Allow-Credentials: true

前端配合:

// fetch
fetch('https://api.example.com/user', {
  credentials: 'include',  // 或 'same-origin'
});

// axios
axios.get('/api/user', { withCredentials: true });

当 credentials: true 时,Access-Control-Allow-Origin 不能是 *,必须明确指定具体的源。

Access-Control-Expose-Headers

默认情况下,浏览器只暴露 6 个响应头给前端:Cache-Control、Content-Language、Content-Type、Expires、Last-Modified、Pragma。要访问其他响应头(如 X-Total-Count、X-RateLimit-Remaining),需要用这个头声明。

Access-Control-Expose-Headers: X-Total-Count, X-RateLimit-Remaining
fetch('/api/list')
  .then(res => {
    const total = res.headers.get('X-Total-Count');
    console.log('总数:', total);
  });

Access-Control-Max-Age

指定预检请求的结果可以被缓存多久(秒),避免频繁 OPTIONS 请求。

Access-Control-Max-Age: 86400

Chrome 上限是 86400 秒(24 小时),Firefox 是 86400 秒。

六、实战配置

Node.js(Express)

const express = require('express');
const app = express();

// CORS 中间件
app.use((req, res, next) => {
  const origin = req.headers.origin;
  const allowed = ['https://mysite.com', 'https://admin.mysite.com'];

  if (allowed.includes(origin)) {
    res.setHeader('Access-Control-Allow-Origin', origin);
  }

  res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE');
  res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
  res.setHeader('Access-Control-Allow-Credentials', 'true');
  res.setHeader('Access-Control-Max-Age', '86400');

  // 预检请求直接 204 返回
  if (req.method === 'OPTIONS') {
    return res.sendStatus(204);
  }

  next();
});

app.get('/api/data', (req, res) => {
  res.json({ message: 'CORS 配置成功' });
});

app.listen(3000);

也可以直接用 cors 包:

const cors = require('cors');

app.use(cors({
  origin: ['https://mysite.com', 'https://admin.mysite.com'],
  methods: ['GET', 'POST', 'PUT', 'DELETE'],
  allowedHeaders: ['Content-Type', 'Authorization'],
  credentials: true,
  maxAge: 86400,
}));

PHP

<?php
header('Access-Control-Allow-Origin: https://mysite.com');
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, Authorization');
header('Access-Control-Allow-Credentials: true');
header('Access-Control-Max-Age: 86400');

// 预检请求直接结束
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
    http_response_code(204);
    exit;
}

// 正常业务逻辑
echo json_encode(['message' => 'CORS 配置成功']);

如果是统一入口(如 Laravel/ThinkPHP),CORS 中间件是更好的选择。

Nginx(反向代理层解决)

如果你能控制 Nginx 配置,可以在代理层统一处理 CORS:

server {
    listen 443 ssl;
    server_name api.example.com;

    location / {
        if ($request_method = 'OPTIONS') {
            add_header 'Access-Control-Allow-Origin' 'https://mysite.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;
            return 204;
        }

        add_header 'Access-Control-Allow-Origin' 'https://mysite.com' always;
        add_header 'Access-Control-Allow-Credentials' 'true' always;
        add_header 'Access-Control-Expose-Headers' 'X-Total-Count' always;

        proxy_pass http://backend:3000;
    }
}

七、开发环境下的 CORS 最佳实践

方案一:Vite 代理(推荐)

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

请求 /api/data → 被 Vite 代理 → 转发到 https://api.example.com/data → 浏览器看到的是同源请求,彻底消灭跨域。

方案二:Webpack DevServer 代理

// webpack.config.js
devServer: {
  proxy: {
    '/api': {
      target: 'https://api.example.com',
      changeOrigin: true,
    },
  },
}

方案三:自己写 CORS 代理

对于调试第三方 API,可以用临时代理:

// Node.js 代理脚本
const http = require('http');
const { createProxyMiddleware } = require('http-proxy-middleware');

const proxy = createProxyMiddleware({
  target: 'https://api.thirdparty.com',
  changeOrigin: true,
  on: {
    proxyRes: (proxyRes) => {
      proxyRes.headers['Access-Control-Allow-Origin'] = '*';
    },
  },
});

http.createServer((req, res) => {
  if (req.url.startsWith('/proxy/')) {
    req.url = req.url.replace('/proxy/', '/');
    return proxy(req, res);
  }
  res.writeHead(404);
  res.end();
}).listen(3000);

八、常见 CORS 报错及排查

错误信息 原因 解决
No 'Access-Control-Allow-Origin' header is present 响应头缺失 服务端加上对应头
Response to preflight request doesn't pass access control check 预检失败 确认 OPTIONS 请求返回正确头
The value of the 'Access-Control-Allow-Origin' header contains multiple values 重复设置该头 检查是否有多个中间件/反向代理同时设置
The request's credentials mode is 'include' but ... not '*'. credentials 与 * 冲突 将 * 改为具体的源
Request header field X-Custom is not allowed by Access-Control-Allow-Headers 自定义头未声明 在预检响应中 Allow-Headers 添加该头

排查步骤:

  1. 打开浏览器 DevTools → Network 标签
  2. 找到 CORS 失败的请求
  3. 检查请求头中的 Origin
  4. 检查响应头中是否有 Access-Control-Allow-Origin
  5. 如果是 OPTIONS 请求,检查 Allow-Methods 和 Allow-Headers
  6. 确认 Nginx/CDN 没有覆盖掉服务端的 CORS 头

九、CORS 与 CDN/边缘网关

如果你的网站使用了 CDN(如 EdgeOne、Cloudflare),CORS 配置可能需要额外注意。

场景:源站服务器上配好了 CORS 头,但 CDN 缓存了不带 CORS 头的旧响应。

解决:确保 CDN 缓存策略考虑 Origin 头,或对 OPTIONS 请求不做缓存。

以 EdgeOne 为例,缓存规则中设置:

  • 请求方法为 OPTIONS 时:不缓存
  • 或者根据 Origin 头做缓存区分

十、总结

CORS 的核心就是几句话:

  1. 浏览器发请求时自动带上 Origin
  2. 服务器在响应头里告诉浏览器允许哪些源
  3. 浏览器检查响应头,决定是否把数据交给前端 JavaScript
  4. 预检请求是浏览器替实际请求先探路
  5. 开发环境用代理(Vite/Webpack)最省心,生产环境后端/网关配 CORS

记住:CORS 不是安全问题,是浏览器的一项保护机制。跨域问题是它工作时遇到了配置不完整,而不是它出了故障。正确配置 CORS 后,前后端分离、微服务架构、第三方 API 集成都可以顺畅运行。