模块三 · 工程与体验
请求层与四态:把 loading、错误与空数据做对
请求不是"调个接口赋值给 ref"。这一节讲清请求层的统一封装、四种页面状态的建模、竞态与取消、去重与缓存,并给出一个可复用的 useRequest。
读完这一节,你能做到
- 设计一个统一的请求层,而不是在组件里散落
fetch。 - 用状态机建模 loading、error、empty、success,避免"闪一下空列表"。
- 用
AbortController解决竞态与卸载后赋值。 - 判断什么时候该引入 TanStack Query 这类请求缓存库。
一、请求层要解决的五件事
在组件里直接 await fetch(...) 的问题不是"不优雅",而是同一类问题要在每个页面重写一遍:
| 问题 | 不封装的后果 | 请求层的做法 |
|---|---|---|
| 基础地址与环境 | 每个文件写死域名 | baseURL 读环境变量 |
| 认证 | 每个请求手写 token | 请求拦截器统一注入 |
| 错误形态 | 每个页面各写一套判断 | 统一成 { code, message, data } |
| 取消 | 切换页面后仍 setState | AbortController 透传 |
| 埋点与耗时 | 无法统计接口健康度 | 拦截器统一打点 |
结论:请求层是基础设施,不是工具函数。它只暴露一种错误形态、一种返回结构,页面不关心 HTTP 细节。
二、fetch 还是 axios
| 维度 | fetch | axios |
|---|---|---|
| 是否内置 | 浏览器原生 | 需要安装 |
| 非 2xx 状态 | 需要手动判断 res.ok | 自动 reject |
| 超时 | 需要 AbortSignal.timeout | 内置 timeout |
| 拦截器 | 无,要自己包 | 内置 |
| 上传进度 | 需要 XHR 或 ReadableStream | 内置 onUploadProgress |
| 体积 | 0 | 约 15KB(gzip) |
新项目用 fetch 完全够,但要自己写一层薄封装;需要上传进度、拦截器、超时默认值时,axios 更省事。不要两套混用,错误处理会分叉。
三、一个可用的请求封装
// src/api/http.ts
const BASE = import.meta.env.VITE_API_BASE;
export class ApiError extends Error {
constructor(
public kind: "network" | "http" | "business" | "cancel",
public code: number | string,
message: string,
) {
super(message);
}
}
export async function request<T>(
path: string,
init: RequestInit & { timeout?: number } = {},
): Promise<T> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), init.timeout ?? 15000);
try {
const res = await fetch(`${BASE}${path}`, {
...init,
signal: init.signal ?? controller.signal,
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${getToken()}`,
...init.headers,
},
});
if (!res.ok) throw new ApiError("http", res.status, `HTTP ${res.status}`);
const body = await res.json();
if (body.code !== 0)
throw new ApiError("business", body.code, body.message);
return body.data as T;
} catch (err: any) {
if (err.name === "AbortError")
throw new ApiError("cancel", "ABORT", "请求已取消");
if (err instanceof ApiError) throw err;
throw new ApiError("network", "NETWORK", err.message ?? "网络异常");
} finally {
clearTimeout(timer);
}
}
这段代码确立了三件事:
- 错误只有一种类型,页面用
err.kind分支,不再猜是网络问题还是业务问题。 - 业务码与 HTTP 码分开:HTTP 200 但
code !== 0也要当失败。 - 取消是正常流程,不是错误,页面不应弹提示。
注意:init.signal 优先于内部 controller,这样调用方可以在切换页面时主动取消;但内部超时仍然生效的前提是合并两个 signal(AbortSignal.any([a, b])),简单项目里二选一即可,不要写出"传了 signal 就丢了超时"的隐式行为。
四、四态建模:不要用三个布尔
反面写法:
const loading = ref(false);
const error = ref(null);
const list = ref([]);
它能跑,但会出现三个典型 bug:
- 首次加载时
list为空且loading为真,页面闪一下"暂无数据"。 - 请求失败后
error有值,但loading已为假,既有错误提示又有空状态。 - 刷新时旧数据是否保留没有约定,每个页面行为不同。
正确做法是用一个状态机,四态互斥:
type Phase = "idle" | "loading" | "success" | "error";
| 状态 | 页面表现 | 判断依据 |
|---|---|---|
| idle | 未请求(如未选筛选条件) | 初始态 |
| loading | 骨架屏或 loading | 首次请求中 |
| success + 空数组 | 空状态插画 + 引导动作 | 请求成功但无数据 |
| success + 有数据 | 列表 | 请求成功 |
| error | 错误提示 + 重试按钮 | 请求失败 |
关键约定:空状态只在 success 且数据为空时展示。这一条能消掉大部分"闪一下暂无数据"。
五、useRequest:把重复逻辑收口
export function useRequest<T>(
fn: (signal: AbortSignal) => Promise<T>,
immediate = true,
) {
const data = ref<T>();
const error = ref<ApiError>();
const phase = ref<Phase>("idle");
let controller: AbortController | null = null;
async function run() {
controller?.abort(); // 取消上一次,解决竞态
controller = new AbortController();
phase.value = "loading";
error.value = undefined;
try {
data.value = await fn(controller.signal);
phase.value = "success";
} catch (e: any) {
if (e?.kind === "cancel") return; // 取消不改变状态
error.value = e;
phase.value = "error";
}
}
if (immediate) onMounted(run);
onBeforeUnmount(() => controller?.abort());
return { data, error, phase, run };
}
四个细节值得单独记住:
- 先 abort 再发新请求:否则先发的请求后返回,会把新数据覆盖成旧数据(竞态)。
- 取消不改变状态:否则切页时会把 loading 卡死。
onBeforeUnmount里取消:避免卸载后仍赋值,也让浏览器尽早释放连接。- 不要在这里写重试:重试策略属于业务,放调用方。
六、去重与缓存:什么时候需要
同一接口在多个组件里各请求一次,是中型项目最常见的浪费。三种处理,复杂度递增:
| 方案 | 做法 | 适用 |
|---|---|---|
| 提升请求 | 在父组件请求一次,props 往下传 | 同一页面内多处使用 |
| 轻量缓存 | 模块级 Map<key, Promise> 存进行中的 Promise | 同一时刻重复请求 |
| 请求库 | TanStack Query / SWR(Vue 版) | 需要缓存失效、重试、分页、乐观更新 |
轻量去重的核心是缓存 Promise 而不是结果:
const inflight = new Map<string, Promise<unknown>>();
export function dedupe<T>(key: string, fn: () => Promise<T>): Promise<T> {
if (!inflight.has(key)) {
inflight.set(
key,
fn().finally(() => inflight.delete(key)),
);
}
return inflight.get(key) as Promise<T>;
}
什么时候该升级到请求库?出现以下任意两条:
- 同一份数据在三个以上组件使用。
- 需要"增删改后让列表失效重取"。
- 需要分页、无限滚动、乐观更新。
- 需要统一的加载态与错误态 UI。
七、一个列表页的完整设计
需求:订单列表,支持状态筛选、关键词、分页,刷新后还原。
URL query(唯一真相)
→ watch(() => route.query, fetch, { immediate: true })
→ useRequest 发请求(带 AbortSignal)
→ phase 驱动骨架屏 / 列表 / 空态 / 错误态
→ 翻页与筛选只改 query,不直接改数据
这个设计的价值在于:状态只有一个入口。用户点"下一页"改 query,浏览器历史可回退,分享链接能还原,请求由 query 变化统一触发,不需要在各处手动调用 fetch。
两个边界要处理:
- 分页越界(删除最后一页的最后一条)要回退到第一页。
- 快速连续切换筛选时,靠 abort 保证只渲染最后一次结果。
八、常见坑
- 用
loading、error、list三个独立变量,导致状态组合爆炸。 - 空状态与 loading 同时出现,页面闪一下"暂无数据"。
- 请求失败后没有清空旧数据,用户看到旧列表配新错误。
- 切换页面不取消请求,控制台报"卸载后赋值"。
- 把业务错误码当成 HTTP 错误,导致所有失败都提示"网络异常"。
- 在
watch里改 query,又监听 query 触发请求,造成循环。 - 上传大文件没有超时与进度,用户以为卡死。
九、动手任务:自写 → AI 写 → 对比 → 取舍
四步不跳:① 自己写(限时,关掉补全)→ ② 让 AI 写同一题 → ③ 逐条比对 → ④ 说出保留谁的写法。 完整动作与记录模板见《AI 协作与代码评审》。
① 自己写
- 按上面的结构写一个
http.ts,覆盖四类错误(网络、HTTP、业务、取消)。 - 实现
useRequest,并在两个组件里同时调用同一接口,验证去重是否生效。 - 给一个列表页接入四态 UI,要求空状态只在 success 后出现。
- 快速点击三个筛选条件,用 Network 面板确认旧请求被 cancel,页面只渲染最后一次结果。
② 让 AI 写同一题
把第 2 题(请求四态封装)交给 AI,补一句 loading / error / empty / data 四态都要有对应 UI。
③ 对比要盯的点
- 它是不是只有 loading 和 data 两态?空数组和加载中在界面上必须能分开。
- 错误它是 catch 后
console.error,还是真的渲染出来给用户看? - 取消请求它用
AbortController,还是干脆不管?
④ 一句话取舍
它的四态里,哪一态在真实网络下其实永远走不到?
十、自测
- 请求层至少要统一哪五件事?
- 为什么不能用
loading/error/list三个独立变量? - 竞态是怎么产生的,abort 解决了哪一半?
- 取消请求应该改变页面状态吗?
- 什么时候该引入请求缓存库?
参考答案要点:地址、认证、错误形态、取消、打点;状态组合不互斥会同时出现;后发先至覆盖新数据,abort 让旧请求不再写回;不应该;出现缓存失效、分页、乐观更新等需求时。
配套题目
★ 越多越该先背;点题目可看完整答案与深入版。
- ★★★★☆ Vue 中如何设计路由权限、按钮权限和动态菜单?
- ★★★★☆ Vue 项目怎么做构建体积优化和首屏优化?
- ★★★☆☆ 什么时候该用全局状态管理,什么时候不该?
提示:读完可以顺手做几道对应题目,检验是否真的理解