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 处理开始,再完成部分更新和删除