为什么会跨域?从 CORS 到 Nginx 到 Gateway 一次讲透

0 阅读13分钟

前端 React 跑在:

http://localhost:5173

后端 Express 跑在:

http://localhost:3000

结果请求一发出去,浏览器直接报错:

Access to XMLHttpRequest has been blocked by CORS policy

是浏览器限制,不是服务器限制

很多人误以为跨域是发不了请求,其实并不是后端拒绝了请求。

真正限制的通常是:JavaScript 跨站读取响应内容

很多情况下:

  • 请求已经发送成功
  • 服务器也已经返回数据

但浏览器不会把响应结果交给当前页面的 JavaScript。

比如:

localhost:5173

访问:

localhost:3000

服务器可能已经返回:

{
  "username": "admin"
}

但浏览器会拦住:不让前端 JavaScript 拿到这个数据。因为浏览器不知道你是不是恶意网站。

因为浏览器担心:

  • 恶意网站盗取数据
  • 用户 Cookie 泄漏
  • 非法请求用户信息

所以默认禁止跨域访问。

这是浏览器的一个非常重要的安全机制:

同源策略(Same-Origin Policy)

所谓“同源”,必须同时满足:

  • 协议相同
  • 域名相同
  • 端口相同

比如:

地址是否同源
localhost:3000 → localhost:3000
localhost:5173 → localhost:3000
http → https

即使都是本地 localhost,只要端口不同,也算跨域。

为什么浏览器要这么严格?

因为浏览器是“用户身份中心”。

里面保存着:

  • Cookie
  • Token
  • 登录状态
  • 用户隐私
  • 本地存储

所以浏览器必须非常严格。

假设: 你已经登录了微博,浏览器里已经保存了微博登录状态:

Cookie:
session_id=abc123

这时候你又打开了一个恶意网站:

https://bad.com

bad.com 页面里偷偷写了:

fetch("https://weibo.com/api/userinfo", {
  credentials: "include"
})

credentials是fetch 请求时“是否携带 Cookie”的配置。

credentials: "same-origin"表示只有同源请求才自动带 Cookie(默认)

credentials: "include"表示即使跨域,也把 Cookie 带上

credentials: "omit"表示永远不携带 Cookie

那么此时,浏览器会携带上cookie

GET /api/userinfo
Cookie: session_id=abc123

Cookie 是什么?

当你登录微博时:

账号 + 密码

发送给微博服务器,登录成功后,微博服务器会返回Cookie:

Set-Cookie: session_id=abc123

于是浏览器会偷偷保存:

weibo.com -> session_id=abc123

以后只要你访问 weibo.com,浏览器都会自动带:

Cookie: session_id=abc123

所以:Cookie 本质上就是“登录凭证”。

于是微博服务器会认为: “用户已经登录”。 那么相当于已经完成了认证。

恶意网站就可以:

  • 冒充你的身份发送请求
  • 以你的登录状态执行操作

如果浏览器再允许读取响应内容,那么用户隐私数据也会泄漏。

不仅能“读取”,还能“操作”。可以直接替你发微博:

fetch("https://weibo.com/api/post", {
  method: "POST",
  credentials: "include",
  body: JSON.stringify({
    content: "我是笨蛋"
  })
})

浏览器还是会自动带 Cookie。于是微博服务器认为:是你本人在发微博。

这种:

借助用户已登录状态,
冒充用户发送请求

的攻击方式,

其实就是经典的:CSRF(跨站请求伪造)

所以:浏览器不允许当前网站读取另一个网站返回的数据。同源策略主要限制JavaScript 读取响应

即使请求已经发送成功,浏览器也可能拦截响应结果,不允许前端 JavaScript 获取。

那么浏览器会阻止所有跨域请求吗?

这也是个很大的误区,如果浏览器阻止所有跨域,那么怎么加载图片资源?

比如网页里:

<img src="https://other.com/a.png">

这个其实就是:跨域请求,当前网站请求不同域名的图片。但是浏览器允许,否则网页无法加载外部资源。因为:

  • CDN图片
  • 外部字体
  • 外部JS
  • 外部CSS

全都跨域。

浏览器真正限制的,是:JavaScript 跨站读取数据

因为:

  • 加载图片
  • 加载脚本
  • 加载 CSS

这些行为本身并不一定危险。

真正危险的是:恶意网站读取用户隐私数据。

很多跨域情况下: 浏览器允许请求发送,但会限制前端 JavaScript 获取响应内容。 不过,对于某些不符合 CORS 规则的请求,浏览器也可能直接终止请求。

虽然 <img> 可以跨域加载图片, 但如果你尝试:

canvas.drawImage(img)

再读取像素数据:

canvas.toDataURL()

浏览器仍然会报跨域错误。因为浏览器允许“显示资源”,不代表允许“读取资源内容”。

跨域最核心的一句话其实是:浏览器限制的不是“请求”,而是“跨站读取数据”。

理解原理之后,下面我们真正动手,亲自复现一次跨域问题。因为只有真正看到浏览器报错,才会对 CORS 有最深的理解。

实战:用一个小demo彻底理解跨域

先写一个没有cros跨域的后端代码

const express = require("express");

const app = express();

app.get("/user", (req, res) => {
  console.log("接口被请求了");

  res.json({
    username: "admin",
    role: "frontend"
  });
});

app.listen(3000, () => {
  console.log("server running at 3000");
});

前端React发送请求

import axios from "axios";

function App() {
  async function getUser() {
    try {
      const res = await axios.get("http://localhost:3000/user");
      console.log(res.data);
    } catch (err) {
      console.error(err);
    }
  }

  return (
    <div>
      <button onClick={getUser}>
        获取用户信息
      </button>
    </div>
  );
}

export default App;

启动好前后端后,就可以点击前端按钮发送请求,我们可以看到浏览器的同源策略的报错:

image.png

此时再打开network面板

image.png

查看请求头发现返回码是200,说明已经成功返回但是response中没有数据

image.png

此时如果我们用Postman同样请求后端,可以看到数据正常返回:

image.png

查看后端发现接口正常请求:

image.png

说明是浏览器不拦截请求,而是拦截了js读取响应。

怎么解决跨域?

1、最常用:后端配置 CORS

后端加上cors中间件即可

const express = require("express");
const cors = require("cors");

const app = express();

app.use(cors());

app.get("/user", (req, res) => {
  console.log("接口被请求了");

  res.json({
    username: "admin",
    role: "frontend",
  });
});

app.listen(3000, () => {
  console.log("server running at 3000");
});

前端正常返回

image.png

cors() 到底干了什么?

其实它本质上只是给响应头加了:

Access-Control-Allow-Origin: *

在network中可以看到添加的响应头内容,表示服务器允许跨域读取

image.png

其实不用 cors 包,你自己也能写:

app.use((req, res, next) => {
  res.setHeader(
    "Access-Control-Allow-Origin",
    "http://localhost:5173"
  );

  res.setHeader(
    "Access-Control-Allow-Methods",
    "GET,POST,PUT,DELETE"
  );

  res.setHeader(
    "Access-Control-Allow-Headers",
    "Content-Type, Authorization"
  );

  next();
});

有的人可能会看到network里有两个请求

明明只写了一个:

axios.post(...)

但 Network 里却出现:

OPTIONS /user
POST /user

这是因为浏览器发现: 这个跨域请求“可能不安全”。

比如:

  • PUT
  • DELETE
  • Authorization
  • application/json

于是浏览器会先发送:

OPTIONS

询问服务器:“你允许这个跨域请求吗?”

服务器返回:

Access-Control-Allow-Origin
Access-Control-Allow-Headers
Access-Control-Allow-Methods

浏览器确认允许后: 才会真正发送 POST 请求。

这个过程叫:预检请求(Preflight Request)

2、指定允许的前端地址(真实项目推荐)

实际项目一般不会:

Access-Control-Allow-Origin: *

而是只允许指定网站(origin 表示允许跨域的网站)

app.use(cors({
  origin: "http://localhost:5173"
}));

这样只有:localhost:5173 能访问,更安全。

3、携带 Cookie 时(非常重要)

如果请求默认携带cookie

fetch(..., {
  credentials: "include"
})
axios.defaults.withCredentials = true

那么后端不能写:

Access-Control-Allow-Origin: *

浏览器会直接报错。因为一旦请求携带cookie+允许所有网站跨域访问,那么黑客可以通过cookie拿到用户的隐私信息。而没有携带cookie时最多只会暴露公开信息,风险相对较小。

正确写法:后端必须指定可以跨域的网站

app.use(cors({
  origin: "http://localhost:5173",
  credentials: true
}));

浏览器最终会看到:

Access-Control-Allow-Origin: http://localhost:5173
Access-Control-Allow-Credentials: true

这样浏览器才允许跨域携带 Cookie。

4、前端代理(开发环境最常见)

这是React/Vite/Vue 开发最常用的方法。核心思想是“骗过浏览器”

由于跨域问题只是浏览器的机制,那么我只要前端请求时同源,代理服务器再偷偷转发给后端不就可以了吗?本质是服务器的跨域,避免了浏览器的跨域。

浏览器的跨域:前端协议/地址/端口 与 要请求的协议/地址/端口 不一致。

5173  3000

现在前端先请求:

5173  5173

同源。

然后Vite 服务器偷偷帮你转发到 3000。于是浏览器以为根本没跨域。

Vite 配置 proxy

export default defineConfig({
  server: {
    proxy: {
      "/api": {
        target: "http://localhost:3000",
        changeOrigin: true,
      },
    },
  },
});

前端

axios.get("/api/user");

然后Vite 自动代理,转发到3000/user,Vite 代替浏览器去请求后端。

所以 proxy 本质是:“中间服务器转发”,也叫反向代理(Reverse Proxy)

代理只能开发用,因为真正跨域问题并没消失。只是开发服务器帮你中转了(只存在于开发环境,生产环境没有vite),生产环境还是需要后端正确配置 CORS。(Nginx、网关、Node 中间层)

5、Nginx、网关、Node 中间层 反向代理(生产环境)

原理就是代理转发。它们虽然都是代理请求,但是职责完全不同:

组件更偏向主要职责
Nginx网络层静态资源、反向代理、负载均衡
API Gateway系统入口层鉴权、限流、服务路由
Node BFF业务层聚合接口、适配前端、SSR

Nginx

Nginx 是一个高性能 Web 服务器 + 反向代理服务器。最大的特点是: 非常并发能力非常强。

Nginx是事件驱动(Event Driven),异步非阻塞 IO

它的思想是:一个线程管理很多连接,并发能力极强。

而不是传统服务器模型的:一个连接开一个线程,如果有10000个用户那么就会有10000个线程,线程切换成本巨大,CPU会爆炸。

你可以先把它理解成:“网站门口的总管”,用户访问网站时,其实很多时候第一时间接触到的不是 Node/Java/Python 后端,而是Nginx。

为什么需要 Nginx?

因为后端服务不适合直接暴露给用户。

如果直接给用户访问会有很多问题:

  • 性能差
  • 并发能力弱
  • 不适合处理静态资源
  • HTTPS 麻烦
  • 容易崩
  • 无法负载均衡

Nginx 最核心的几个作用:

一. 静态资源服务器

index.html
main.js
style.css
logo.png

静态资源本质就是“读文件”,浏览器请求资源,Nginx直接从磁盘读取文件返回。

Nginx 是 C 语言写的,并且极度优化了文件 IO。

  • 内存占用低
  • 系统调用少
  • 文件读取快

二. 反向代理(最重要)

解决浏览器跨域问题。

三. 负载均衡

比如你的网站火了,一台 Node 扛不住。

于是:

Node1
Node2
Node3

Nginx:有很多算法,自动帮你分流。

  1. 轮询(默认)
请求A → Node1
请求B → Node2
请求C → Node3
请求D → Node1
请求E → Node2
请求F → Node3

2. 权重,性能强的机器分更多请求。

  1. IP Hash,同一用户固定同一服务器。

  2. 最少连接,谁最闲给谁。

用户
 ↓
Nginx
 ↓
Node1 / Node2 / Node3

四. HTTPS

Nginx 本质上是专门为“高性能网络通信”设计的,专注解决“网络层问题”。

  • TCP 连接

  • HTTP 请求

  • 文件传输

  • HTTPS 加密

  • 请求转发

  • 并发连接

网关(Gateway)

网关是所有后端服务的统一入口。

真实项目中往往会有多个后端,也就是微服务

微服务就是把一个巨大的后端系统,拆成很多“小服务”。

每个服务:

  • 独立开发
  • 独立运行
  • 独立部署
  • 独立数据库(很多时候)
用户服务
订单服务
支付服务
商品服务
聊天服务
推荐服务
...

如果没有网关,前端要自己请求:

user.xxx.com
order.xxx.com
pay.xxx.com
chat.xxx.com

问题巨大:

  • 接口太乱
  • 域名很多
  • 权限难统一
  • 登录难统一
  • 安全难管理
  • 跨域复杂
  • 前端耦合严重

于是需要一个“总入口”,这就是:Gateway(网关)。

网关最核心作用就是统一入口,用户永远只访问:

api.xxx.com

比如:

api.xxx.com/user
api.xxx.com/order
api.xxx.com/pay

网关内部再转发:

/user → 用户服务
/order → 订单服务
/pay → 支付服务

Gateway 常见功能:

  1. 登录鉴权(特别重要)

比如:JWT。

用户请求:

/api/order

Gateway 先检查:

Authorization: Bearer xxx

验证:

  • token 是否合法
  • 是否过期
  • 用户权限

验证成功再转发给:订单服务,后端服务不用重复鉴权。

2、API 聚合(特别经典)

比如:前端首页需要:

  • 用户信息
  • 商品列表
  • 推荐数据

如果前端自己请求:

/user
/products
/recommend

会很多请求。

Gateway 可以统一返回:

{
  "user": {},
  "products": [],
  "recommend": []
}

这叫:BFF(Backend For Frontend)

3、限流

比如: 防止有人疯狂刷接口。

Gateway:

1秒最多10次

超过则直接拒绝。

4、灰度发布

比如:新功能只给:

10% 用户

Gateway:可以控制流量。

5、服务发现

微服务很多:

user-service
order-service
pay-service

IP 还会动态变化。

Gateway负责:找到真正服务地址。

Node中间层(BFF)

真实后端接口往往很难用

比如首页需要:

  • 用户信息
  • 商品列表
  • 推荐数据

结果后端给你:

/user/detail
/product/list
/recommend/list
/banner/list
/activity/list

前端请求一堆。

await fetch(...)
await fetch(...)
await fetch(...)
await fetch(...)

更恶心的是:不同后端数据格式还不统一。

于是出现了: Node 中间层(BFF),“专门服务前端” 所以: BFF = Backend For Frontend

Node 中间层会做什么?

  1. 聚合接口

前端:

/home

Node内部:

/user
/product
/recommend

然后统一返回:

{
  "user": {},
  "products": [],
  "recommend": []
}

前端只请求一次,性能更好,代码更简单。

  1. 统一数据格式

Node可以统一不同语言写的后端接口

return {
  code: 0,
  data: result
}

3. 处理权限逻辑

比如:JWT。

Node可以统一验证,后端服务不用重复写。

jwt.verify(token)

4. 防止前端直接暴露真实后端

比如:真实后端接口:

internal-user-service.xxx

前端根本不知道,只知道:

/api/user

安全性更高。

  1. SSR(特别重要)

SSR(服务端渲染)是指:页面 HTML 由服务器提前生成,而不是浏览器拿到 JS 后再渲染。

普通 React(CSR 客户端渲染)是先返回空的HTML,再下载JS,最后渲染。

你现在写 React:

比如: Next.js。

Node 中间层可以服务器提前请求数据,然后直接返回 HTML,SEO 更好。

6、JSONP(历史方案,了解即可)

早期浏览器没有 CORS,于是利用<script> 可以跨域的特点。

比如:

<script src="https://cdn.com/vue.js"></script>

浏览器会:

第一步:请求

GET https://cdn.com/vue.js

第二步:服务器返回 js

window.Vue = ...

第三步:浏览器直接执行这段 JS。

所以页面里就有:

Vue

那么JSONP 的本质就是模仿这个过程(script 请求到的内容会被“当 JS 执行”)

前端先定义函数:

function getUser(data) {}

后端返回:

getUser({...})

浏览器把返回内容当 JS 执行,于是函数被调用,数据就传进去了,实现:“跨域获取数据”。

但 JSONP 有巨大缺点,现在基本都用 CORS。

  • 只能 GET
  • 不安全
  • 本质是执行 чужое JS
  • 现代项目几乎不用