知

模块三 · 工程与体验

第 10 / 17 节19 分钟阅读型

请求层与四态:把 loading、错误与空数据做对

请求不是"调个接口赋值给 ref"。这一节讲清请求层的统一封装、四种页面状态的建模、竞态与取消、去重与缓存,并给出一个可复用的 useRequest。

读完这一节,你能做到

  1. 设计一个统一的请求层,而不是在组件里散落 fetch。
  2. 用状态机建模 loading、error、empty、success,避免"闪一下空列表"。
  3. 用 AbortController 解决竞态与卸载后赋值。
  4. 判断什么时候该引入 TanStack Query 这类请求缓存库。

一、请求层要解决的五件事

在组件里直接 await fetch(...) 的问题不是"不优雅",而是同一类问题要在每个页面重写一遍:

问题不封装的后果请求层的做法
基础地址与环境每个文件写死域名baseURL 读环境变量
认证每个请求手写 token请求拦截器统一注入
错误形态每个页面各写一套判断统一成 { code, message, data }
取消切换页面后仍 setStateAbortController 透传
埋点与耗时无法统计接口健康度拦截器统一打点

结论:请求层是基础设施,不是工具函数。它只暴露一种错误形态、一种返回结构,页面不关心 HTTP 细节。

二、fetch 还是 axios

维度fetchaxios
是否内置浏览器原生需要安装
非 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);
  }
}

这段代码确立了三件事:

  1. 错误只有一种类型,页面用 err.kind 分支,不再猜是网络问题还是业务问题。
  2. 业务码与 HTTP 码分开:HTTP 200 但 code !== 0 也要当失败。
  3. 取消是正常流程,不是错误,页面不应弹提示。

注意: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 };
}

四个细节值得单独记住:

  1. 先 abort 再发新请求:否则先发的请求后返回,会把新数据覆盖成旧数据(竞态)。
  2. 取消不改变状态:否则切页时会把 loading 卡死。
  3. onBeforeUnmount 里取消:避免卸载后仍赋值,也让浏览器尽早释放连接。
  4. 不要在这里写重试:重试策略属于业务,放调用方。

六、去重与缓存:什么时候需要

同一接口在多个组件里各请求一次,是中型项目最常见的浪费。三种处理,复杂度递增:

方案做法适用
提升请求在父组件请求一次,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 保证只渲染最后一次结果。

八、常见坑

  1. 用 loading、error、list 三个独立变量,导致状态组合爆炸。
  2. 空状态与 loading 同时出现,页面闪一下"暂无数据"。
  3. 请求失败后没有清空旧数据,用户看到旧列表配新错误。
  4. 切换页面不取消请求,控制台报"卸载后赋值"。
  5. 把业务错误码当成 HTTP 错误,导致所有失败都提示"网络异常"。
  6. 在 watch 里改 query,又监听 query 触发请求,造成循环。
  7. 上传大文件没有超时与进度,用户以为卡死。

九、动手任务:自写 → AI 写 → 对比 → 取舍

四步不跳:① 自己写(限时,关掉补全)→ ② 让 AI 写同一题 → ③ 逐条比对 → ④ 说出保留谁的写法。 完整动作与记录模板见《AI 协作与代码评审》。

① 自己写

  1. 按上面的结构写一个 http.ts,覆盖四类错误(网络、HTTP、业务、取消)。
  2. 实现 useRequest,并在两个组件里同时调用同一接口,验证去重是否生效。
  3. 给一个列表页接入四态 UI,要求空状态只在 success 后出现。
  4. 快速点击三个筛选条件,用 Network 面板确认旧请求被 cancel,页面只渲染最后一次结果。

② 让 AI 写同一题

把第 2 题(请求四态封装)交给 AI,补一句 loading / error / empty / data 四态都要有对应 UI。

③ 对比要盯的点

  • 它是不是只有 loading 和 data 两态?空数组和加载中在界面上必须能分开。
  • 错误它是 catch 后 console.error,还是真的渲染出来给用户看?
  • 取消请求它用 AbortController,还是干脆不管?

④ 一句话取舍

它的四态里,哪一态在真实网络下其实永远走不到?

十、自测

  1. 请求层至少要统一哪五件事?
  2. 为什么不能用 loading/error/list 三个独立变量?
  3. 竞态是怎么产生的,abort 解决了哪一半?
  4. 取消请求应该改变页面状态吗?
  5. 什么时候该引入请求缓存库?

参考答案要点:地址、认证、错误形态、取消、打点;状态组合不互斥会同时出现;后发先至覆盖新数据,abort 让旧请求不再写回;不应该;出现缓存失效、分页、乐观更新等需求时。

配套题目

★ 越多越该先背;点题目可看完整答案与深入版。

提示:读完可以顺手做几道对应题目,检验是否真的理解