适合读者:前端初学者,遇到过 Console 里的 CORS 红色报错但不知道为什么会这样。
你将收获:理解同源策略的本质、能判断什么情况会跨域、掌握三种解决方案的原理和适用场景。
一个熟悉的红色报错
你写了一个 Vue 前端跑在 localhost:5173,后端 Express 跑在 localhost:3000。前端发了一个 fetch:
js
fetch('http://localhost:3000/stream?prompt=hello')
Console 里出现:
csharp
Access to fetch at 'http://localhost:3000/stream' from origin
'http://localhost:5173' has been blocked by CORS policy:
No 'Access-Control-Allow-Origin' header is present on the requested resource.
你很困惑:"我不就是请求自己电脑上的另一个端口吗,这也要拦?"
是的。这就是同源策略(Same-Origin Policy),浏览器最核心的安全机制之一。
什么是"源"(Origin)
浏览器判断"是不是自己人"的标准很粗暴:协议 + 域名 + 端口,三者必须完全一样。
bash
你的前端页面: http://localhost:5173
以下地址的"源":
http://localhost:5173/api/stream ✅ 同源(端口一样)
http://localhost:3000/stream ❌ 跨域(端口不同!5173 vs 3000)
https://localhost:5173/xxx ❌ 跨域(协议不同!http vs https)
http://127.0.0.1:5173/xxx ❌ 跨域(域名不同!localhost vs 127.0.0.1 是两个字面量)
http://api.deepseek.com/v1 ❌ 跨域(完全不同)
即使它们指向的是同一台物理机器、同一个进程,只要字符串不一样,就是跨域。
发生了跨域时,浏览器到底做了什么
很多人的误解是"浏览器阻止了跨域请求发出去"。不是的------请求发出去了,服务器也处理了,数据也返回了。
浏览器的操作流程是这样的:
markdown
1. JS 代码执行 fetch('http://localhost:3000/stream')
↓
2. 浏览器发现目标地址跟当前页面不同源
↓
3. 浏览器自动在请求头上加 Origin: http://localhost:5173
(告诉对方:这个请求是从 5173 端口发来的)
↓
4. 请求正常发出 → 服务器正常处理 → 响应正常到达浏览器
↓
5. 浏览器检查响应头里有没有:
Access-Control-Allow-Origin: http://localhost:5173
或者
Access-Control-Allow-Origin: *
↓
6a. 有 → JS 拿到数据,一切正常
6b. 没有 → 浏览器拦截,JS 什么都拿不到,Console 报错
跨域限制的不是"发请求",而是"JS 能不能拿到返回的数据"。 请求本身已经成功完成了。
为什么要有同源策略
假设没有同源策略:
- 你打开了
http://evil-site.com这个页面 - 页面里的 JS 偷偷发了一个
fetch('http://your-bank.com/api/balance') - 由于你之前登录过银行,浏览器自动带了 Cookie
- 攻击者的 JS 拿到了你的账户余额
- 这就是 CSRF 攻击的基本思路
同源策略阻止了 A 网站的 JS 随意读取 B 网站的接口返回数据。这不是网络层面的安全,是浏览器层面的沙箱隔离。
什么时候会触发跨域检查
| 场景 | 是否触发跨域 |
|---|---|
JS 发 fetch() / XMLHttpRequest 到不同源 |
✅ 会触发 |
<img src="不同源的图片"> |
❌ 不触发 |
<script src="不同源的 JS"> |
❌ 不触发(历史原因) |
<link href="不同源的 CSS"> |
❌ 不触发 |
| 浏览器地址栏直接输入 URL | ❌ 不触发(不是 JS 发起的) |
Node.js 服务器发 fetch() |
❌ 不触发(没有浏览器,同源策略是浏览器规则) |
注意最后一条------跨域是浏览器的规则,不是网络的规则。 服务器之间互相发请求不存在跨域限制。这是理解所有解决方案的关键。
解决方案一:CORS(让目标服务器说"我允许")
最简单的办法:在 Express 里加 CORS 中间件。
js
import express from 'express';
import cors from 'cors';
const app = express();
app.use(cors()); // 一行搞定,所有路由对所有来源开放
// 或者精细控制:
app.use(cors({
origin: 'http://localhost:5173' // 只允许这个来源
}));
app.get('/stream', (req, res) => {
// ......
})
原理:Express 在每个响应里自动加上:
arduino
Access-Control-Allow-Origin: http://localhost:5173
浏览器看到这个头,就知道"哦,目标服务器允许我访问",于是把数据交给 JS。
适用场景:你拥有目标服务器的控制权(比如你自己的 BFF)。
解决方案二:代理(让浏览器觉得没跨域)
既然跨域是浏览器和目标服务器之间的事,那就在中间加一层代理:
markdown
没有代理:
浏览器 ──跨域请求──→ 目标服务器
有代理:
浏览器 ──同源请求──→ 代理服务器 ──服务端请求──→ 目标服务器
↑ ↑
跟浏览器同源 没有浏览器参与
不受同源策略限制
开发阶段:Vite Proxy
js
// vite.config.js
export default defineConfig({
server: {
proxy: {
'/api': { // 拦截 /api 开头的请求
target: 'http://localhost:3000', // 转发到真正的后端
rewrite: path => path.replace(/^\/api/, '') // /api/stream → /stream
}
}
}
})
前端请求 /api/stream(同源,不跨域),Vite 开发服务器收到后转发给 http://localhost:3000/stream。第二步转发是服务器之间的通信,不受同源策略限制。
生产阶段:Nginx 反向代理
nginx
server {
listen 80;
server_name example.com;
# 前端静态文件
location / {
root /var/www/dist;
try_files $uri /index.html;
}
# API 请求转发到 BFF
location /api/ {
proxy_pass http://localhost:3000/;
}
}
原理跟 Vite Proxy 完全一样。前端始终只跟同源的 /api/xxx 通信。
适用场景 :开发用 Vite Proxy,生产用 Nginx。前端代码不需要改,始终请求 /api/xxx 就行。
解决方案三:JSONP(历史遗迹,不推荐)
利用 <script> 标签不受同源策略限制的特点。只支持 GET 请求,需要后端配合,基本被淘汰了。了解就行,不要再用了。
三种方案对比
| 方案 | 原理 | 需要改谁 | 适用 |
|---|---|---|---|
| CORS 中间件 | 目标服务器在响应里声明"允许" | 后端 | 你拥有后端控制权 |
| Vite Proxy | 浏览器和代理同源,代理转发 | 前端项目配置 | 本地开发 |
| Nginx 反向代理 | 浏览器和 Nginx 同源,Nginx 转发 | 运维配置 | 生产部署 |
实践中,通常是开发用 Vite Proxy,上线用 Nginx。前后端代码都不用因为跨域问题而妥协。
一个特例:为什么直接调 DeepSeek 不跨域
你可能会发现,前端直接 fetch('https://api.deepseek.com/...') 并不会报跨域错误。
原因很简单:DeepSeek 作为对外提供 API 的公司,它主动在响应头里加了 Access-Control-Allow-Origin: *。
跨域不是"浏览器禁止你访问别人",而是"浏览器要求别人明确说可以访问"。DeepSeek 说了"可以",所以不报错。
那为什么还要加 BFF?因为 API Key。 DeepSeek 解决的是跨域问题,解决不了"你的 Key 暴露在浏览器里"的问题。
总结
- 同源 = 协议 + 域名 + 端口完全一致,任何一个不同就是跨域
- 跨域发生时,请求正常发送、返回,只是浏览器不让 JS 拿到结果
- 跨域是浏览器的安全机制(沙箱),服务端之间通信不受限制
- 三种解法 :
- CORS:让后端加响应头说"允许"
- 代理(Vite/Nginx):让浏览器请求代理,代理去转发
- JSONP:别用了
- 实战组合:开发 Vite Proxy + 生产 Nginx