设计 useFetch:清理、竞态与重试
自定义 Composables · 阅读后完成下方实验与检查题,再用「标记完成」记录进度。
学习目标
把 V12 的三态、取消、竞态三件事封装成一个 useFetch:支持响应式 URL、自动取消、手动重试、卸载清理。这一节也是练习 C67 的完整思路,写完后你能在任何组件里用一行代码获取数据。
问题场景
每个列表页都在重复:声明 data/loading/error 三个 ref、写 try/catch/finally、处理 abort、处理「URL 变化重新请求」。第五个页面复制粘贴时漏了 onCleanup,快速切换时旧数据覆盖新页面。此时应该停止复制,抽一个 composable。
核心解释
先定 API 契约,再写实现。useFetch 的输入输出:
const { data, error, loading, refetch } = useFetch(urlOrGetter, options);
urlOrGetter:字符串或返回字符串的函数(函数形式让 URL 可以依赖响应式状态);options.immediate:是否立即请求,默认 true;- 返回
data/error/loading三个 ref,refetch()手动重试。
实现要点:用 watchEffect 追踪 URL 变化(V03 讲过它能自动侦听读到的依赖),每次变化取消旧请求:
// composables/useFetch.js
import { onUnmounted, ref, watchEffect } from "vue";
export function useFetch(urlOrGetter, { immediate = true } = {}) {
const data = ref(null);
const error = ref(null);
const loading = ref(false);
let controller = null;
async function run() {
const url = typeof urlOrGetter === "function" ? urlOrGetter() : urlOrGetter;
if (!url) return;
controller?.abort();
controller = new AbortController();
loading.value = true;
error.value = null;
try {
const res = await fetch(url, { signal: controller.signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
data.value = await res.json();
} catch (err) {
if (err.name === "AbortError") return;
error.value = err.message;
} finally {
loading.value = false;
}
}
if (immediate) {
watchEffect(run);
}
onUnmounted(() => controller?.abort());
return { data, error, loading, refetch: run };
}
三个细节值得展开:
watchEffect(run)同时解决「首次请求」和「URL 变化重新请求」。因为 run 内部读了urlOrGetter()(可能是() => \/api/courses/${id.value}`),id 变化会触发重跑。如果只想要手动触发,传immediate: false`。finally里的loading.value = false会被取消的旧请求执行,把新请求的 loading 提前关掉。修复:在 finally 里判断「本次请求是否仍是当前 controller」,或用请求序号(V12)。生产级写法:
let current = null;
async function run() {
const ac = new AbortController();
current = ac;
// ...
finally {
if (current === ac) loading.value = false;
}
}
- 卸载时 abort:
onUnmounted里取消在途请求,避免「组件已卸载还在写状态」。
返回值的类型约定:data 初始为 null,调用方要么用三态模板(V12),要么用可选链。不要返回 undefined 和 null 混用,会让判断逻辑变复杂。
示例拆解
在详情页使用,验证「参数变化自动重取」和「快速切换不串数据」:
<script setup>
import { computed } from "vue";
import { useRoute } from "vue-router";
import { useFetch } from "../composables/useFetch";
const route = useRoute();
const { data: course, error, loading, refetch } = useFetch(
() => `/api/courses/${route.params.slug}`
);
</script>
<template>
<p v-if="loading">加载中…</p>
<div v-else-if="error">
<p>加载失败:{{ error }}</p>
<button @click="refetch">重试</button>
</div>
<article v-else-if="course">
<h1>{{ course.title }}</h1>
<p>{{ course.summary }}</p>
</article>
</template>
验证:在列表页快速点击两门课,DevTools 里第一次请求应该是 (canceled);详情内容永远对应 URL 里的那门课。再断网重试,验证 error 与 refetch。
动手任务
- 实现
useFetch并用在详情页,验证参数变化、取消、卸载清理三个行为。 - 给
useFetch加「请求序号」防护(改完上面第 2 点),构造一个忽略 abort 的 mock 接口验证不串数据。 - 加
options.deps(额外依赖数组),让调用方可以指定「除了 URL 还有什么变化时重取」,比如{ deps: [page] }。
常见错误
- URL 传值而不是函数:
useFetch(\/api/x/${id.value}`)在调用时就求值了,id 后续变化不会重取。传() => ...`。 - 在 run 里用
if (!url) return却没重置状态:URL 变空时界面还留着旧数据。置空 data。 - 让调用方自己处理 abort:封装泄漏了实现细节。composable 内部管理。
- 不区分 AbortError:切换页面就闪一次「加载失败」。单独特判。
检查与复盘
- 能写出支持响应式 URL 的 useFetch 并说明 watchEffect 的作用
- 能修复「取消请求提前关闭 loading」的缺陷
- 能说清 composable 卸载时的清理责任归属
参考资料
- Vue 官方文档「组合式函数」:useFetch 的官方示例
- MDN:fetch 的 signal 选项
读完这一课了?
标记完成后,工作台会安排接下来的学习。