Vue 调用 FastAPI:从 HTTP 请求到 CORS
项目骨架搭好以后,我没有立刻接数据库,而是先完成一条最小的前后端请求链路
这一阶段只做两件事:Vue 向 FastAPI 发送请求,FastAPI 返回浏览器能够读取的 JSON
代码本身不多,但中间涉及 HTTP 方法、状态码、请求头、JSON、Origin、CORS、异步处理和 TypeScript 类型边界,顺便查漏补缺一下基础的网络知识
如果这些概念没有分清,后面接数据库或登录时,一个请求失败就很难判断问题究竟出在哪一层
最终数据流
这次完成的请求链路如下:
text
用户填写 Vue 表单
→ 浏览器触发表单提交事件
→ createUser() 组织 HTTP 请求
→ fetch 发送 POST 和 JSON 请求体
→ FastAPI 匹配 /users 路由
→ 后端读取请求数据
→ 返回 201 和 JSON
→ 前端检查状态码并解析响应体
→ Vue 显示成功结果或错误信息
当前实现仍然是最小版本,没有数据库,也没有 Pydantic 字段校验
这两个边界会留到下一阶段处理
一、先用健康检查确认 HTTP 链路
后端入口位于:
text
backend/app/main.py
最小 FastAPI 应用如下:
python
from fastapi import FastAPI
app = FastAPI(title="AI workspace")
@app.get("/health")
async def health():
return {"status": "ok", "service": "backend"}
启动开发服务器:
bash
cd /d/code/ai-workspace-rebuild/backend
uv run fastapi dev
使用 curl 查看原始响应:
bash
curl -i http://127.0.0.1:8000/health
响应大致分成三部分:
http
HTTP/1.1 200 OK
content-type: application/json
content-length: 35
{"status":"ok","service":"backend"}
状态行
http
HTTP/1.1 200 OK
它表示本次请求使用的 HTTP 协议版本,以及服务器给出的处理结果
200 OK 说明请求在 HTTP 层面成功
响应头
http
content-type: application/json
响应头描述这次响应,Content-Type 告诉客户端响应体采用 JSON 格式
响应体
json
{"status":"ok","service":"backend"}
响应体才是接口真正返回的业务数据
因此,HTTP 成功和业务数据不是一回事:
text
状态码
→ 请求处理结果
响应头
→ 如何理解响应
响应体
→ 具体返回内容
二、为什么 curl 成功,浏览器仍可能失败
在终端中请求健康检查时,curl 可以直接读取响应
同一个地址放进浏览器页面中的 fetch(),却可能出现 CORS 错误
原因不是 FastAPI 没有返回,而是浏览器还要执行同源策略
curl、Postman 和后端服务之间不存在浏览器页面的安全边界,因此不会替浏览器执行同源策略
浏览器中的 JavaScript 来自一个页面,它必须受页面 Origin 的约束
三、Origin 是什么
Origin 由三部分组成:
text
协议 + 主机 + 端口
当前前端地址是:
text
http://localhost:5173
后端地址是:
text
http://127.0.0.1:8000
逐项比较:
| 部分 | 前端 | 后端 | 是否相同 |
|---|---|---|---|
| 协议 | http |
http |
相同 |
| 主机 | localhost |
127.0.0.1 |
不同 |
| 端口 | 5173 |
8000 |
不同 |
只要协议、主机或端口中任意一项不同,就是不同 Origin
即使 localhost 和 127.0.0.1 都指向本机,浏览器仍然把它们当成不同主机
四、CORS 到底由谁执行
CORS 全称是:
text
Cross-Origin Resource Sharing
中文通常译为跨源资源共享
容易混淆的一点是:CORS 由浏览器执行,但允许规则由后端声明
完整过程是:
text
浏览器发现请求跨源
→ 后端返回 CORS 响应头
→ 浏览器检查当前页面是否在允许范围内
→ 允许时把响应交给 JavaScript
→ 不允许时阻止 JavaScript 读取响应
因此,网络面板中有时能看到服务器已经返回响应,但页面中的 fetch() 仍然报错
这不是矛盾,而是浏览器在收到响应后拒绝把内容暴露给页面代码
可以把职责记成一句话:
text
后端声明允许规则,浏览器负责执行规则
五、在 FastAPI 中配置 CORS
FastAPI 通过 CORSMiddleware 添加跨源响应头
python
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
app = FastAPI(title="AI workspace")
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:5173"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
allow_origins
python
allow_origins=["http://localhost:5173"]
这里只允许从 http://localhost:5173 打开的页面读取后端响应
如果改用下面的地址打开前端:
text
http://127.0.0.1:5173
它会形成另一个 Origin,需要单独加入允许列表
allow_credentials
python
allow_credentials=True
它允许跨源请求携带凭据,例如 Cookie 或认证相关信息
当前创建用户请求还没有使用 Cookie,但后面进入登录认证时会再次遇到凭据边界
allow_methods
python
allow_methods=["*"]
它表示开发阶段允许各种 HTTP 方法,例如 GET、POST、PATCH 和 DELETE
正式部署时可以根据真实接口缩小范围
allow_headers
python
allow_headers=["*"]
它表示允许前端请求携带各种请求头
本阶段需要使用 Content-Type: application/json
六、简单请求和预检请求
浏览器不会对所有跨源请求都先发送预检
某些满足条件的请求属于简单请求,浏览器可以直接发送,再检查响应中的 CORS 头
其他请求会先发送 OPTIONS:
text
OPTIONS 预检请求
→ 询问后端是否允许当前 Origin、方法和请求头
→ 后端返回允许规则
→ 浏览器判断通过
→ 再发送真实业务请求
携带 Content-Type: application/json 的跨源 POST 通常会触发预检
CORSMiddleware 会处理这类 OPTIONS 请求,不需要为每个接口手写一个预检路由
七、创建一个最小 POST 接口
健康检查使用 GET,只负责读取状态
创建用户更适合 POST,因为它表达的是向服务器提交数据并创建资源
当前最小接口如下:
python
from fastapi import FastAPI, status
@app.post("/users", status_code=status.HTTP_201_CREATED)
async def create_user(user: dict):
return {
"id": 1,
"username": user["username"],
"display_name": user["display_name"],
}
为什么返回 201
普通查询成功通常返回:
http
200 OK
成功创建资源更适合返回:
http
201 Created
直接写 status_code=201 也行,但命名常量更加专业:
python
status_code=status.HTTP_201_CREATED
当前为什么使用 dict
user: dict 只要求请求体能解析成字典,没有限制必须有哪些字段,也没有验证字符串长度
这不是最终方案,只是为了先看清 HTTP 和 JSON 链路
下一阶段会用 Pydantic 模型替换它
八、前端请求类型与响应类型
前端定义两个方向不同的类型:
ts
export type UserCreate = {
username: string
display_name: string
}
export type User = {
id: number
username: string
display_name: string
}
请求不包含 id,因为用户还没有被创建
响应包含 id,因为服务器已经为新资源生成标识
可以按数据方向理解:
text
UserCreate
→ Client 发送给 Server
User
→ Server 返回给 Client
如果把 createUser() 的返回类型误写成 Promise<UserCreate>,TypeScript 就会认为响应仍然没有 id
请求模型和响应模型字段相似,也不应该混用
九、使用 fetch 发送 JSON
请求代码集中放在:
text
frontend/src/api/users.ts
ts
export async function createUser(user: UserCreate): Promise<User> {
const response = await fetch('http://127.0.0.1:8000/users', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(user),
})
if (!response.ok) {
throw new Error(`Create user failed: ${response.status}`)
}
const data: unknown = await response.json()
return data as User
}
method: 'POST'
明确告诉服务器,这次请求的意图是提交并创建数据
同一个 /users 地址可以同时拥有 GET 和 POST 路由,HTTP 方法也是路由匹配条件的一部分
Content-Type
ts
headers: {
'Content-Type': 'application/json',
}
它告诉后端,请求体应该按照 JSON 解析
JSON.stringify()
Vue 中的数据是 JavaScript 对象:
ts
{
username: 'review_user',
display_name: 'Review User',
}
HTTP 请求体不能直接携带 JavaScript 对象,因此需要转换成 JSON 字符串:
text
JavaScript 对象
→ JSON.stringify()
→ JSON 字符串
→ HTTP 请求体
Content-Type 和 JSON.stringify() 解决的是两个问题:
text
Content-Type
→ 声明请求体是什么格式
JSON.stringify()
→ 真正把对象转换成该格式
只写请求头,不会自动完成对象转换
十、fetch 的两个 await
请求代码中通常会看到两个 await:
ts
const response = await fetch(url, options)
const data = await response.json()
第一个 await 等待服务器返回 HTTP 响应
第二个 await 等待响应体被读取并解析为 JavaScript 数据
拿到 response 时,可以先查看状态码和响应头;此时还没有得到解析后的业务对象
十一、response.ok 不等于解析 JSON
fetch 遇到 404、409 或 500 时,通常不会自动抛出异常
只要 HTTP 通信正常完成,它仍然会返回一个 Response
因此需要主动检查:
ts
if (!response.ok) {
throw new Error(`Create user failed: ${response.status}`)
}
response.ok 在状态码为 200~299 时是 true
它只判断 HTTP 状态范围,不会验证响应 JSON 是否符合 User 类型
这两个问题需要分开处理:
text
response.ok
→ HTTP 请求是否成功
响应数据校验
→ 返回内容是否符合程序预期
十二、为什么 response.json() 会绕过类型检查
浏览器 API 中的 response.json() 返回值是 any
即使 TypeScript 开启 strict 和 noImplicitAny,下面的代码仍然可能不报错:
ts
const data = await response.json()
return data
noImplicitAny 负责阻止隐式产生的 any,但不会自动把库 API 明确返回的 any 变成错误
网络数据来自程序外部,更稳妥的做法是主动降为 unknown:
ts
const data: unknown = await response.json()
unknown 表示目前不知道它的结构,使用前必须先缩小类型
当前阶段暂时使用:
ts
return data as User
as User 只是类型断言,它不会检查真实数据
后端即使返回错误字段,浏览器也不会因为这行代码自动报错
运行时类型守卫会在后续文章中单独完成
十三、Vue 表单为什么会一闪而过
第一次接入表单时,按钮写成:
vue
<form>
<button @click="submit">提交</button>
</form>
没有声明 type 的按钮放在表单中时,浏览器默认把它当成提交按钮
点击后会发生:
text
先触发 click 并执行 submit()
→ result 短暂更新
→ 浏览器继续执行表单默认提交
→ 页面刷新
→ Vue 内存状态被清空
→ 结果一闪而过
正确做法是把提交行为交给表单:
vue
<form @submit.prevent="submit">
<button type="submit">提交</button>
</form>
.prevent 会调用 event.preventDefault(),阻止浏览器刷新页面
这种写法还保留了表单语义,用户在输入框中按 Enter 也能提交
十四、在 Vue 中区分成功和失败状态
页面使用不同状态保存输入、成功结果和错误信息:
ts
const username = ref('')
const displayName = ref('')
const result = ref('')
const errorMessage = ref('')
输入状态和请求结果状态不能混淆:
text
username、displayName
→ 用户准备发送的数据
result、errorMessage
→ 上一次请求的处理结果
提交前应该清空旧结果,而不是清空即将发送的输入:
ts
async function submit() {
result.value = ''
errorMessage.value = ''
try {
const data = await createUser({
username: username.value,
display_name: displayName.value,
})
result.value = JSON.stringify(data)
} catch (err: unknown) {
if (err instanceof Error) {
errorMessage.value = err.message
} else {
errorMessage.value = 'Unknown error'
}
}
}
如果在请求前执行:
ts
username.value = ''
displayName.value = ''
后面的 createUser() 读取到的就会是空字符串
清空旧结果的原因是避免页面同时显示上一次成功数据和本次错误:
text
开始新请求
→ 清空 result 和 errorMessage
→ 成功时只设置 result
→ 失败时只设置 errorMessage
捕获错误后也不应该立即再次抛出,否则页面无法把错误保存到响应式状态
十五、当前完整页面
vue
<script setup lang="ts">
import { ref } from 'vue'
import { createUser } from './api/users'
const username = ref('')
const displayName = ref('')
const result = ref('')
const errorMessage = ref('')
async function submit() {
result.value = ''
errorMessage.value = ''
try {
const data = await createUser({
username: username.value,
display_name: displayName.value,
})
result.value = JSON.stringify(data)
} catch (err: unknown) {
if (err instanceof Error) {
errorMessage.value = err.message
} else {
errorMessage.value = 'Unknown error'
}
}
}
</script>
<template>
<main>
<h1>AI Workspace</h1>
<form @submit.prevent="submit">
<div>
<label>用户名:</label>
<input v-model="username" type="text" />
</div>
<div>
<label>昵称:</label>
<input v-model="displayName" type="text" />
</div>
<button type="submit">提交</button>
</form>
<p>{{ result }}</p>
<p v-if="errorMessage">{{ errorMessage }}</p>
</main>
</template>
十六、如何验证这条链路
同时启动后端和前端:
bash
# 终端一
cd /d/code/ai-workspace-rebuild/backend
uv run fastapi dev
# 终端二
cd /d/code/ai-workspace-rebuild/frontend
pnpm dev
打开:
text
http://localhost:5173
成功流程:
text
填写用户名和昵称
→ 点击提交
→ 浏览器完成 CORS 检查
→ POST /users 返回 201
→ 页面显示 id、username 和 display_name
失败流程可以通过暂时停止后端观察:
text
停止 FastAPI
→ 再次提交表单
→ fetch 抛出网络错误
→ catch 保存错误文本
→ 页面显示 errorMessage
阶段结果
这条链路已经跑通,但它还不是完整的用户系统:
- 后端使用
dict接收请求,没有字段类型和长度校验 - 用户没有写入数据库,每次都返回固定的
id=1 - 后端没有检查重复用户名
- 前端 API 地址仍然硬编码
response.ok只显示简单错误,没有解析后端错误详情as User没有验证运行时响应结构- 页面只把结果格式化成字符串,还没有正式的用户信息组件
这些边界不是当前代码的隐藏能力,而是后续需要逐步实现的内容
下一阶段会先用 Pydantic 校验请求和响应,再把 FastAPI 单文件代码拆分成 Router、Schema 和 Service