ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

FastAPI与React前后端分离实战:从API设计到全栈部署指南

2026/10/3 15:36:58 拓冰建站 浏览量
FastAPI与React前后端分离实战:从API设计到全栈部署指南 1. 前后端分离架构的核心设计思路1.1 这个项目到底在做什么FastAPI React 这套组合说白了就是在做一个典型的 Web 全栈项目React 负责浏览器里的界面渲染和用户交互FastAPI 负责接收 HTTP 请求、处理业务逻辑、读写数据库再把结构化 JSON 返回给前端。两者之间通过一套明确的 HTTP 接口契约协作互不干扰。我接手过不少独角兽公司遗留的“全家桶”项目也维护过那种把服务端渲染模板、路由、数据库查询全部耦合在一个框架里的老系统。那种项目最让人头疼的地方在于前端改一个按钮样式要小心翼翼因为一不小心就会碰到后端渲染逻辑后端换一个数据库字段前端模板也要跟着改。前后端分离项目实战的意义就是把这两种关注点彻底拆开让前端工程师只关心界面后端工程师只关心数据。前后端分离的好处还有一层前端和后端可以分别独立部署、独立扩容。React 构建出来的是一堆纯静态文件可以丢到 CDN 上分发给全世界FastAPI 跑在服务器上只接受 API 请求。用户量大了你可以只给后端加机器前端无需再动。这套架构在中小团队里非常合适它能把一份大需求拆成两条平行线并行开发提升交付速度。1.2 为什么是 FastAPI React而不是其他组合很多人会拿 Flask、Django、Spring Boot 来对比 FastAPI也会拿 Vue、jQuery 来对比 React。我逐一说说自己的真实感受。先看后端。Flask 很轻但很多时候轻到“什么都没有”你要自己装 Flask-SQLAlchemy、Flask-Migrate、Flask-JWT-Extended、Flask-CORS 一堆扩展才能拼出一个像样的项目。Django 又太重自带 Admin、ORM、Migration、Form、Auth 全套大项目很爽但中小项目和快速原型就显得杀鸡用牛刀而且“重”就重在学习曲线陡。Spring Boot 是 Java 生态的答案性能稳定、生态丰富但写一个小接口要配一堆注解启动一个项目要等好几秒开发体验对工程师的耐心是一种考验。FastAPI 正好卡在中间。它基于 Python 类型提示做到了“写的时候就是代码跑的时候就是接口文档”再加上 Pydantic 做参数校验和序列化写一个带完整校验的接口往往只需要十几行代码。更友好的是FastAPI 天然支持异步async def路由在遇到 IO密集型操作比如查数据库、调第三方 HTTP 接口时能更高效地利用线程资源。自动生成的 OpenAPI 文档在联调时更是神兵利器前端打开/docs就能看到所有接口的出入参结构可以直接在线调试。再看前端。React 的核心价值是组件化和数据驱动视图。组件化意味着界面可以拆成独立小块每个人开发自己的部分互不干扰数据驱动视图意味着你只需要更新状态React 会自动帮你计算出 DOM 的最小更新手动操作 DOM 的时代已经过去了。我见过不少团队用 jQuery 写过大型后台管理系统到后期维护成本高得惊人——一个全局状态的变化可能引发十几个 DOM 操作。React 让你从“如何操作 DOM”的思维转变成“数据长什么样界面就是什么样”这种心智模型一旦建立写复杂交互会轻松非常多。Vue 和 React 的争论我在其他文章里写过这里只说结论两者都能做出好项目。选择 React 的原因在于它的生态更庞大全栈方向相关的库比如 TanStack Query、Zustand、React Router质量都很高而且 TSX 写起来有非常明确的类型感。如果你是一名学习型的开发者React 那种“函数式组件 副作用钩子”的风格会强迫你理解更多 JavaScript 语言特性从长期来看对成长帮助不小。一句话总结我的选型逻辑FastAPI 负责用最少的心智负担把后端接口写好React 负责用最通透的组件化模型把前端展示写好中间用一份 OpenAPI 契约把它们拧在一起。2. FastAPI 后端从零搭建可维护的 API 服务2.1 项目结构与依赖管理拿到一个项目我习惯先把目录结构理清楚。结构乱了后面每一个功能的添加都会变成煎熬。以下是我在多个 FastAPI 项目里逐渐沉淀出来的一套目录结构backend/ ├── app/ │ ├── __init__.py │ ├── main.py │ ├── database.py │ ├── models.py │ ├── schemas.py │ ├── routers/ │ │ ├── __init__.py │ │ ├── auth.py │ │ └── users.py │ └── config.py ├── requirements.txt └── run.py这里面的核心思路是按照“模块”拆分而不是按照“文件类型”拆分。很多人会把所有的路由函数塞进一个 main.py几百行代码跑起来没问题但等到项目需要加权限、加日志、加缓存时你会发现你根本找不到该改哪里。依赖方面我推荐用一个requirements.txt管理整个环境的依赖fastapi0.115.6 uvicorn[standard]0.34.0 sqlalchemy2.0.36 pydantic2.10.4 pydantic-settings2.7.0 email-validator2.2.0 python-jose[cryptography]3.3.0 passlib[bcrypt]1.7.4 python-multipart0.0.20这里有不少细节值得说明。uvicorn[standard]这个方括号表示要装上 extra 依赖它会让 uvicorn 获得更高的性能比如用 uvloop 替代 asyncio 默认的事件循环。email-validator是 Pydantic 的EmailStr类型需要用的如果不装导入EmailStr会报错。python-multipart是 FastAPI 处理表单数据OAuth2PasswordRequestForm的后端依赖少了它登录接口会直接 500。创建虚拟环境这一步也别省cd backend python -m venv venv source venv/bin/activate pip install -r requirements.txt很多新手在这步最容易遇到坑明明pip list里能看到 fastapi但uvicorn app.main:app却提示找不到模块。90% 的情况是因为装到了全局 Python 环境而不是当前虚拟环境。检查一下你是否已经激活了虚拟环境路径前缀里有(venv)才算成功。2.2 配置管理不要让配置散落在代码里配置管理听起来是个小事但对项目来说是地基级别的问题。我见过太多项目把数据库地址、密钥、回调地址直接写在代码文件顶部还偏偏是 commit 到 Git 里的那种。稍微正规一点的团队都会用环境变量或配置文件来管理。FastAPI 生态里我强烈推荐 Pydantic 的BaseSettings。新建一个 config.pyfrom pydantic_settings import BaseSettings class Settings(BaseSettings): app_name: str Fullstack Demo API database_url: str sqlite:///./dev.db cors_origins: list[str] [http://localhost:5173] secret_key: str change-me-in-production class Config: env_file .env settings Settings()然后在项目根目录创建一个.env文件DATABASE_URLsqlite:///./dev.db CORS_ORIGINS[http://localhost:5173] SECRET_KEYyour-super-secret-key这样配置的好处是代码里不出现任何硬编码的密钥开发环境、测试环境、生产环境通过不同的.env文件就能切换。.env文件本身应该加入.gitignore防止把密钥提交到仓库。这里有一个细节要注意CORS_ORIGINS在 .env 里写成 JSON 数组格式[http://localhost:5173]pydantic-settings 会自动解析成 Python 的 list。如果你用的是旧的pydantic1.x 版本它不一定支持这种自动解析需要手动用json.loads()处理。建议直接上 pydantic 2.x省心很多。2.3 数据模型与 Pydantic 校验入参和出参要分开FastAPI 项目里有两套“模型”容易让人混淆一套是 SQLAlchemy 的 ORM 模型负责和数据库表映射一套是 Pydantic 的 Schema 模型负责接口入参和出参的校验。我的做法是严格分成两个文件models.py 和 schemas.py。models.py 里放 ORM 模型还是继续用 SQLAlchemy 2.0 的风格from sqlalchemy import Column, Integer, String, DateTime from sqlalchemy.orm import DeclarativeBase from datetime import datetime class Base(DeclarativeBase): pass class User(Base): __tablename__ users id Column(Integer, primary_keyTrue, indexTrue) username Column(String(50), uniqueTrue, indexTrue) email Column(String(120), uniqueTrue, indexTrue) password_hash Column(String(255)) created_at Column(DateTime, defaultdatetime.utcnow)schemas.py 里放 Pydantic 模型from pydantic import BaseModel, EmailStr, Field from datetime import datetime class UserCreate(BaseModel): username: str Field(..., min_length3, max_length50) email: EmailStr password: str Field(..., min_length6, max_length128) class UserOut(BaseModel): id: int username: str email: str created_at: datetime class Config: from_attributes True注意UserCreate里有password字段而UserOut里没有。这就是我强调的“入参模型和出参模型一定要分开”的实战意义如果一个模型既要接用户提交的数据又要返回用户信息很容易把password_hash返回到前端。而这种安全问题往往在代码 review 时看不出来直到渗透测试或线上事故才暴露。from_attributes True这个配置也很关键。它让 Pydantic 可以直接从 SQLAlchemy 对象创建实例否则你需要手动写成UserOut(iduser.id, usernameuser.username, ...)字段多了会写到手抽筋。在 pydantic 2.x 里它替代了旧版的orm_mode True。FastAPI 在response_modelschemas.UserOut时会拿着数据库返回的 ORM 对象自动做这种转换。2.4 路由编写状态码语义化与统一响应路由是接口的灵魂也是前后端契约的第一入口。在 routers/users.py 里我一般这么写from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.orm import Session from app.database import get_db from app import models, schemas router APIRouter(prefix/api/users, tags[users]) router.post(/, response_modelschemas.UserOut, status_codestatus.HTTP_201_CREATED) def create_user(user: schemas.UserCreate, db: Session Depends(get_db)): existing db.query(models.User).filter( (models.User.username user.username) | (models.User.email user.email) ).first() if existing: raise HTTPException( status_codestatus.HTTP_409_CONFLICT, detail用户名或邮箱已被注册, ) db_user models.User(usernameuser.username, emailuser.email) db.add(db_user) db.commit() db.refresh(db_user) return db_user router.get(/, response_modellist[schemas.UserOut]) def list_users(skip: int 0, limit: int 100, db: Session Depends(get_db)): return db.query(models.User).offset(skip).limit(limit).all()这里有几个值得说的点。一个是status_codestatus.HTTP_201_CREATED。创建成功后返回 201 而不是 200这是 REST 语义的一部分。前端可以根据状态码走不同的逻辑2xx 都算成功201 表示创建成功可能需要刷新列表204 表示无内容401 未登录403 无权限409 冲突。很多新手写接口永远是 200把错误信息都塞在响应的 body 里。这样做的坏处是前端无法用统一拦截器识别异常每个请求都要解包看内部业务码。我们既然做全栈就不要给自己挖这个坑。另一个是错误响应的格式。FastAPI 的HTTPException默认返回{detail: 错误信息}。如果你在项目里所有接口都保持这个结构前端的 axios 拦截器只需要统一判断error.response.data.detail就能弹出错误提示不用每个页面分别处理。这算是最朴素的“前后端错误规范约定”了。每个接口我还会加上skip和limit这样的分页参数虽然是 demo但分页几乎是业务接口的标配。前端传?skip20limit10就能翻页比一次性拉取全部数据要稳妥得多。等到数据量大了可以再升级成基于 cursor 的分页方案但当前这种 offset 分页在中小项目里已经足够。2.5 数据库会话管理一劳永逸的 get_db 依赖FastAPI 的依赖注入系统是它最被低估的武器之一。用Depends(get_db)处理数据库会话是我见过解决连接泄漏最优雅的方式。from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker from app.config import settings engine create_engine( settings.database_url, connect_args{check_same_thread: False} if settings.database_url.startswith(sqlite) else {} ) SessionLocal sessionmaker(autocommitFalse, autoflushFalse, bindengine) def get_db(): db SessionLocal() try: yield db finally: db.close()get_db是生成器函数FastAPI 会在请求开始前获取生成器把db注入到路由函数参数中请求结束后在 finally 块里关闭会话。这样不需要在每个路由里手工写db.close()。文件多了以后这个能力节省的心力是巨大的。很多踩坑帖都在问“FastAPI 连接数越来越高是怎么回事”十有八九是没用Depends(get_db)而是在函数里手动创建 Session 却没关闭。数据库连接一旦泄漏初期看不出来过几天数据库的连接数打满整个服务直接雪崩。我建议所有 FastAPI 项目从第一天起就养成Depends(get_db)的习惯。使用 SQLite 的读者务必注意connect_args{check_same_thread: False}这个参数。FastAPI 的线程池会让请求跑在不同的线程里SQLite 默认只允许创建它的线程访问数据库不加这个参数就会报SQLite objects created in a thread can only be used in that same thread。等到接 PostgreSQL 或 MySQL 时这个参数就不需要了所以我在代码里做了字符串判断来区分。3. React 前端从 Vite 到可维护的组件架构3.1 初始化项目选 Vite 而不是 CRA前端的工程化工具链近几年变化非常快。Create React AppCRA曾经是官方推荐但它的开发服务器启动慢、配置自由度低社区维护也放慢了很多。现在新项目基本上都用 Vite启动速度非常快开发体验极佳构建产物也比 CRA 小得多。初始化命令很简单npm create vitelatest frontend -- --template react-ts cd frontend npm install npm install axios react-router-dom zustand这里我特意选了react-ts模板而不是纯 JS 模板。前后端分离项目里TypeScript 不是可选项而是必需品。后端有 Pydantic 帮你定义接口出入参前端没有 TypeScript 的话联调时从response.data里拿到的字段就没有任何保障。写错了字段名运行时才报undefined那种调试体验经历过的人都懂。3.2 API 请求层拦截器是灵魂前端项目里我最在意的文件之一是api/client.ts它决定了整个项目调用后端的统一姿势。用 axios 封装一个实例核心代码如下import axios from axios; import { useAuthStore } from ../stores/authStore; const apiClient axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL || /api, timeout: 10000, }); apiClient.interceptors.request.use((config) { const token useAuthStore.getState().token; if (token) { config.headers.Authorization Bearer ${token}; } return config; }); apiClient.interceptors.response.use( (response) response, (error) { if (error.response?.status 401) { useAuthStore.getState().logout(); window.location.href /login; } return Promise.reject(error); } ); export default apiClient;第一段逻辑是请求拦截从 Zustand store 里取出 token如果存在就自动加到Authorization头。这样业务代码里完全不用关注 token 拼接这件事。你写个apiClient.get(/users)它自然会带上认证信息。第二段逻辑是响应拦截如果收到 401说明 token 失效或未登录直接执行 logout 并跳转到登录页。这个统一处理能避免每个页面都写一遍if (error.status 401)。如果没有这个拦截器遇到登录过期前端可能只会默默地在某个接口上抛一个错误用户无感知体验很糟。关于baseURL这个配置我再强调一下把它设成/api是因为开发环境由 Vite proxy 转发生产环境由 Nginx 转发前端代码始终只认/api这个前缀。这样无论后端部署在哪个 IP、哪个端口前端代码都不用改动。3.3 状态管理Zustand 的轻量之美React 的状态管理库我用过 Redux Toolkit、MobX、Zustand。Redux Toolkit 功能强大、规范严格但模板代码量不小对于一个 FastAPI React 这种“后端已经帮前端处理了很多逻辑”的架构来说很多状态管理特性都用不上。MobX 的响应式模型很有趣但团队新手上手成本略高。Zustand 是我现在最喜欢的选择代码量极小没有 Provider 嵌套直接在组件外用 hook 取状态。最简单的 auth store 长这样import { create } from zustand; interface User { id: number; username: string; email: string; } interface AuthState { token: string | null; user: User | null; setAuth: (token: string, user: User) void; logout: () void; } export const useAuthStore createAuthState((set) ({ token: localStorage.getItem(token), user: null, setAuth: (token, user) { localStorage.setItem(token, token); set({ token, user }); }, logout: () { localStorage.removeItem(token); set({ token: null, user: null }); }, }));把 token 初始值从 localStorage 里读出来这个细节很重要。否则用户刷新页面后如果 store 是空的前端就认为“未登录”实际上浏览器里明明还存着 token。从 localStorage 同步初始值刷新后依然能保持登录态。关于 token 存 localStorage 的安全性这里再补充一句localStorage 可以被页面里的任何 JavaScript 读取如果项目有 XSS 漏洞token 就会被盗。更安全的做法是使用 HttpOnly Cookie让 JavaScript 完全无法接触 token但会增加 CSRF 防护成本。开发中小型内部系统用 localStorage 的方案是最常见、最方便的选择做面向公众的高安全系统建议升级到 HttpOnly Cookie 方案。3.4 组件设计与 React Router 配置后端接口有了前端组件的常规写法我也展开讲一下。用户列表页是后台系统里最典型的场景import { useEffect, useState } from react; import apiClient from ../api/client; interface User { id: number; username: string; email: string; created_at: string; } function UserList() { const [users, setUsers] useStateUser[]([]); const [loading, setLoading] useState(false); useEffect(() { setLoading(true); apiClient .get(/users) .then((res) setUsers(res.data)) .catch((err) console.error(err)) .finally(() setLoading(false)); }, []); if (loading) return div加载中.../div; return ( ul {users.map((user) ( li key{user.id} {user.username} - {user.email} /li ))} /ul ); } export default UserList;这段代码有几个点都值得说说。第一个是interface User的字段必须和后端UserOut对齐。你可以在 FastAPI 的/docs页面看到UserOut的 schema照着写前端 TypeScript 类型这就是前后端契约的落地方式。当你发现前端拿到的user.created_at是 undefined 时第一个应该检查的就是这个 interface 字段名是否和后端一致。第二个是key{user.id}在列表渲染里的作用。React 会根据 key 来判断哪些列表项是新增、删除、移动如果只用数组 index 做 key列表排序或增删时 React 的 diff 会出各种诡异问题比如输入框内容错位、DOM 复用错误。这是一个非常经典的 React 坑记住一点key 要用稳定且唯一的标识不要用 index。第三个是loading状态。接口请求期间显示“加载中”避免用户误以为页面卡死。更复杂的项目还会引入 error 状态和空数据状态这其实就是“加载态、错误态、空态、成功态”四态设计后台管理系统里非常常见。路由配置我一般这样写import { BrowserRouter, Routes, Route } from react-router-dom; import UserList from ./pages/UserList; import Login from ./pages/Login; import Layout from ./components/Layout; function App() { return ( BrowserRouter Routes Route path/login element{Login /} / Route path/ element{Layout /} Route index element{UserList /} / /Route /Routes /BrowserRouter ); }我把/login放在 Layout 外面因为登录页通常不需要侧边栏和顶部导航把实际的业务页面嵌套在 Layout 内这样公共布局只维护一次。这种嵌套路由的模式比每个页面都渲染一遍侧边栏要合理得多。4. 联调实战从两个独立服务到一条完整链路4.1 Vite Proxy 配置开发期解决跨域的最优雅方案前端跑在 5173 端口后端跑在 8000 端口浏览器里直接访问对方的地址必然触发跨域。解决方式通常有两个方向改后端 CORS或者用前端代理。两者我都用过但开发阶段我更推荐 Vite Proxy。它让浏览器只认识localhost:5173这一个“同源”地址由 Node 层的 Vite dev server 代理转发到后端这样浏览器的请求模型里就不存在跨域。配置非常简单// vite.config.ts import { defineConfig } from vite; import react from vitejs/plugin-react; export default defineConfig({ plugins: [react()], server: { port: 5173, proxy: { /api: { target: http://localhost:8000, changeOrigin: true, }, }, }, });前端代码里的apiClient.get(/users)实际发出的请求是http://localhost:5173/api/usersVite 在收到这个请求后转发到http://localhost:8000/api/users。后端 FastAPI 看到的请求来源就变成了 Vite dev server而不是浏览器所以不存在跨域。为什么还要在后端配置 CORS因为总有场景会绕过 Vite proxy——比如你用 Postman 直接调试后端或者临时开一个别的页面直接请求 8000 端口。为了给这些场景留后路后端 CORS 配置还是要留着一份两边配合是最稳的。4.2 接口联调中的常见“转身”问题说一个特别经典的联调事故现场后端说“接口没问题”前端说“前端没问题”但一联调就报错。这种问题往往不是技术问题而是契约问题。我归纳了几类高频坑。字段命名是第一个重灾区。Python 后端习惯用蛇形命名created_atJavaScript 前端习惯用驼峰命名createdAt。如果后端接口返回created_at前端代码里写createdAt拿到的就是 undefined。这个问题的最佳解法是“由后端统一输出驼峰”或者“由前端做字段映射”关键是两边要提前约定好。FastAPI 有个方式可以控制序列化字段名在 Pydantic 模型里用alias或额外配置但我个人更建议直接让后端输出你想要的名字不要跟前端拧巴。时间格式是第二个坑。FastAPI 默认输出 ISO 8601 格式的字符串比如2025-01-17T10:30:00。如果你在前端直接展示这个字符串会看到一个大写的 T 夹在中间很丑。处理方式是在前端做格式化建议使用 dayjs 或原生Intl.DateTimeFormat。不要在后端拼字符串格式化时间应该输出结构完整的时间字段让前端按需展示。空值是第三个坑。数据库里某个字段允许为 NULL后端返回null前端如果直接访问user.name.length就会崩溃。TypeScript 类型里要标注name: string | null业务代码里做空值兜底。这种问题靠“小心”是防不住的必须在类型层面约束。还有一个很现实的建议联调的第一步应该先让后端把 OpenAPI 文档http://localhost:8000/docs打开前端照着文档核对每个接口的入参和出参。这样能在写页面之前发现 80% 的契约问题。4.3 登录认证完整示例JWT 从后端到前端认证是全栈项目的核心环节我把它完整跑一遍。后端先装依赖pip install python-jose[cryptography] passlib[bcrypt] python-multipart然后写routers/auth.pyfrom datetime import datetime, timedelta from typing import Annotated from fastapi import APIRouter, Depends, HTTPException, status from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm from jose import jwt, JWTError from passlib.context import CryptContext from sqlalchemy.orm import Session from app import models, schemas from app.database import get_db from app.config import settings router APIRouter(prefix/api/auth, tags[auth]) SECRET_KEY settings.secret_key ALGORITHM HS256 ACCESS_TOKEN_EXPIRE_MINUTES 30 pwd_context CryptContext(schemes[bcrypt], deprecatedauto) oauth2_scheme OAuth2PasswordBearer(tokenUrlapi/auth/login) def hash_password(password: str) - str: return pwd_context.hash(password) def verify_password(plain_password: str, hashed_password: str) - bool: return pwd_context.verify(plain_password, hashed_password) def create_access_token(data: dict, expires_delta: timedelta | None None) - str: to_encode data.copy() expire datetime.utcnow() (expires_delta or timedelta(minutesACCESS_TOKEN_EXPIRE_MINUTES)) to_encode.update({exp: expire}) return jwt.encode(to_encode, SECRET_KEY, algorithmALGORITHM) router.post(/login) def login(form_data: OAuth2PasswordRequestForm Depends(), db: Session Depends(get_db)): user db.query(models.User).filter(models.User.username form_data.username).first() if not user or not verify_password(form_data.password, user.password_hash): raise HTTPException( status_codestatus.HTTP_401_UNAUTHORIZED, detail用户名或密码错误, ) token create_access_token({sub: str(user.id)}) return {access_token: token, token_type: bearer} router.get(/me, response_modelschemas.UserOut) def read_current_user(token: Annotated[str, Depends(oauth2_scheme)], db: Session Depends(get_db)): credentials_exception HTTPException( status_codestatus.HTTP_401_UNAUTHORIZED, detail无法验证凭证, ) try: payload jwt.decode(token, SECRET_KEY, algorithms[ALGORITHM]) user_id payload.get(sub) if user_id is None: raise credentials_exception except JWTError: raise credentials_exception user db.query(models.User).filter(models.User.id int(user_id)).first() if user is None: raise credentials_exception return user这个示例里有一个特别容易让前端困惑的点OAuth2PasswordRequestForm要求前端提交表单格式的数据也就是username和password作为表单字段而不是 JSON。这也是 OAuth2 Password Flow 的标准做法好处是能在 Swagger UI 的/docs里直接点 Authorize 按钮测试。如果你更想让前端用 JSON{ username: ..., password: ... }提交可以定义一个 Pydantic 模型作为请求体但那样就不太好直接接入 Swagger UI 的认证测试了。两种方案各有取舍我个人在中小项目里倾向于表单方式因为在开发调试时打开/docs就能直接试接口省去拼 JSON 的功夫。前端登录逻辑import apiClient from ../api/client; import { useAuthStore } from ../stores/authStore; async function login(username: string, password: string) { const form new URLSearchParams(); form.append(username, username); form.append(password, password); const res await apiClient.post(/auth/login, form); useAuthStore.getState().setAuth(res.data.access_token, null); }URLSearchParams构造出来的是application/x-www-form-urlencoded格式正好对应后端OAuth2PasswordRequestForm的解析方式。这里不能直接传对象{ username, password }因为 axios 会把它序列化成 JSON后端就解析不了。登录成功后setAuth会调用localStorage.setItem(token, token)并把 token 存进 Zustand store。之后的请求通过拦截器自动带上Authorization: Bearer token后端oauth2_scheme会提取 token 并解密从而识别当前用户。5. 项目部署从本地到服务器5.1 后端部署Gunicorn UvicornWorker开发环境的uvicorn app.main:app --reload自带热更新非常舒服但生产环境不能这么跑原因有两个一是--reload会监听文件变化浪费资源且不安全二是单进程处理能力有限扛不住并发。我推荐 Gunicorn 作为进程管理器配合 UvicornWorker 运行 ASGI 应用pip install gunicorn gunicorn app.main:app -w 4 -k uvicorn.workers.UvicornWorker -b 0.0.0.0:8000-w 4表示开 4 个 worker 进程-k uvicorn.workers.UvicornWorker表示使用 Uvicorn 的 worker 类。这两项是关键。直接使用 Gunicorn 默认的同步 worker 跑 FastAPI 会直接崩溃因为 FastAPI 是 ASGI 应用不是 WSGI 应用。单机轻量部署也可以继续用 uvicorn通过 systemd 或 supervisord 做守护。不过如果机器性能允许Gunicorn 的多进程模型通常更稳还能来回自动重启挂掉的 worker。真实项目里为了保险我一般会在前面加一层 Nginx 做反向代理和静态文件服务后端进程放在内网端口只接受来自 Nginx 的请求。这里提醒一个多进程模型下的隐患如果你还在用 SQLite4 个 worker 进程同时读数据库会出现“database is locked”错误。SQLite 不适合多进程高并发写入只要项目打算长期发展建议一开始就选 PostgreSQL 或 MySQL。5.2 前端构建与 Nginx 配置两个经典坑前端构建npm run build生成dist/目录后把它放到服务器的站点目录。Nginx 配置是最容易出问题的环节我给你一份能直接用的模版server { listen 80; server_name example.com; root /var/www/fullstack-demo/dist; index index.html; location / { try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }第一个坑try_files $uri $uri/ /index.html;必须有。如果不写这行用户访问/users这样的前端路由时Nginx 会在磁盘上寻找一个叫users的文件找不到就返回 404。加了这行后Nginx 会把所有匹配不到文件的路由都回退到index.html再由 React Router 接管渲染正确的页面。这是 BrowserRouter 模式下最经典的部署问题几乎每个 SPA 项目都会遇到。第二个坑proxy_pass http://127.0.0.1:8000;末尾不要带斜杠。带斜杠和不带斜杠的效果完全不同。不带斜杠表示“保留原始 URI”所以/api/users会被转发成http://127.0.0.1:8000/api/users如果写了proxy_pass http://127.0.0.1:8000/;它会把匹配到的/api/前缀替换为空转发成http://127.0.0.1:8000/users后端接口路径就丢了。这个细节非常容易踩排查起来也很耗时间。5.3 环境变量的构建期与运行期差异部署与本地联调之间还有一个隐蔽差异前端环境变量是构建期注入后端环境变量是运行期读取。前端.env.production里的VITE_API_BASE_URL会在npm run build时被 Vite 内联进 JS bundle。这意味着你修改了前端的环境变量后必须重新npm run build只重启 Nginx 不重新构建是没用的。后端的.env由 FastAPI 在进程启动时读取。改了数据库地址或密钥需要重启 Gunicorn/Uvicorn 进程。这两个“改完没生效”的场景是全栈项目里最容易让人困惑的问题。记住前端改完要 build后端改完要 restart。6. 常见问题与排查技巧实录6.1 CORS 报错先分清楚请求到底有没有到后端浏览器控制台出现这样的报错Access to XMLHttpRequest at http://localhost:8000/api/users from origin http://localhost:5173 has been blocked by CORS policy说明前端请求直接打到 8000 端口了且后端响应里没有包含允许跨域的响应头。排查顺序建议如下先确认前端请求的 URL 是什么。如果请求的是http://localhost:5173/api/users那说明走了 Vite proxy理论上不该有 CORS 问题。如果请求的是http://localhost:8000/api/users那说明baseURL没有生效或者代码里用了绝对地址。检查后端 CORS 配置里的allow_origins是否正确包含http://localhost:5173。如果设了allow_credentialsTrueallow_origins里不能用*。很多人的问题出在“前端没有走代理”直接就把锅甩给 CORS 配置结果怎么调后端都没用。排查的第一步永远是看网络请求到底去了哪里而不是盲目改配置。6.2 Vite Proxy 生效但接口依然 502请求发到 5173Vite 负责转发但报 502 Bad Gateway。这个状态码表示“网关收到了上游服务器的非法响应”翻译成人话就是Vite 找到了目标地址但对方没应答。排查思路确认 FastAPI 是否在运行curl http://localhost:8000/api/health。确认 proxy target 端口是否正确。后端改了端口前端 proxy 没改就会 502。如果开发环境跑在 WSL、Docker 或远程虚拟机里localhost的指向可能不对。后端在 Docker 容器里时Vite 的 target 要写宿主机的可访问地址或容器网络别名不能简单写127.0.0.1。这类问题最常见的场景就是“本地能跑换了个环境就 502”。记住环境变了端口和 host 的映射关系也要跟着变。6.3 uvicorn 热更新没生效先查端口占用FastAPI 开发模式下--reload没生效改代码后接口还是旧逻辑大概率是有两个 uvicorn 进程在打架旧进程占着 8000 端口新进程因为端口被占起不来你访问的始终是旧的。排查方式lsof -i:8000找到监听 8000 端口的进程 PIDkill -9之后再重新启动。如果你的编辑器或终端还挂着旧的进程也会造成这种问题。用 systemd 管理后端项目时改完代码后要正确重启服务别只 refresh 浏览器。另外如果在 Docker 里用 bind mount 挂载项目目录某些文件系统的事件通知可能不会触发 uvicorn 的 reload。这时候只能手动重启容器别指望热更新。6.4 React 页面刷新后 404又是那个 try_files项目部署后访问首页正常但刷新/login或其他子路由时 Nginx 返回 404。这个现象几乎成了 SPA 部署的“标志性事故”原因就是我前面讲的try_files配置缺失。只要看到“SPA 刷新 404”第一反应就应该是 Nginx 的location /块里有没有try_files $uri $uri/ /index.html;。这是 React Router BrowserRouter 模式下的唯一解法。如果你不想配置 Nginx 的 fallback也可以改用 HashRouterURL 变成/#/login但那种 URL 形式对 SEO 不友好视觉上也丑除非有特殊要求否则不推荐。6.5 React 标签闭合与编辑器插件问题不少入门者会搜“vscode什么插件支持react标签怎么闭合插件”。JSX 的标签要比 HTML 严格得多所有标签必须正确闭合br /这种自闭合写法和input的 HTML 习惯完全不同。推荐在 VSCode 里安装ES7 React/Redux/React-Native snippets提供大量 React 片段补全比如输入rfce能自动生成函数组件模板。Prettier - Code formatter保存时自动格式化标签闭合问题在保存瞬间就会被修复。如果遇到“React Native 启动白屏”通常和 Metro 打包缓存有关执行npx react-native start --reset-cache或删除缓存目录即可。这类问题基本上不是代码逻辑错误而是工具链状态污染。6.6 全栈面试要点速查如果你正在准备全栈开发工程师相关的面试围绕 FastAPI React 这套技术栈我建议重点梳理这几个点前后端分离的本质不是“把前端代码和后端代码分成两个目录”而是通过 API 契约实现两个独立系统的解耦。独立开发、独立部署、独立伸缩契约不变任何一方都可以替换实现。React 的核心机制虚拟 DOM、key 的作用、组件生命周期在函数组件里如何用 useEffect 替代、闭包陷阱、状态提升。FastAPI 的异步与性能async def和普通def路由的区别什么时候用异步什么时候用同步。FastAPI 之所以快不仅是支持异步还在于 Pydantic 高效的数据校验和 Starlette 底层的性能。JWT 认证流程登录签发 token、前端存储 token、请求携带 token、后端校验 token以及 token 过期和刷新策略。这些点既是面试常客也是实际开发中每天都要面对的问题。能把它们讲清楚的人基本上不会是一个只会“调库”的工程师。7. 项目扩展与经验心得做完整套 FastAPI React 全栈项目之后我再分享几个在实际使用中沉淀下来的心得。第一契约比代码更值得花时间。我见过太多团队花大量时间在两个人都能写的 CRUD 代码上却很少花时间定义清楚 API 文档。FastAPI 的 OpenAPI 文档已经是白送的了如果你再配合前端类型生成工具把后端的 schema 自动生成 TypeScript 类型你会发现联调时出错的概率大幅下降。我现在做新项目第一步一定是先固定接口文档再开始写前后端代码。第二环境问题要形成自己的排查套路。前端 404先看 Nginx fallback后端 500先看日志不要只看浏览器里的提示CORS 报错先看请求到底发到了哪个地址接口 502先确认后端进程还活着。这些“先看哪里”的直觉是排查效率差距的主要来源。第三别害怕技术栈更新。FastAPI 和 React 的版本迭代都不算慢今天学的技巧可能半年后就有新的最佳实践。重点是掌握架构思想和底层原理版本只是表现形式。比如 Hooks 刚出来时很多人不习惯但理解了“数据驱动视图”之后你会觉得这是自然演化而非矫揉造作。最后还有一个小技巧在做全栈项目时我会在 FastAPI 项目里加一个scripts/目录放一些常用的脚本比如init_db.py用来初始化数据库表seed.py用来填充测试数据。这样不管换到哪台电脑一条命令就能把后端环境跑起来。前端方面则建议在项目里配好 ESLint 和 Prettier提交代码前自动格式化一遍减少 diff 噪音。这些看起来不起眼的工程化习惯在项目变大之后会回报你十倍的时间。希望这篇 FastAPI React 全栈实践指南能帮你少踩几个坑。如果你按这个流程走通了一个项目大概率已经能享受到前后端分离带来的快乐了改前端不会碰到后端改后端不会吓到前端大家只需要盯紧那份接口契约就行。