前端手摸手跑路之 AI 应用开发(二)

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

即使 localhost127.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-TypeJSON.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 开启 strictnoImplicitAny,下面的代码仍然可能不报错:

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

相关推荐
mayaairi1 小时前
Vue2 组件通讯(四):v-model、scoped样式、mixins与plugins
前端·javascript·vue.js
遨翔在知识的海洋里2 小时前
nest(5)-文件上传和静态资源
后端
梦曦i2 小时前
@meng-xi/uni-router 未来展望:夯实基础、深化体验、探索前沿
前端·uni-app
Yeyu2 小时前
AAOS AppCard 实践:怎么把自己的 App 塞进别人的卡片里
前端
遨翔在知识的海洋里2 小时前
nest(3)-jwt和RBAC
后端
遨翔在知识的海洋里2 小时前
nest(5)-Middleware
后端
程序员梅雨2 小时前
Linux & Shell 实用干货
linux·运维·服务器·后端·面试·php
大牧师2 小时前
TypeORM 入门教程
后端·sql·mysql·orm·nest·typeorm
IT_陈寒2 小时前
Vite静态资源路径这个大坑害我调了一下午
前端·人工智能·后端