三态模式:loading、error、data
异步数据与错误处理 · 阅读后完成下方实验与检查题,再用「标记完成」记录进度。
学习目标
异步请求是界面里最容易出错的部分。这一节建立一套固定的三态模式(loading / error / data),让每个数据获取组件都长得一样,并能在请求失败时给出可操作的提示。
问题场景
一个课程详情页:onMounted 里发请求,data 初始为 null。模板里写 {{ course.title }},请求还没回来时页面报错「Cannot read properties of null」;组件在请求途中被切走,回来时 data.value 赋值给已卸载组件;接口 500 时页面只有一个空白列表,用户不知道发生了什么。
核心解释
把请求状态显式建模成三种互斥状态,比「data + loading 布尔」更不容易出错:
<script setup>
import { onMounted, ref } from "vue";
const state = ref({ status: "loading", data: null, error: null });
async function load(id) {
state.value = { status: "loading", data: null, error: null };
try {
const res = await fetch(`/api/courses/${id}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
state.value = { status: "ready", data, error: null };
} catch (err) {
state.value = { status: "error", data: null, error: err.message };
}
}
onMounted(() => load(1));
</script>
<template>
<div>
<p v-if="state.status === 'loading'">加载中…</p>
<div v-else-if="state.status === 'error'">
<p>加载失败:{{ state.error }}</p>
<button @click="load(1)">重试</button>
</div>
<article v-else-if="state.status === 'ready'">
<h1>{{ state.data.title }}</h1>
<p>{{ state.data.summary }}</p>
</article>
</div>
</template>
状态对象的好处:三态互斥,不会出现「loading 为 true 同时 error 有值」的矛盾组合;data 只在 ready 时非空,模板里不需要到处可选链;重试逻辑就是再调一次 load。
另一种常见写法是分开的 ref(data、loading、error),配合模板里的 v-if 顺序。两种都可以,关键是「互斥」:进入请求时清空旧错误,成功时清空错误,失败时清空数据。混着不清就是 bug 温床。
与 React 的对照:React 里这套通常写成 useEffect + 三个 useState,或者用 TanStack Query。Vue 没有官方数据请求库,社区常用 TanStack Query 的 Vue 版或自己抽 composable(V13 就抽一个 useFetch)。核心模式是一样的:状态机 + 重试 + 取消。
空状态也是状态:请求成功但结果为空(data.length === 0)要有专门的提示,不要和「加载中」或「错误」混用。四态(loading/error/empty/ready)才是完整的。
示例拆解
把三态模式封装成一个可复用函数,注意「重试」与「参数变化」两个入口:
// 简化版:返回状态与重试方法
import { ref } from "vue";
export function useAsync(fn) {
const state = ref({ status: "loading", data: null, error: null });
async function run(...args) {
state.value = { status: "loading", data: null, error: null };
try {
const data = await fn(...args);
state.value = { status: "ready", data, error: null };
} catch (err) {
state.value = { status: "error", data: null, error: err.message };
}
}
return { state, run };
}
<script setup>
import { onMounted } from "vue";
import { useAsync } from "../composables/useAsync";
const { state, run } = useAsync(async () => {
const res = await fetch("/api/courses");
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
});
onMounted(() => run());
</script>
<template>
<p v-if="state.status === 'loading'">加载中…</p>
<p v-else-if="state.status === 'error'">
加载失败:{{ state.error }}
<button @click="run()">重试</button>
</p>
<p v-else-if="state.data.length === 0">还没有课程</p>
<ul v-else>
<li v-for="c in state.data" :key="c.id">{{ c.title }}</li>
</ul>
</template>
动手任务
- 用三态模式重写一个列表页,补齐「空状态」分支,验证网络断开时显示错误和重试按钮。
- 把三态对象改成分散 ref 版本,对比两种写法在「重试时清空旧错误」上的差异。
- 给请求加最小 loading 时长(比如至少显示 300ms),避免快速返回时 loading 一闪而过。
常见错误
- 模板直接访问
data.title:请求未完成时 data 为 null,报错。用状态分支保护或可选链。 - 状态清理不干净:成功时不清 error,页面同时显示数据和错误。每次状态转移时显式重置其他字段。
- 空结果当错误处理:空数组是正常业务状态,需要单独文案。
- 在模板里写请求逻辑:每次渲染都发请求。请求放 onMounted 或 watch。
检查与复盘
- 能写出互斥的三态/四态渲染结构
- 能解释为什么用状态对象而不是多个布尔
- 能说清「空状态」和「错误状态」的区别
参考资料
- Vue 官方文档「生命周期钩子」:onMounted 发起初始请求
- MDN:fetch 的响应状态与错误处理
读完这一课了?
标记完成后,工作台会安排接下来的学习。