从同源策略到Nginx代理:React+NestJS全栈项目跨域实战
一次搞懂跨域的本质与三种解决方案,附完整代码解析
前言:一个真实的全栈项目场景
最近在搭建一个基于 React + NestJS 的全栈项目时,遇到了前端开发中几乎绕不开的经典问题------跨域。
我的前端跑在 http://localhost:5173(Vite默认端口),后端服务在 http://localhost:3000。当前端通过 fetch 或 axios 向后端 /api/todos 发起请求时,浏览器直接抛出了一个熟悉的红色错误:
csharp
Access to XMLHttpRequest at 'http://localhost:3000/api/todos' from origin 'http://localhost:5173' has been blocked by CORS policy
这个错误背后到底发生了什么?为什么浏览器要"多管闲事"?又该如何优雅地解决?
本文会从同源策略 的底层原理讲起,逐层深入,最终带大家用 CORS 、Vite代理 和 Nginx反向代理三种方式彻底解决跨域问题。同时,我会结合项目中的真实代码,展示一个完整的全栈项目架构设计。
一、同源策略:浏览器安全的基石
1.1 什么是"源"(Origin)
在讲跨域之前,我们必须先理解同源策略(Same-Origin Policy)。
"源"由三个部分共同定义:
| 组成部分 | 示例 |
|---|---|
| 协议(Protocol) | http:// |
| 域名(Domain) | localhost |
| 端口(Port) | 5173 |
只有当两个URL的协议、域名、端口 三者完全一致时,它们才是同源的。
| 对比URL | 是否同源 | 原因 |
|---|---|---|
http://localhost:5173 和 http://localhost:5173/api |
✅ 是 | 协议、域名、端口一致 |
http://localhost:5173 和 http://localhost:3000 |
❌ 否 | 端口不同(5173 ≠ 3000) |
http://localhost:5173 和 https://localhost:5173 |
❌ 否 | 协议不同(http ≠ https) |
http://localhost:5173 和 http://127.0.0.1:5173 |
❌ 否 | 域名不同(localhost ≠ 127.0.0.1) |
1.2 同源策略限制了什么?
同源策略是浏览器最重要的安全机制,它限制了来自不同源的文档或脚本对当前源资源的交互能力。主要限制在以下三个方面:
- DOM访问限制:不同源的页面无法互相操作对方的DOM。
- Cookie、LocalStorage等存储限制:不同源无法读取对方的存储数据。
- 网络请求限制 :不同源的AJAX/Fetch请求会被浏览器拦截------这正是我们遇到的跨域问题的核心。
1.3 为什么需要同源策略?
试想一下:如果没有同源策略,你打开了一个恶意网站,这个网站可以直接向你的银行服务器发起请求,获取你的账户信息,甚至执行转账操作。因为浏览器会携带你在该网站存储的Cookie,服务器会认为是你在操作。
同源策略就像一道防火墙,隔离了不同源之间的交互,保护了用户数据的安全。
但这也带来了一个实际开发中的矛盾:前后端分离架构下,前端和后端往往部署在不同的端口甚至不同的域名下,如何让它们正常通信?
这正是跨域问题产生的根源,也是本文要解决的核心问题。
二、项目架构概览:技术栈与目录结构
在深入解决跨域之前,我们先看看项目的整体架构。这样后面分析代码和解决方案时会更有全局视角。
2.1 技术栈一览
| 层级 | 技术选型 | 说明 |
|---|---|---|
| 前端框架 | React 18 + TypeScript | 类型安全,提升开发体验 |
| 状态管理 | Zustand | 轻量级状态管理,比Redux更简洁 |
| 网络请求 | Axios | 基于Promise的HTTP客户端,支持拦截器 |
| 构建工具 | Vite | 极速的开发服务器和构建工具 |
| 后端框架 | NestJS | 基于Node.js的渐进式框架,支持TypeScript |
| 容器化 | Docker + Nginx | 生产环境部署方案 |
2.2 前端目录结构
bash
src/
├── api/
│ ├── config.ts # Axios实例配置(拦截器、baseURL)
│ └── todos.ts # 具体的API请求函数
├── components/
│ └── TodoList.tsx # 待办列表UI组件
├── store/
│ └── todoStore.ts # Zustand状态管理
├── types/
│ └── todo.ts # TypeScript类型定义
├── App.tsx # 根组件
└── main.tsx # 入口文件
2.3 请求链路图
三、跨域解决方案一:CORS(服务端配置)
CORS(Cross-Origin Resource Sharing,跨域资源共享)是W3C制定的标准,它允许服务器通过设置HTTP响应头来声明哪些源可以访问其资源。
3.1 CORS的工作原理
CORS的核心在于服务器在响应头中添加特定的字段,告诉浏览器:"这个源是被允许的,你可以放行"。
关键的响应头字段:
| 响应头 | 作用 |
|---|---|
Access-Control-Allow-Origin |
指定允许访问的源(*表示任意源) |
Access-Control-Allow-Methods |
允许的HTTP方法(GET、POST等) |
Access-Control-Allow-Headers |
允许的自定义请求头 |
Access-Control-Allow-Credentials |
是否允许携带Cookie |
Access-Control-Max-Age |
预检请求的缓存时间 |
3.2 简单请求 vs 预检请求
CORS请求分为两种:
简单请求(同时满足以下条件):
- 方法为 GET、HEAD、POST 之一
- 只使用了 CORS 安全首部(如 Accept、Accept-Language、Content-Language、Content-Type 且值为 application/x-www-form-urlencoded、multipart/form-data、text/plain)
非简单请求(如 PUT、DELETE 或 Content-Type: application/json):
- 浏览器会先发送一个 OPTIONS 预检请求,询问服务器是否允许实际操作
- 服务器响应预检请求后,浏览器才会发送真实请求
3.3 NestJS中配置CORS
NestJS基于Express,配置CORS非常简单。在NestJS的main.ts中:
typescript
// main.ts (NestJS)
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// 启用CORS
app.enableCors({
origin: 'http://localhost:5173', // 允许的前端源
methods: 'GET,HEAD,PUT,PATCH,POST,DELETE',
credentials: true, // 允许携带凭证
});
await app.listen(3000);
}
bootstrap();
为什么CORS能解决跨域? 因为服务器通过Access-Control-Allow-Origin头明确告知浏览器:"这个源(http://localhost:5173)是我允许的",浏览器收到这个头后就不会拦截响应了。
CORS的优缺点:
- ✅ 标准方案,适用于各种环境
- ✅ 粒度细,可以精确控制允许的源和方法
- ❌ 需要后端配合配置
- ❌ 生产环境如果配置
*会有安全风险
注意 :在NestJS中,如果使用
app.enableCors({ origin: '*' }),则credentials: true会失效,因为带凭证的请求不能使用*通配符,必须指定具体的源。
四、跨域解决方案二:Vite代理(开发环境)
4.1 代理的原理
代理(Proxy)的核心思想是:把跨域请求变成同源请求。
具体做法是:
- 前端在
localhost:5173下请求/api/todos - 开发服务器(Vite)接收到请求后,将请求转发到
localhost:3000/api/todos - 浏览器看到的请求是
localhost:5173/api/todos,与页面同源,浏览器不认为是跨域请求 - 实际上,请求被Vite服务器"偷偷"转发到了后端
4.2 Vite代理配置
在vite.config.ts中配置代理:
typescript
// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
proxy: {
// 将以 /api 开头的请求代理到目标服务器
'/api': {
target: 'http://localhost:3000',
changeOrigin: true, // 修改请求头中的host为目标地址
// rewrite: (path) => path.replace(/^\/api/, ''), // 可选:路径重写
},
},
},
});
配置解析:
| 配置项 | 作用 |
|---|---|
'/api' |
匹配所有以/api开头的请求路径 |
target |
代理转发的目标地址 |
changeOrigin: true |
将请求头中的Host字段改为目标地址,防止后端通过Host做校验 |
注意 :Vite代理只在开发环境生效 。当你执行
npm run build构建生产包后,代理配置不会被打包进去。生产环境需要借助Nginx等反向代理。
4.3 配合Axios的baseURL
在config.ts中,我们将baseURL设置为/api:
typescript
// src/api/config.ts
const service = axios.create({
baseURL: '/api', // 统一前缀
timeout: 10000,
});
这样,service.get('/todos')实际请求的完整URL是/api/todos,在开发环境下会被Vite代理捕获并转发到http://localhost:3000/api/todos。
代理的本质 :它并没有真正"解决"跨域,而是绕过了浏览器的同源策略------因为请求是从服务器发起的,不经过浏览器,自然不受同源策略限制。
五、跨域解决方案三:Nginx反向代理(生产环境)
生产环境中,我们通常会部署Nginx作为Web服务器和反向代理。Nginx可以实现与Vite代理相同的效果,但更强大、更稳定。
5.1 Nginx反向代理配置
假设我们有一个Docker化的部署环境,Nginx配置文件如下:
nginx
# nginx.conf
server {
listen 80;
server_name example.com;
# 前端静态文件
location / {
root /usr/share/nginx/html;
try_files $uri $uri/ /index.html;
}
# 反向代理后端API
location /api/ {
proxy_pass http://backend:3000/api/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
配置解析:
| 指令 | 作用 |
|---|---|
location /api/ |
匹配以/api/开头的请求 |
proxy_pass |
将请求转发到后端服务 |
proxy_set_header |
设置转发请求的头信息,保留原始客户端信息 |
5.2 完整的Docker Compose编排
通常我们会用Docker Compose来编排前端、后端和Nginx:
yaml
# docker-compose.yml
version: '3'
services:
backend:
build: ./backend
ports:
- "3000:3000"
environment:
- NODE_ENV=production
frontend:
build: ./frontend
ports:
- "80:80"
depends_on:
- backend
# 前端容器中运行Nginx,作为静态文件服务器和反向代理
提示:生产环境使用Nginx代理的优势在于:
- 高性能:Nginx处理静态文件和高并发请求非常优秀
- 灵活性:可以配置负载均衡、缓存、SSL等
- 安全性:可以隐藏后端服务的具体地址和端口
5.3 三种方案对比
| 方案 | 适用环境 | 是否需要后端配合 | 核心原理 |
|---|---|---|---|
| CORS | 所有环境 | ✅ 需要 | 服务器声明允许的源 |
| Vite代理 | 开发环境 | ❌ 不需要 | 开发服务器转发请求 |
| Nginx代理 | 生产环境 | ❌ 不需要 | 反向代理转发请求 |
六、前端架构设计:状态管理与API调用
理解了跨域解决方案后,我们来看前端代码的完整设计。这里采用 Zustand + Axios 的组合,形成了清晰的分层架构。
6.1 Axios实例配置:拦截器的妙用
config.ts是整个API请求的基础设施,它创建了一个独立的Axios实例,并配置了请求/响应拦截器:
typescript
// src/api/config.ts
import axios from 'axios';
// 1. 创建独立的 axios 实例,避免污染全局配置
const service = axios.create({
baseURL: '/api', // 统一请求前缀,配合代理
timeout: 10000, // 全局超时时间
});
// 2. 请求拦截器:在请求发出前统一处理
service.interceptors.request.use(
(config) => {
// 从本地存储获取 token 并注入请求头
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => Promise.reject(error)
);
// 3. 响应拦截器:统一处理响应数据或全局错误
service.interceptors.response.use(
(response) => {
// 直接返回 response.data,省去业务代码中重复的 .data 解析
return response.data;
},
(error) => {
// 全局错误处理:401 跳转登录页等
console.error('API Error:', error.message);
return Promise.reject(error);
}
);
export default service;
这里有几个设计亮点值得关注:
① 为什么要创建独立的实例?
axios.create()创建了一个全新的Axios实例,它的配置不会影响全局默认配置。这样在项目中可以同时存在多个不同配置的实例(例如:一个带Token的、一个不带Token的、一个用于文件上传的)。
② 请求拦截器的职责
在请求发出前,我们可以在这里做:
- 注入认证Token(JWT)
- 添加公共请求参数
- 对请求数据进行加密或格式化
③ 响应拦截器的巧妙设计
typescript
return response.data; // 直接返回 data
这行代码非常关键!它让业务代码中无需再写res.data.data这种冗长的取值方式,直接拿到业务数据。对应到todos.ts中:
typescript
// 调用方直接拿到业务数据,无需 .data
const res = await getTodos(); // res 就是 Todo[] 类型
6.2 API模块:类型安全的接口封装
todos.ts封装了所有与待办事项相关的API调用:
typescript
// src/api/todos.ts
import service from './config';
import { type Todo } from '../types/todo';
// 获取所有 Todos
export const fetchTodos = () => {
return service.get<Todo[]>('/todos');
};
// 新增 Todo
export const createTodo = (title: string) => {
return service.post<Todo>('/todos', { title });
};
// 更新 Todo 状态
export const updateTodo = (id: number, patch: Partial<Todo>) => {
return service.patch<Todo>(`/todos/${id}`, patch);
};
// 删除 Todo
export const deleteTodo = (id: number) => {
return service.delete(`/todos/${id}`);
};
TypeScript泛型的应用:
service.get<Todo[]>('/todos') 通过泛型 Todo[] 告诉TypeScript这个接口返回的数据结构是Todo数组。配合响应拦截器返回的response.data,调用方可以直接获得类型安全的数据。
typescript
const todos = await fetchTodos(); // TypeScript 推导出 todos 的类型为 Todo[]
这种设计让前端代码的类型安全贯穿始终,从API响应到状态管理再到UI渲染,全程有类型保障。
6.3 Zustand Store:轻量级状态管理
Zustand是一个非常轻量但强大的状态管理库,相比Redux,它的学习成本更低,代码更简洁。
typescript
// src/store/todoStore.ts
import { create } from 'zustand';
import { type Todo } from '../types/todo';
import { fetchTodos as getTodos, createTodo } from '../api/todos';
interface TodoStore {
todos: Todo[];
fetchTodos: () => Promise<void>;
addTodo: (title: string) => Promise<void>;
}
export const useTodoStore = create<TodoStore>((set) => ({
todos: [],
// 获取所有待办事项
fetchTodos: async () => {
const res = await getTodos();
console.log(res);
set({ todos: res });
},
// 新增待办事项
addTodo: async (title: string) => {
const res = await createTodo(title);
console.log(res);
// 优化:新增成功后更新本地状态
set((state) => ({ todos: [...state.todos, res] }));
},
}));
Zustand的核心设计理念:
① create 函数 :接受一个工厂函数,返回一个自定义Hook(useTodoStore),在组件中直接调用。
② set 函数:用于更新状态,可以传入新状态对象或一个更新函数。Zustand会自动触发组件重新渲染。
③ 异步操作 :直接在Store中编写async/await函数,比Redux的Thunk/Saga方案更直观。
注意 :在addTodo中,我建议使用set((state) => ({ todos: [...state.todos, res] }))这种函数式更新 方式,而不是直接set({ todos: [...state.todos, res] })。原因是在并发场景下,函数式更新能确保基于最新的状态进行更新。
6.4 组件层:UI与状态的连接
TodoList.tsx是UI组件,它通过useTodoStoreHook连接状态:
typescript
// src/components/TodoList.tsx
import React, { useEffect } from 'react';
import { useTodoStore } from '../store/todoStore';
const TodoList: React.FC = () => {
const { todos, fetchTodos, addTodo } = useTodoStore();
useEffect(() => {
fetchTodos(); // 组件挂载时获取数据
}, []);
const handleAdd = () => {
const title = prompt('Enter todo title:');
if (title) addTodo(title);
};
return (
<div>
<h1>Todo List</h1>
<button onClick={handleAdd}>Add Todo</button>
<ul>
{todos.map((todo) => (
<li key={todo.id}>{todo.title}</li>
))}
</ul>
</div>
);
};
export default TodoList;
数据流向:
用户点击按钮 → 调用 addTodo → 更新 Zustand Store → 触发组件重新渲染 → UI 更新
整个流程是单向数据流,清晰可控。
七、项目中的跨域问题解决实战
回到文章开头的问题,让我们结合项目代码,看看跨域问题是如何被一步步解决的。
7.1 开发环境配置
第一步 :在config.ts中设置baseURL: '/api':
typescript
const service = axios.create({
baseURL: '/api', // 所有请求以 /api 开头
});
第二步 :在vite.config.ts中配置代理:
typescript
server: {
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true,
},
},
}
第三步:在NestJS后端配置CORS(可选,但建议保留):
typescript
app.enableCors({
origin: 'http://localhost:5173',
credentials: true,
});
这样配置后,前端请求/api/todos → Vite代理转发到http://localhost:3000/api/todos → 后端返回数据 → 前端收到响应。跨域问题不复存在。
7.2 生产环境配置
生产环境使用Nginx反向代理,前端请求依然发送到/api,由Nginx转发到后端服务。
前端构建 :npm run build生成静态文件,部署到Nginx的静态目录。
Nginx配置 :如前文所述,将/api路径的请求代理到后端服务。
八、常见问题与踩坑记录
8.1 代理配置后请求404
问题:Vite代理配置后,请求返回404。
原因 :后端接口路径是/api/todos,但代理转发时没有正确拼接。
解决 :检查代理配置中的rewrite选项是否正确,或者后端路由是否匹配。
8.2 CORS配置了但依然被拦截
问题 :后端配置了enableCors({ origin: '*' }),但前端请求带credentials: 'include'时被拦截。
原因 :带凭证的请求不能使用*通配符。
解决 :指定具体的源,origin: 'http://localhost:5173'。
8.3 响应拦截器返回的.data丢失
问题 :在todos.ts中调用service.get<Todo[]>('/todos'),拿到的数据是AxiosResponse类型,而不是Todo[]。
原因 :没有在响应拦截器中return response.data。
解决 :在响应拦截器中返回response.data。
8.4 Zustand中状态更新不触发重新渲染
问题 :调用了set但页面没有更新。
原因 :可能是直接修改了状态对象,而不是创建新对象。Zustand使用浅比较检测变化。
解决 :确保使用不可变数据 方式更新,如set({ todos: newTodos }),而不是state.todos.push(newTodo)。
九、总结与思考
9.1 核心知识点回顾
本文围绕跨域问题,从同源策略的底层原理出发,介绍了三种解决方案:
| 方案 | 核心机制 | 最佳实践场景 |
|---|---|---|
| CORS | 服务器通过响应头声明允许的源 | 所有环境,尤其是需要精细控制访问权限的场景 |
| Vite代理 | 开发服务器转发请求 | 开发环境,快速迭代 |
| Nginx反向代理 | Web服务器转发请求 | 生产环境,高性能和高可用 |
9.2 技术选型感悟
Zustand + Axios的组合让我体验到了"少即是多"的哲学。相比Redux+Redux-Saga,Zustand的API设计更简洁,学习成本更低,同时完美支持TypeScript。
Axios拦截器的设计非常巧妙,将认证、日志、错误处理等横切关注点(Cross-cutting Concerns)从业务代码中抽离出来,让API调用代码保持干净。
Vite代理解决开发环境跨域的方式非常优雅------它让开发者可以"假装"前后端是同源的,从而专注于业务逻辑的开发。
9.3 关于跨域的深度思考
跨域问题本质上是浏览器的安全策略 与前后端分离架构之间的冲突。这个冲突在开发中不可避免,但正因为有了这些限制,Web应用才能够在开放的网络环境中安全地运行。
理解跨域的底层原理,不仅是为了解决当前的问题,更是为了在设计系统架构时,能够做出更合理的技术决策。知其然,更要知其所以然。
如果你觉得这篇文章有帮助,欢迎点赞、收藏、评论交流!你的支持是我持续创作的动力。
本文首发于掘金,转载请联系作者授权。