在这里插入图片描述

前端开发者必看:彻底搞懂 CORS 跨域问题与实战解决方案

“本地跑得好好的,一上线就 403?”
“后端拍着胸口说接口通了,浏览器却反手一个 CORS 报错?”
“明明照着 StackOverflow 复制粘贴,为什么还是跨不过那道坎?”

如果你曾经蹲在工位上一边抓耳挠腮一边对着控制台里血红的 Access to fetch at 'xxx' from origin 'yyy' has been blocked by CORS policy 发呆,别慌——这篇文章就是给你写的。今天咱们不背八股文,也不贴晦涩的 W3C 条文,而是像老伙计一样,把 CORS 拉到小酒馆里,边喝边聊:它到底从哪儿来、要到哪儿去、怎么才能让它别挡我们的道儿。

文章很长,代码很多,吐槽也不少。拿好啤酒,系好安全带,发车——


你写的接口请求为啥被浏览器拦了?

故事得从 1995 年说起。那时的网景工程师做梦也想不到,自己随手写下的「同源策略」会在 30 年后成为无数前端加班的元凶。

所谓同源,指的是协议 + 域名 + 端口这三兄弟必须严丝合缝。只要有一个对不上,浏览器就把这段跨域 HTTP 请求当成“隔壁老王递来的香烟”——谁敢抽?万一有毒呢!于是浏览器化身安检小哥,一把把你拦下:
“对不起,老板规定,外域请求请先出示通行证。”

通行证就是 CORS(Cross-Origin Resource Sharing)。它其实一点也不神秘,就是服务器在响应头里塞几个字段,告诉浏览器:“别紧张,自己人,放行!”

可现实常常变成:后端说“我给了通行证”,前端却仍旧被摁在地上摩擦。到底谁撒谎?别急,咱们一点点拆。


跨域资源共享到底是个啥

先上段最精简的“人话”定义:

CORS 是 W3C 的一份**“服务器白名单”**规范。
服务器通过响应头声明:哪些源可以访问我、能用什么方法、能否带cookie、能看哪些头。
浏览器拿到这些头之后,才决定是否把响应交给前端 JS。

换句话说,CORS 保护的是浏览器里的“用户”,而不是服务器。服务器其实早就把数据吐回去了,只是浏览器帮你先“政审”一遍,政审不过,response 被拦截在安检口,JS 连包都摸不着。


浏览器同源策略的前世今生

1995 年,网景 Navigator 2.0 发布,JavaScript 首次可以发 XMLHttpRequest——啊不,那时还叫“LiveScript”,请求也只能在同一个域名下晃悠。
到 2005 年,Gmail 大火,大家第一次意识到:原来网页还能这么玩!异步拉数据,体验起飞。于是“跨域”成了刚需。
2006 年,W3C 推出第一版 CORS 草案;2014 年成为正式推荐标准。
再往后,fetch、HTTP/2、SameSite Cookie 陆续登场,跨域的玩法越来越花哨,坑也越挖越深。


CORS 背后的工作机制全解析

浏览器把跨域请求拆成两类:简单请求 和 需预检请求。
分类标准看似绕,其实目的只有一个——尽量少打扰用户。
能走绿色通道的,就别让人家排队。

简单请求(Simple Request)

满足以下所有条件,就是“简单请求”:

  1. 方法只能是 GET/HEAD/POST 之一
  2. 除了浏览器自动带上的头(如 Accept、User-Agent),你手动只能塞下面这几颗“安全头”:
    • Accept
    • Accept-Language
    • Content-Language
    • Content-Type 且值只能是
      • application/x-www-form-urlencoded
      • multipart/form-data
      • text/plain

满足以上,浏览器直接发请求,不带预检。但!响应头里必须返回:

Access-Control-Allow-Origin: https://your-site.com

否则照样凉凉。

预检请求(Preflight Request)

一旦你想自定义头、想发 PUT/DELETE、Content-Type 是 application/json——恭喜,触发预检。
浏览器先偷偷发一个 OPTIONS 请求,俗称“探路小弟”:

OPTIONS /api/user HTTP/1.1
Host: api.foo.com
Origin: https://your-site.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: Content-Type,X-Custom-Header

服务器必须在 200/204 响应里给出:

Access-Control-Allow-Origin: https://your-site.com
Access-Control-Allow-Methods: PUT, DELETE
Access-Control-Allow-Headers: Content-Type,X-Custom-Header
Access-Control-Max-Age: 86400   // 告诉浏览器缓存 1 天,别天天问

预检通过,浏览器才真正发业务请求;否则,直接 Abort。


简单请求 vs 预检请求:别再傻傻分不清

直接上代码,感受一下差别:

// 1. 简单请求:不会触发 OPTIONS
fetch('https://api.foo.com/emoji?q=hotpot', {
  method: 'GET',
  headers: { 'X-Simple': 'true' } // ❌ 自定义头,瞬间升级成预检!
});

// 2. 预检请求:一定触发 OPTIONS
fetch('https://api.foo.com/emoji', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ name: '毛肚' })
});

打开 DevTools -> Network,你会发现第二条记录里多了一根 OPTIONS 请求。
很多新人会误以为是“后端接口写错了”,其实是浏览器按规范办事。


常见的 CORS 错误类型和它们的真实含义

控制台报错的句式高度统一,但细节里藏着魔鬼。下面列几个高频场景,顺带翻译成人话:

报错原文(省略 URL)真实含义
...has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.服务器压根没返回 Access-Control-Allow-Origin,浏览器直接拒收。
...header contains multiple values '*, http://localhost:3000', but only one is allowed.后端把 Access-Control-Allow-Origin: * 和具体域名同时写了,浏览器一脸懵:到底信谁?
...credentials flag is true, but Access-Control-Allow-Origin is '*'.你想带 cookie,却给 *,浏览器规定:带凭证时 origin 必须明确指定,不能用通配。
...Method PUT is not allowed by Access-Control-Allow-Methods in preflight response.预检里告诉你“不支持 PUT”,你还硬发,被打回。

开发环境下的 CORS 绕过技巧(别在生产乱用!)

本地起服务,端口 3000 调 8080,跨域家常便饭。推荐三招“临时通行证”,用完即焚:

  1. 浏览器禁用安检(仅调试)
    Chrome/Edge 启动加 flag:

    chrome.exe --disable-web-security --user-data-dir=%TEMP%\cors
    

    打开后顶部会飘红条“您处于不受支持的模式”,提醒自己:这是裸奔,千万别用来逛淘宝。

  2. webpack-dev-server 代理(最香)
    前端起本地服务,把 /api 转发到后端,浏览器看到的还是同源:

    // vue.config.js 或 webpack.config.js
    module.exports = {
      devServer: {
        port: 3000,
        proxy: {
          '/api': {
            target: 'http://localhost:8080',
            changeOrigin: true,          // 把 Host 头也改掉
            pathRewrite: { '^/api': '' } // 去掉前缀
          }
        }
      }
    };
    

    前端代码里直接写:

    fetch('/api/user/1'); // 实际打到 localhost:8080/user/1
    
  3. Chrome 插件「Allow CORS」
    一键给所有响应塞 Access-Control-Allow-Origin: *,适合临时调第三方 demo,用完记得关,防止“自己坑自己”。


服务端如何正确配置 CORS 响应头

语言千千万,道理一条线:根据请求头,动态返回白名单。下面给出主流框架的“最佳实践片段”,复制即可食,注释管饱。

Node.js(Express)

import cors from 'cors';
import express from 'express';
const app = express();

// 1. 最简玩法:允许任何源(不带凭证)
// app.use(cors());

// 2. 生产级:动态白名单 + 支持凭证
const ALLOWED = [
  'https://www.foo.com',
  'https://app.foo.com'
];
app.use(cors({
  origin: (origin, callback) => {
    // 注意:移动端 SDK 不带 origin,需要放行 null
    if (!origin || ALLOWED.includes(origin)) {
      callback(null, origin);
    } else {
      callback(new Error('Not allowed by CORS'));
    }
  },
  credentials: true,                 // 允许 cookie
  allowedHeaders: ['Content-Type', 'Authorization', 'X-Trace-Id'],
  methods: ['GET', 'PUT', 'POST', 'DELETE', 'OPTIONS'],
  maxAge: 86400
}));

app.get('/api/user', (req, res) => {
  res.json({ name: '火锅', age: 18 });
});

app.listen(8080);

Spring Boot(Kotlin)

@Configuration
@EnableWebMvc
class CorsConfig : WebMvcConfigurer {
    override fun addCorsMappings(registry: CorsRegistry) {
        registry.addMapping("/api/**")
            .allowedOrigins(
                "https://www.foo.com",
                "https://app.foo.com"
            )
            .allowCredentials(true)
            .allowedHeaders("*")
            .allowedMethods("*")
            .maxAge(86400)
    }
}

Nginx(反向代理层解决)

server {
  listen 80;
  server_name api.foo.com;

  location / {
    if ($request_method = OPTIONS) {
      add_header Access-Control-Allow-Origin "$http_origin" always;
      add_header Access-Control-Allow-Credentials "true" always;
      add_header Access-Control-Allow-Methods "GET, PUT, POST, DELETE, OPTIONS" always;
      add_header Access-Control-Allow-Headers "Content-Type,Authorization,X-Trace-Id" always;
      add_header Access-Control-Max-Age "86400" always;
      return 204;
    }

    proxy_pass http://localhost:8080;

    # 业务响应也别忘了追加头
    add_header Access-Control-Allow-Origin "$http_origin" always;
    add_header Access-Control-Allow-Credentials "true" always;
  }
}

前端代码中处理 CORS 的几种姿势

很多同学习惯性把锅甩给“后端没配好”,其实前端也能做不少事:

  1. fetch 的 credentials 开关
    默认 fetch 不会带 cookie,需要手动开:

    fetch('https://api.foo.com/user', {
      credentials: 'include' // 等价于 XHR 的 withCredentials = true
    });
    
  2. 错误兜底 + 降级
    检测到 403 CORS,可提示用户“请关闭广告屏蔽插件”或“切换网络”,别让用户对着空白页发呆。

  3. Service Worker 拦截
    对 GET 类静态资源,可用 SW 把外域资源缓存到本地,绕过频繁跨域:

    // sw.js
    self.addEventListener('fetch', (e) => {
      if (e.request.url.includes('https://cdn.external.com/')) {
        e.respondWith(
          caches.match(e.request).then(res => {
            return res || fetch(e.request).then(resp => {
              return caches.open('v1').then(cache => {
                cache.put(e.request, resp.clone());
                return resp;
              });
            });
          })
        );
      }
    });
    

使用代理解决本地开发跨域的经典套路

前面 webpack 已经露了一手,这里再补几发“代理组合拳”:

1. Vite 配置

// vite.config.ts
import { defineConfig } from 'vite';
export default defineConfig({
  server: {
    proxy: {
      '/api': {
        target: 'http://localhost:8080',
        changeOrigin: true,
        rewrite: path => path.replace(/^\/api/, '')
      }
    }
  }
});

2. 本地 Nginx 反向代理(适合多项目复用)

# /usr/local/nginx/conf/localhost.conf
server {
  listen 80;
  server_name local.foo.com;

  location / {
    proxy_pass http://localhost:3000; # 前端 devServer
  }
  location /api {
    proxy_pass http://localhost:8080; # 后端
    proxy_set_header Host $host;
  }
}

Mac 下把 local.foo.com 写进 /etc/hosts 指向 127.0.0.1,即可用“漂亮域名”开发,还顺带 https 同源。


CORS 与认证凭证(cookies、Authorization)的那些坑

  1. 带凭证时,后端不能把 Access-Control-Allow-Origin 设为 *
    否则浏览器直接报错,必须明确指定源。

  2. Cookie 的 SameSite 属性
    哪怕 CORS 配好了,后端 Set-Cookie 写了 SameSite=Strict,第三方上下文也写不进去。解决:

    Set-Cookie: session=xxx; HttpOnly; Secure; SameSite=None
    
  3. Authorization 头≠cookie
    用 Bearer token 时,前端只需把 Authorization: Bearer <token> 塞进头即可,不属于“凭证”范畴,不会触发 cookie 相关限制;但自定义头会触发预检,别忘配置 Access-Control-Allow-Headers。


第三方 API 调用时遇到 CORS 该怎么破

第三方接口多半不会给你改 CORS。能走的路:

  1. 让后端转一层
    自己服务器去调第三方,再吐给前端——最稳。

  2. Serverless 函数代理
    Vercel、Netlify、阿里云 FC,随便写 20 行代码,把第三方请求包一层:

    // /api/hello.ts (Vercel)
    import type { VercelRequest, VercelResponse } from '@vercel/node';
    import axios from 'axios';
    
    export default async (req: VercelRequest, res: VercelResponse) => {
      const { data } = await axios.get('https://api.github.com/users/vercel');
      res.setHeader('Access-Control-Allow-Origin', '*');
      res.json(data);
    };
    
  3. JSONP(古老但偶尔救命)
    只支持 GET,需要后端配合,2024 年了不推荐,但应急可食用。


排查 CORS 问题的实用工具和调试思路

  1. DevTools Network 面板
    重点看 OPTIONS 预检的 Response Headers,缺啥补啥。

  2. curl 模拟预检

    curl -X OPTIONS https://api.foo.com/user \
      -H "Origin: https://your-site.com" \
      -H "Access-Control-Request-Method: PUT" \
      -H "Access-Control-Request-Headers: Content-Type" \
      -v
    

    看返回头里有没有 Access-Control-Allow-***,一目了然。

  3. 在线工具
    访问 https://www.test-cors.org,把 URL 粘进去,自动生成前端代码并报告问题。

  4. 抓包对比
    用 Charles、Fiddler 把成功/失败两条请求拖出来,diff 一下响应头,通常五分钟定位元凶。


从网络面板读懂 CORS 失败的蛛丝马迹

  1. 预检返回 404/405
    多半后端没处理 OPTIONS,路由层直接拒收。

  2. 预检 200,但头不对
    比如 Access-Control-Allow-Headers 少了 X-Upload-Id,浏览器会报
    ...header X-Upload-Id is not allowed。

  3. 预检成功,正式请求 200,但 JS 拿不到 body
    十有八九是 Access-Control-Allow-Origin 写了多个值,或含空格、大小写不匹配。


当后端说“我配好了”但前端还是跨域怎么办

  1. 先让后端把完整响应头截图给你
    注意:有些框架会“合并”同名字段,用逗号分隔,浏览器不认。

  2. 确认 URL 是否一致
    协议、端口、Path 多一个 / 都算不同源。

  3. 确认是否经过多层代理
    常见于 Nginx + Gateway + 业务容器,每层都可能重写头,需要逐层 add_header ... always。

  4. 缓存作祟
    预检结果被 Access-Control-Max-Age 缓存,调配置后清不掉?
    给 OPTIONS 返回 Cache-Control: no-cache 或直接改 URL 加查询串 ?v=1。


避免 CORS 陷阱的前端工程化最佳实践

  1. 统一封装 fetch
    集中处理 credentials、错误码、埋点:

    const request = async <T>(url: string, opts?: RequestInit): Promise<T> => {
      const res = await fetch(url, {
        ...opts,
        credentials: 'include',
        headers: {
          'X-Trace-Id': crypto.randomUUID(),
          ...opts?.headers
        }
      });
      if (!res.ok) throw new BizError(await res.text(), res.status);
      return res.json();
    };
    
  2. 把“源”写进环境变量
    本地、测试、生产 origin 不同,用 .env 注入,别硬编码:

    VITE_API_ORIGIN=https://api.foo.com
    
  3. Monorepo 下共享类型 + 代理配置
    前后端同仓库,一份 OpenAPI 生成 TypeScript 类型,代理配置随代码走,新人一键 pnpm dev 即可调试,零文档成本。


别让 CORS 拖慢你的首屏加载速度

  1. 预检请求也有体积
    带 Cookie 的 POST 预检,哪怕 body 是 0,也要 400~600 ms RTT。
    对首屏关键链路,尽量拆成简单请求:

    • JSON → text/plain 绕预检(后端兼容解析即可)
    • 自定义头 → 放查询参数 ?trace=xxx
  2. Max-Age 缓存
    静态资源 304 你懂,预检也能 304。合理设置 Access-Control-Max-Age: 86400,让 Chrome 省一次往返。

  3. 同域收敛
    图片、字体、接口尽量收敛到同域 + CDN,减少 OPTION 流量。
    实在要跨域?那把资源推到自己 CDN,CORS 头一次性配好,别让浏览器去第三方“开盲盒”。


真实项目中那些让人哭笑不得的 CORS 翻车现场

  1. “我本地用 IP 访问,后端配置了 localhost”
    127.0.0.1 ≠ localhost,浏览器眼里完全是两条好汉。

  2. 运维大哥把 Nginx 头配到 if ($request_method = POST) 里
    结果 OPTIONS 请求没命中,预检头全军覆没。

  3. iOS 微信内置浏览器,SameSite=None 必须 Secure
    开发环境用 HTTP,cookie 写不进去,前端以为是跨域,折腾三天发现是 SameSite 新规。

  4. “后端返回 200,但 Content-Type 是 text/html”
    前端 fetch 设置了 Accept: application/json,预检头不匹配,直接凉凉。


写在最后

CORS 像一位古板却尽责的老门卫:规则多、嗓门大,但只要你把通行证(响应头)递对了,他立刻笑眯眯地放行。
与其每次报错都去搜“最快解决方案”,不如把它的脾气摸透:

  • 什么时候会预检?
  • 带凭证和通配符为啥势不两立?
  • OPTIONS 404 到底该找谁?

当你能把 Network 面板每一根 OPTIONS 请求都讲出故事,就不再是“踩坑侠”,而是“填坑侠”。
愿下一次控制台再飘红,你不再心头一紧,而是嘴角上扬:
“嘿,老伙计,又见面——这次我可不怵你。”

欢迎来到我的博客,很高兴能够在这里和您见面!希望您在这里可以感受到一份轻松愉快的氛围,不仅可以获得有趣的内容和知识,也可以畅所欲言、分享您的想法和见解。

推荐:DTcode7的博客首页。
一个做过前端开发的产品经理,经历过睿智产品的折磨导致脱发之后,励志要翻身农奴把歌唱,一边打入敌人内部一边持续提升自己,为我们广大开发同胞谋福祉,坚决抵制睿智产品折磨我们码农兄弟!


专栏系列(点击解锁)学习路线(点击解锁)知识定位
《微信小程序相关博客》 持续更新中~结合微信官方原生框架、uniapp等小程序框架,记录请求、封装、tabbar、UI组件的学习记录和使用技巧等
《AIGC相关博客》 持续更新中~AIGC、AI生产力工具的介绍,例如stable diffusion这种的AI绘画工具安装、使用、技巧等总结
《HTML网站开发相关》 《前端基础入门三大核心之html相关博客》前端基础入门三大核心之html板块的内容,入坑前端或者辅助学习的必看知识
《前端基础入门三大核心之JS相关博客》前端JS是JavaScript语言在网页开发中的应用,负责实现交互效果和动态内容。它与HTML和CSS并称前端三剑客,共同构建用户界面。
通过操作DOM元素、响应事件、发起网络请求等,JS使页面能够响应用户行为,实现数据动态展示和页面流畅跳转,是现代Web开发的核心
《前端基础入门三大核心之CSS相关博客》介绍前端开发中遇到的CSS疑问和各种奇妙的CSS语法,同时收集精美的CSS效果代码,用来丰富你的web网页
《canvas绘图相关博客》Canvas是HTML5中用于绘制图形的元素,通过JavaScript及其提供的绘图API,开发者可以在网页上绘制出各种复杂的图形、动画和图像效果。Canvas提供了高度的灵活性和控制力,使得前端绘图技术更加丰富和多样化
《Vue实战相关博客》持续更新中~详细总结了常用UI库elementUI的使用技巧以及Vue的学习之旅
《python相关博客》持续更新中~Python,简洁易学的编程语言,强大到足以应对各种应用场景,是编程新手的理想选择,也是专业人士的得力工具
《sql数据库相关博客》持续更新中~SQL数据库:高效管理数据的利器,学会SQL,轻松驾驭结构化数据,解锁数据分析与挖掘的无限可能
《算法系列相关博客》持续更新中~算法与数据结构学习总结,通过JS来编写处理复杂有趣的算法问题,提升你的技术思维
《IT信息技术相关博客》持续更新中~作为信息化人员所需要掌握的底层技术,涉及软件开发、网络建设、系统维护等领域的知识
《信息化人员基础技能知识相关博客》无论你是开发、产品、实施、经理,只要是从事信息化相关行业的人员,都应该掌握这些信息化的基础知识,可以不精通但是一定要了解,避免日常工作中贻笑大方
《信息化技能面试宝典相关博客》涉及信息化相关工作基础知识和面试技巧,提升自我能力与面试通过率,扩展知识面
《前端开发习惯与小技巧相关博客》持续更新中~罗列常用的开发工具使用技巧,如 Vscode快捷键操作、Git、CMD、游览器控制台等
《photoshop相关博客》 持续更新中~基础的PS学习记录,含括PPI与DPI、物理像素dp、逻辑像素dip、矢量图和位图以及帧动画等的学习总结
日常开发&办公&生产【实用工具】分享相关博客》持续更新中~分享介绍各种开发中、工作中、个人生产以及学习上的工具,丰富阅历,给大家提供处理事情的更多角度,学习了解更多的便利工具,如Fiddler抓包、办公快捷键、虚拟机VMware等工具

吾辈才疏学浅,摹写之作,恐有瑕疵。望诸君海涵赐教。望轻喷,嘤嘤嘤

非常期待和您一起在这个小小的网络世界里共同探索、学习和成长。愿斯文对汝有所裨益,纵其简陋未及渊博,亦足以略尽绵薄之力。倘若尚存阙漏,敬请不吝斧正,俾便精进!

在这里插入图片描述

更多推荐