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

Vue 接口响应校验与错误处理:从 as User 到可读的失败提示

上一篇把用户创建和列表查询接到了 PostgreSQL,但前端仍有两个问题:成功响应通过 as User 直接使用,失败响应统一显示 response error

后端明明返回了用户名重复、字段长度不足等具体原因,页面却没有利用这些信息。这一阶段从现有的用户表单出发,补齐成功响应校验和错误解析

一、TypeScript 类型不会检查网络响应

用户响应类型已经定义:

ts 复制代码
export type User = {
  id: number;
  username: string;
  display_name: string;
};

原来的请求函数这样返回数据:

ts 复制代码
const data: unknown = await response.json();
return data as User;

as User 是类型断言,它告诉 TypeScript 按 User 看待这个值,但不会生成运行时检查,也不会转换字段类型

例如服务器返回:

json 复制代码
{
  "id": "1",
  "username": "alice",
  "display_name": "Alice"
}

这里的 ID 是字符串。即使写了 as User,它也不会变成数字,类型断言本身不会抛出异常

接口响应来自程序外部,因此先保存为 unknown,检查通过后再使用

二、用类型守卫检查 User

frontend/src/api/users.ts 中增加:

ts 复制代码
function isUser(value: unknown): value is User {
  return (
    typeof value === "object" &&
    value !== null &&
    "id" in value &&
    typeof value.id === "number" &&
    "username" in value &&
    typeof value.username === "string" &&
    "display_name" in value &&
    typeof value.display_name === "string"
  );
}

&& 从左到右判断,遇到 false 就停止,所以要先确认值是非空对象,再检查字段是否存在及字段类型

这里不能省略 value !== null,因为 JavaScript 中 typeof null 的结果也是 "object"

"id" in value 检查属性是否存在,typeof value.id === "number" 检查实际值的类型

函数返回值中的 value is User 是类型谓词:返回 true 后,TypeScript 会在对应分支中把传入值缩小为 User

真正执行校验的是函数体中的条件,类型谓词只是向类型检查器声明这个判断的含义。如果函数体写错,TypeScript 不会自动替我们修正

三、用校验代替断言

成功分支改为:

ts 复制代码
const data: unknown = await response.json();

if (!isUser(data)) {
  throw new Error("Invalid user response");
}

return data;

不符合结构的数据会在这里被拒绝。因为失败分支已经抛出异常,执行到最后一行时,TypeScript 知道 data 已通过检查,不再需要 as User

用两组只有 ID 类型不同的数据,可以验证校验是否生效:

ts 复制代码
isUser({ id: 1, username: "alice", display_name: "Alice" });
// true

isUser({ id: "1", username: "alice", display_name: "Alice" });
// false

这份守卫检查必要字段和基础类型,允许额外字段,不承担用户名长度等完整业务校验。后端仍需独立校验客户端输入

四、复杂结构不必全部手写

User 只有三个字段,手写守卫适合看清运行时检查和类型缩小的过程。但响应包含多层对象、数组和可选字段时,逐项维护会变得繁琐,类型定义与检查逻辑也容易不同步

复杂项目可以使用 Schema 校验库,集中声明规则,再从 Schema 推导 TypeScript 类型。本文的 User 只有三个字段,使用手写守卫即可完成基础结构校验

校验集中在 API 模块入口,通过后内部代码使用已确认的数据,不需要每个 Vue 组件重新检查一遍

五、HTTP 失败需要自己判断

当前请求使用 fetch:

ts 复制代码
const response = await fetch("http://127.0.0.1:8000/users", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
  },
  body: JSON.stringify(user),
});

服务器正常返回 409 或 422 时,fetch 仍然会得到 Response,不会因为状态码代表失败而自动抛出异常

因此需要检查:

ts 复制代码
if (!response.ok) {
  const message = await readErrorMessage(response);
  throw new Error(message);
}

response.ok 表示状态码在 200~299 之间。它只判断 HTTP 状态,不能证明 JSON 合法,也不能证明内容符合 User

网络连接失败时,fetch 本身可能拒绝 Promise;成功响应的 JSON 解析或结构检查也可能失败,这些异常都会传递给页面的 catch

六、409 和 422 的 detail 不同

顺序提交一个已经存在的用户名,后端返回 409,响应中的 detail 是字符串:

json 复制代码
{
  "detail": "Username already exists"
}

字段校验失败时返回 422,detail 通常是数组,因为一个请求可能有多个字段错误。下面省略了其他错误属性:

json 复制代码
{
  "detail": [
    {
      "loc": ["body", "username"],
      "msg": "String should have at least 3 characters"
    },
    {
      "loc": ["body", "display_name"],
      "msg": "String should have at least 1 character"
    }
  ]
}

loc 表示错误位置,msg 是可读说明。本阶段先提取 msg 并合并展示,还没有按 loc 把错误显示到各自输入框下面

七、统一解析错误响应

同一个 API 模块中增加:

ts 复制代码
async function readErrorMessage(response: Response): Promise<string> {
  const fallback = `请求失败(HTTP ${response.status})`;

  try {
    const body: unknown = await response.json();

    if (typeof body !== "object" || body === null || !("detail" in body)) {
      return fallback;
    }

    if (typeof body.detail === "string") {
      return body.detail;
    }

    if (Array.isArray(body.detail)) {
      const details: unknown[] = body.detail;
      const messages: string[] = [];

      for (const item of details) {
        if (
          typeof item === "object" &&
          item !== null &&
          "msg" in item &&
          typeof item.msg === "string"
        ) {
          messages.push(item.msg);
        }
      }

      if (messages.length > 0) {
        return messages.join(";");
      }
    }
  } catch {
    return fallback;
  }

  return fallback;
}

先排除不符合基本结构的响应,再分别处理字符串和数组,能减少条件嵌套

Array.isArray() 只能确认是数组,不能说明元素是什么。赋给 unknown[] 后,每个 item 都必须继续检查,才能安全访问 msg

for...of 逐项遍历,push() 收集消息,join(";") 用中文分号连接多条说明

响应也可能不是合法 JSON,例如代理服务返回 HTML 错误页面。此时 response.json() 会失败,catch 返回带 HTTP 状态码的备用提示。合法 JSON 但结构不符合要求时,也会使用 fallback

八、创建请求的最终实现

请求类型保持不变:

ts 复制代码
export type UserCreate = {
  username: string;
  display_name: string;
};

同一个 users.ts 中,保留前面定义的 User、isUser 和 readErrorMessage,创建函数为:

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) {
    const message = await readErrorMessage(response);
    throw new Error(message);
  }

  const data: unknown = await response.json();

  if (!isUser(data)) {
    throw new Error("Invalid user response");
  }

  return data;
}

错误分支解析响应体后立即抛出异常,成功分支才会执行下面的 JSON 解析,因此这段代码不会对同一个响应重复读取 body

Promise<User> 描述成功返回值,不能代替运行时检查。现在能够信任返回值,是因为函数返回之前真正执行了 isUser

九、Vue 接住错误并显示

页面已有的提交逻辑可以直接使用这套处理:

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";
    }
  }
}

JavaScript 可以抛出任意值,所以 err 使用 unknown。instanceof Error 确认它是 Error 实例后,再读取 message

每次提交前清空旧结果和旧错误,避免失败时继续展示上一轮的成功数据

模板继续使用:

vue 复制代码
<form @submit.prevent="submit">
  <!-- 保留已有用户名、昵称输入框和提交按钮 -->
</form>

<p>{{ result }}</p>
<p v-if="errorMessage">{{ errorMessage }}</p>

API 模块负责解释响应并抛出错误,组件负责保存和展示状态,不需要在模板里判断 detail 的结构

十、通过页面验收

本阶段通过现有用户表单完成三组验收:

操作 页面结果
输入未使用过的合法用户名和昵称 显示用户数据,包含数字类型的 ID
再次提交相同用户名 显示 Username already exists
用户名输入 ab,昵称留空 同时显示两个字段的长度错误,以中文分号连接

第三组专门验证多条错误能够一起显示,而不是只取第一项。当前输入框没有阻止这组数据提交,后端能够收到请求并返回 422

完整处理过程是:

text 复制代码
提交表单 → fetch 请求
├─ HTTP 成功 → 解析 JSON → isUser 校验 → 页面显示用户
└─ HTTP 失败 → 解析 detail → 抛出 Error → 页面显示原因

阶段结果

到这里,前端已移除成功响应中的 as User,通过类型守卫检查实际数据,并能展示后端返回的重复用户名提示和多个字段校验错误。合法创建、重复提交和多字段失败已通过页面验收

但当前只完成了用户创建接口的响应校验和基本错误展示:

  • 复杂响应还没有引入 Schema 校验库,当前手写守卫只覆盖 User 的基础结构
  • 422 错误仍合并显示在页面底部,没有定位到对应输入框
  • 后端字段错误仍使用英文原文,没有转换为中文业务提示
  • 还没有提交中的按钮禁用、请求取消和网络错误分类
  • 按 ID 查询、修改和删除用户的接口尚未实现

下一篇继续实现用户 CRUD,从按 ID 查询、路径参数校验和 404 处理开始,再完成部分更新和删除

相关推荐
liuyt20221 小时前
【javaweb】day4
前端
cidy_981 小时前
06 — Model 层:数据模型与操作
后端
TechLee1 小时前
一行代码防水平越权:用 PHP-Casbin 终结业务里的数据级 if-else
后端·php
步行cgn1 小时前
Spring 底层如何创建对象:反射机制与实例化策略
java·后端·spring
Lyra_Infra1 小时前
从 MySQL 到达梦:一次信创隔离环境里的数据库迁移踩坑实录
数据库·后端·mysql
程序员天天困1 小时前
Kafka 接入 AI 的三条路线:MCP 提案、会话记忆与实时上下文
大数据·后端·kafka
志尊宝1 小时前
Vue3 零基础每日笔记(024):组件的创建与使用——从 import 到自动导入
前端·vue.js·笔记·前端框架·html5
@PHARAOH1 小时前
WHAT - 从前端组件化思维到后端架构设计入门
前端·微服务·架构
明月_清风1 小时前
用自然语言控制 Blender:开源项目 Blender MCP 深度介绍
人工智能·后端