错误分级与全局兜底
异步数据与错误处理 · 阅读后完成下方实验与检查题,再用「标记完成」记录进度。
学习目标
错误处理的目标不是「不报错」,而是「出错时用户知道怎么办、开发者能定位」。这一节建立错误分级模型:哪些错误组件内消化、哪些上抛给全局、哪些静默重试;并写出 Vue 的全局错误兜底。
问题场景
接口超时、401 未登录、500 服务异常、JSON 解析失败、用户断网——五种错误在界面上全显示成「加载失败」。用户不知道点重试有没有用;401 时还停在原页反复请求;开发者只能翻控制台猜。错误不分级,就没有正确的恢复动作。
核心解释
按「谁能处理、怎么恢复」分四类:
| 类型 | 例子 | 处理方式 |
|---|---|---|
| 可重试 | 超时、502、网络抖动 | 显示重试按钮,指数退避自动重试 1-2 次 |
| 需要用户动作 | 401 未登录、403 无权限 | 跳登录 / 显示权限提示,不重试 |
| 业务错误 | 404 资源不存在、400 参数非法 | 明确文案,引导返回列表 |
| 未知错误 | 解析失败、代码异常 | 上报日志 + 通用兜底文案 |
实现上,做一个 classifyError 把 fetch 的各种失败归一化:
// utils/errors.js
export function classifyError(err, res) {
if (err?.name === "AbortError") return { kind: "aborted", message: "" };
if (err instanceof TypeError) return { kind: "network", message: "网络不可用,请检查连接", retryable: true };
if (!res) return { kind: "unknown", message: "请求失败,请稍后重试", retryable: true };
if (res.status === 401) return { kind: "auth", message: "登录已过期,请重新登录", retryable: false };
if (res.status === 403) return { kind: "forbidden", message: "没有权限访问该资源", retryable: false };
if (res.status === 404) return { kind: "notfound", message: "资源不存在或已被删除", retryable: false };
if (res.status >= 500) return { kind: "server", message: "服务暂时不可用,请稍后重试", retryable: true };
return { kind: "client", message: `请求失败(HTTP ${res.status})`, retryable: false };
}
组件里按 retryable 决定是否显示重试按钮,按 kind 决定后续动作:
<script setup>
import { ref } from "vue";
import { useRouter } from "vue-router";
import { classifyError } from "../utils/errors";
const router = useRouter();
const error = ref(null);
async function load() {
error.value = null;
try {
const res = await fetch("/api/courses/1");
if (!res.ok) {
error.value = classifyError(null, res);
if (error.value.kind === "auth") router.push("/login");
return;
}
// 成功分支
} catch (err) {
error.value = classifyError(err);
}
}
</script>
<template>
<div v-if="error">
<p>{{ error.message }}</p>
<button v-if="error.retryable" @click="load">重试</button>
</div>
</template>
全局兜底:Vue 有 app.config.errorHandler,捕获组件渲染、生命周期、watcher 里未处理的异常:
// main.js
const app = createApp(App);
app.config.errorHandler = (err, instance, info) => {
console.error("[vue error]", info, err);
// 上报到日志服务(生产环境),带上 info 便于定位是哪个生命周期/钩子
reportError({ message: err.message, info, stack: err.stack });
};
app.use(createPinia());
app.use(router);
app.mount("#app");
注意 errorHandler 捕获不到 async 函数里未 await 的 rejection(那属于 unhandledrejection),需要在 window 层再挂一个兜底:
再在 window 层挂一个兜底,接住未被 await 的 Promise 拒绝:window.addEventListener("unhandledrejection", (e) => reportError({ message: String(e.reason) }))。
异步组件的错误:onErrorCaptured 可以在父组件捕获子组件树里的错误,适合「局部错误边界」——一个区块出错不影响整页。
示例拆解
给三态模式加「自动重试一次」:服务端错误(5xx/网络)时静默重试一次,仍失败才显示错误:
async function fetchWithRetry(url, { retries = 1 } = {}) {
for (let attempt = 0; ; attempt++) {
try {
const res = await fetch(url);
if (res.ok) return await res.json();
if (res.status >= 500 && attempt < retries) {
await new Promise((r) => setTimeout(r, 500 * (attempt + 1)));
continue;
}
throw classifyError(null, res);
} catch (err) {
const info = err?.kind ? err : classifyError(err);
if (info.retryable && attempt < retries) {
await new Promise((r) => setTimeout(r, 500 * (attempt + 1)));
continue;
}
throw info;
}
}
}
要点:只对可重试错误重试;重试有上限和退避;401/404 不重试——重试只会让服务端更忙、用户更迷惑。
动手任务
- 完成
classifyError与组件集成,用 DevTools 的 Network 面板模拟 500/401/离线,验证三种文案和按钮行为。 - 实现
fetchWithRetry,验证 500 时自动重试一次、404 时立即失败。 - 挂上
app.config.errorHandler和一个故意抛错的组件,确认错误被捕获并带上组件名/钩子信息。
常见错误
- 所有错误都显示「加载失败」:用户无法判断该重试还是该登录。必须分类。
- 401 反复重试:服务端压力上来,用户也看不到登录入口。识别后直接跳转。
- 在 catch 里再抛错但不处理:外层没人接,变成 unhandledrejection。要么处理,要么明确上抛给全局。
- 全局 errorHandler 里再抛错:会造成递归报错。handler 本身要防御性编写。
检查与复盘
- 能画出四类错误与对应恢复动作的映射
- 能写出只对可重试错误生效的退避重试
- 能说出 errorHandler 与 unhandledrejection 的覆盖差异
参考资料
- Vue 官方文档「错误处理」:app.config.errorHandler 与 onErrorCaptured
- MDN:HTTP 状态码分类
读完这一课了?
标记完成后,工作台会安排接下来的学习。