从同源策略到Nginx代理:React+NestJS全栈项目跨域实战

从同源策略到Nginx代理:React+NestJS全栈项目跨域实战

一次搞懂跨域的本质与三种解决方案,附完整代码解析

前言:一个真实的全栈项目场景

最近在搭建一个基于 React + NestJS 的全栈项目时,遇到了前端开发中几乎绕不开的经典问题------跨域

我的前端跑在 http://localhost:5173(Vite默认端口),后端服务在 http://localhost:3000。当前端通过 fetchaxios 向后端 /api/todos 发起请求时,浏览器直接抛出了一个熟悉的红色错误:

csharp 复制代码
Access to XMLHttpRequest at 'http://localhost:3000/api/todos' from origin 'http://localhost:5173' has been blocked by CORS policy

这个错误背后到底发生了什么?为什么浏览器要"多管闲事"?又该如何优雅地解决?

本文会从同源策略 的底层原理讲起,逐层深入,最终带大家用 CORSVite代理Nginx反向代理三种方式彻底解决跨域问题。同时,我会结合项目中的真实代码,展示一个完整的全栈项目架构设计。


一、同源策略:浏览器安全的基石

1.1 什么是"源"(Origin)

在讲跨域之前,我们必须先理解同源策略(Same-Origin Policy)

"源"由三个部分共同定义:

组成部分 示例
协议(Protocol) http://
域名(Domain) localhost
端口(Port) 5173

只有当两个URL的协议、域名、端口 三者完全一致时,它们才是同源的。

对比URL 是否同源 原因
http://localhost:5173http://localhost:5173/api ✅ 是 协议、域名、端口一致
http://localhost:5173http://localhost:3000 ❌ 否 端口不同(5173 ≠ 3000)
http://localhost:5173https://localhost:5173 ❌ 否 协议不同(http ≠ https)
http://localhost:5173http://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 请求链路图

sequenceDiagram participant UI as TodoList组件 participant Store as Zustand Store participant API as API模块 (todos.ts) participant Axios as Axios实例 (config.ts) participant Proxy as 代理层 (Vite/Nginx) participant Server as NestJS后端 UI->>Store: 调用 fetchTodos() Store->>API: 调用 getTodos() API->>Axios: service.get('/todos') Axios->>Proxy: 请求 /api/todos Proxy->>Server: 转发到实际后端 Server-->>Proxy: 返回数据 Proxy-->>Axios: 返回数据 Axios-->>API: 响应拦截器处理 API-->>Store: 返回数据 Store-->>UI: 更新状态,触发渲染

三、跨域解决方案一: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)的核心思想是:把跨域请求变成同源请求

具体做法是:

  1. 前端在localhost:5173下请求/api/todos
  2. 开发服务器(Vite)接收到请求后,将请求转发到localhost:3000/api/todos
  3. 浏览器看到的请求是localhost:5173/api/todos,与页面同源,浏览器不认为是跨域请求
  4. 实际上,请求被Vite服务器"偷偷"转发到了后端
flowchart LR Browser[浏览器 localhost:5173] -->|请求 /api/todos| Vite[Vite开发服务器] Vite -->|转发到| Backend[NestJS localhost:3000] Backend -->|响应| Vite Vite -->|返回| Browser

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应用才能够在开放的网络环境中安全地运行。

理解跨域的底层原理,不仅是为了解决当前的问题,更是为了在设计系统架构时,能够做出更合理的技术决策。知其然,更要知其所以然


如果你觉得这篇文章有帮助,欢迎点赞、收藏、评论交流!你的支持是我持续创作的动力。

本文首发于掘金,转载请联系作者授权。

相关推荐
卷无止境1 小时前
FastAPI Events 深度解析与工程实践
后端·python·fastapi
FYKJ_20101 小时前
springboot网上购书商城---附源码15749
java·spring boot·后端·python·spark·django·php
evans在进步2 小时前
Spring Boot 启动流程与 @SpringBootApplication 核心原理详解
java·spring boot·后端
fatcoder2 小时前
玩转 Redis · Set 篇
前端·redis·后端
掘金者阿豪2 小时前
你的公网 IP 是专线还是动态变化的?一文讲透动态 IP 与固定 IP 的那些事
前端·后端
超超不吵吵2 小时前
Java AI转型实战(八):RAG完整链路实战
后端
识途老码2 小时前
docker运行sqlserver
docker·容器·sqlserver
大厂码农老A2 小时前
汤森路透的座上宾?Qwen3.5-397B-A17B到底有什么本事?
前端·人工智能·后端
程序猿乐锅2 小时前
从 dsh 源码看「一切皆插件」与 Spring IoC
java·网络·数据库·人工智能·后端·spring