Vue3 全栈实战第七周:错误处理与请求层封装实战记录
「Vue前端转全栈实战」系列第九篇。这周内容是错误处理与请求层封装:统一请求层的错误分类、全局提示组件、
loading/error状态标准化、把这套封装落地到已有的两个表单,最后补上单元测试。这篇把知识点、完整代码、真实踩坑都记录下来。
目录
- 本周项目结构
- Day1:统一请求层封装
- Day2:全局错误提示组件
- [Day3:loading / error 状态标准化](#Day3:loading / error 状态标准化 "#day3loading--error-%E7%8A%B6%E6%80%81%E6%A0%87%E5%87%86%E5%8C%96")
- Day4:错误处理落地到现有业务
- [Day5:整合优化 + 补测试](#Day5:整合优化 + 补测试 "#day5%E6%95%B4%E5%90%88%E4%BC%98%E5%8C%96--%E8%A1%A5%E6%B5%8B%E8%AF%95")
- 四种错误类型对比
- 本周总结
- 下周计划
本周项目结构
vbscript
src/
utils/
request.ts ← 修改:第二周就建过的文件,这周扩展错误分类
request.test.ts ← 新增:normalizeError 单元测试,6 条用例
stores/
messageStore.ts ← 新增:全局提示消息队列
messageStore.test.ts ← 新增:3 条用例
components/
MessageContainer.vue ← 新增:全局提示展示容器,挂在 App.vue
composables/
useAsyncState.ts ← 新增:loading/error/data 状态标准化封装
useAsyncState.test.ts ← 新增:3 条用例
stores/
mailStore.ts ← 修改:loading 改从 useAsyncState 解构
views/
ComposeMailView.vue ← 修改:handleSend 改用 useAsyncState
ProfileEditView.vue ← 修改:handleSubmit 改用 useAsyncState
App.vue ← 修改:挂载 MessageContainer
全周新增 15 条测试用例,全部通过。
Day1:统一请求层封装
为什么不能让每个组件自己处理请求错误
mailStore.ts 目前用的是内置模拟数据,从来没真的失败过。等第9周接了真实后端,网络超时、接口返回 500、返回格式不对,这些情况会开始真实出现。如果每个业务函数各写一套 try/catch,错误提示的样式和文案会不统一,用户体验碎片化,重复代码也多。统一封装一层请求层,把"网络层面的错误怎么识别、怎么提示"收敛到一个地方。
修改文件:src/utils/request.ts
这个文件第二周(axios 封装)就建过了,已经有基础配置、请求拦截器(自动带 token)、响应拦截器(按状态码打日志)。这周在原有基础上扩展,不新建文件:
typescript
import axios from "axios";
import type { AxiosInstance, AxiosResponse, InternalAxiosRequestConfig } from "axios";
const request: AxiosInstance = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL,
timeout: 10000,
headers: {
'Content-Type': 'application/json'
}
})
request.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
console.log(`[请求] ${config.method?.toUpperCase()} ${config.url}`)
return config
},
(error) => {
console.error('[请求错误]', error)
return Promise.reject(error)
}
)
/**
* 功能:自定义业务错误类型
* 场景:统一错误的形状,区分"网络层面的错误"和"后端明确返回的业务错误"
*/
export interface ApiError {
type: 'network' | 'timeout' | 'http' | 'business'
message: string
status?: number
}
/**
* 功能:把 axios 抛出的原始错误,转换成统一的 ApiError 格式
* 场景:拦截器里调用,让上层业务代码不用关心 axios 错误对象的具体结构
*/
export function normalizeError(error: any): ApiError {
if (error.code === 'ECONNABORTED') {
return { type: 'timeout', message: '请求超时,请稍后重试' }
}
if (!error.response) {
return { type: 'network', message: '网络连接异常,请检查网络' }
}
const status = error.response.status
const message = error.response.data?.message
switch (status) {
case 400:
return { type: 'business', message: message || '请求参数错误', status }
case 401:
localStorage.removeItem('token')
return { type: 'http', message: '登录已过期,请重新登录', status }
case 403:
return { type: 'http', message: '没有权限访问', status }
case 404:
return { type: 'http', message: '请求的资源不存在', status }
case 500:
return { type: 'http', message: '服务器出错了,请稍后重试', status }
default:
return { type: 'business', message: message || '未知错误', status }
}
}
request.interceptors.response.use(
(response: AxiosResponse) => {
console.log(`[响应] ${response.status} ${response.config.url}`)
return response.data
},
(error) => {
const normalized = normalizeError(error)
console.error(`[${normalized.type}] ${normalized.message}`)
return Promise.reject(normalized) // 关键改动:reject 出去的是统一格式,不是原始 error
}
)
export default request
知识点:错误为什么要分四类
network(请求都没发出去/没收到响应)、timeout(发出去了但超时)、http(收到响应但状态码表示出错)、business(后端明确告诉你"这次操作不合法",比如邮箱已被注册)。这四类在用户体验上需要不同的应对------网络错误可以提示"检查网络后重试",业务错误应该直接把后端给的具体原因显示给用户,混在一起处理会导致提示文案要么太笼统、要么想区分又区分不出来。
在响应拦截器里统一转换,不是让每个业务函数自己判断:拦截器是所有请求的必经之路,在这里统一转换,具体业务函数拿到的永远是统一格式的 ApiError,不需要重复写"怎么判断这个错误是超时还是网络问题"这套逻辑。
error.code === 'ECONNABORTED' 是 axios 的约定,axios 内部对不同类型的失败会设置不同的 code 值,超时固定是这个字符串,这是库本身的行为,不是自定义的。
文件放在哪:继续 src/utils/,不换到 api 文件夹 ------这个文件是"怎么发请求"(axios 实例配置、拦截器、错误格式转换),是通用工具能力,不是某个具体业务接口的定义,utils 该放的东西。api 文件夹通常放"具体调用了哪些接口",比如以后的 mailApi.ts 会 import request from '@/utils/request' 来发起请求。
Day2:全局错误提示组件
设计思路:一个全局的"错误消息队列"
不用每个组件各自管理"要不要显示错误提示",搞一个全局共享的状态,谁想弹提示,往队列里塞一条消息就行,一个固定挂在页面角落的组件负责渲染。
新增文件:src/stores/messageStore.ts
typescript
import { ref } from 'vue'
import { defineStore } from 'pinia'
/**
* 功能:单条提示消息
* 场景:error 显示红色,success/info 以后可能用得上,先预留类型
*/
export interface Message {
id: number
type: 'error' | 'success' | 'info'
content: string
}
let messageId = 0
/**
* 功能:全局提示消息队列
* 场景:任何地方(请求层、业务代码)想弹一条提示,调用这里的方法即可,
* 不需要每个组件自己维护提示状态
*/
export const useMessageStore = defineStore('message', () => {
const messages = ref<Message[]>([])
/**
* 功能:新增一条提示消息,并在指定时间后自动移除
* 场景:调用方不需要关心"什么时候消失",这里统一处理
*/
function push(type: Message['type'], content: string, duration = 3000) {
const id = ++messageId
messages.value.push({ id, type, content })
setTimeout(() => {
messages.value = messages.value.filter(m => m.id !== id)
}, duration)
}
function error(content: string) {
push('error', content)
}
function success(content: string) {
push('success', content)
}
return { messages, push, error, success }
})
为什么用 id 而不是数组下标标识一条消息 :如果同时弹出好几条提示,前一条先超时被移除,数组会整体往前挪一位,用下标标识的话 setTimeout 里记的下标就对不上了,可能删错消息。用自增的 id 精确标识每一条,不管数组怎么增删,filter 出来的永远是对的那一条。
新增文件:src/components/MessageContainer.vue
vue
<script setup lang="ts">
import { useMessageStore } from '@/stores/messageStore'
import { storeToRefs } from 'pinia'
/**
* 功能:全局提示消息的展示容器
* 场景:挂在 App.vue 最外层,固定在页面右上角,展示所有排队的提示
*/
defineOptions({
name: 'MessageContainer'
})
const messageStore = useMessageStore()
const { messages } = storeToRefs(messageStore)
</script>
<template>
<div style="position:fixed;top:20px;right:20px;z-index:9999;display:flex;flex-direction:column;gap:8px">
<div
v-for="msg in messages"
:key="msg.id"
:style="{
padding: '10px 16px',
borderRadius: '6px',
color: '#fff',
background: msg.type === 'error' ? '#f56c6c' : msg.type === 'success' ? '#67c23a' : '#909399',
fontSize: '14px',
minWidth: '200px',
boxShadow: '0 2px 8px rgba(0,0,0,0.15)'
}"
>
{{ msg.content }}
</div>
</div>
</template>
修改文件:App.vue,挂载 MessageContainer
vue
<template>
<div :style="{ display: 'flex', height: '100vh' }">
<div style="flex:1;padding:20px;overflow-y:auto">
<router-view v-slot="{ Component }">
<keep-alive :include="['InboxView']">
<component :is="Component" />
</keep-alive>
</router-view>
</div>
<!-- 全局提示容器:不属于任何路由页面,固定挂在最外层 -->
<MessageContainer />
</div>
</template>
<script setup lang="ts">
import MessageContainer from '@/components/MessageContainer.vue'
</script>
MessageContainer 放在 App.vue 而不是某个具体页面:错误提示是全局能力,不管用户在哪个页面,只要有请求失败都应该能弹出提示。放进具体页面组件,切走这个页面提示组件就被销毁了。
修改文件:request.ts 接入全局提示
typescript
import { useMessageStore } from '@/stores/messageStore'
request.interceptors.response.use(
(response: AxiosResponse) => {
console.log(`[响应] ${response.status} ${response.config.url}`)
return response.data
},
(error) => {
const normalized = normalizeError(error)
console.error(`[${normalized.type}] ${normalized.message}`)
// 新增:请求失败时自动弹出全局提示,业务代码不需要每次手动调用
const messageStore = useMessageStore()
messageStore.error(normalized.message)
return Promise.reject(normalized)
}
)
Day2 踩坑记录:useMessageStore() 调用时机------Pinia 初始化顺序的坑
request.ts 是在 Pinia 初始化之前 就会被 import 的模块级代码(axios.create(...) 这些是模块加载时立刻执行的),但 useMessageStore() 必须在 Pinia 实例创建之后才能调用。
上面的写法之所以没问题,是因为 useMessageStore() 写在拦截器的回调函数内部,回调函数只在真正发生请求错误时才会执行(那时候 Pinia 早就初始化完了),不是在模块加载的瞬间就执行:
typescript
// ✅ 正确:写在回调函数内部,真正出错时才调用,那时 Pinia 已经初始化完毕
request.interceptors.response.use(
(response) => response,
(error) => {
const messageStore = useMessageStore() // 调用时机安全
messageStore.error(...)
}
)
// ❌ 错误:写在拦截器注册代码的外层,模块加载瞬间就会执行,那时 Pinia 还没初始化
const messageStore = useMessageStore() // 报错
request.interceptors.response.use(...)
这和第四周提到的 $patch 在 Composition Store 内部访问不到,是相反的一类问题 :那次是"访问的时机太早"($patch 要等 setup 执行完才存在),这次如果把 useMessageStore() 写在拦截器注册代码的外层,同样会犯"访问太早"的错误(Pinia 要等应用初始化时 app.use(createPinia()) 执行完才存在)。两次踩坑的表象不同,但根源都是"某个东西还没准备好就去用它"。
Day3:loading / error 状态标准化
重复代码在哪
mailStore.ts 里 fetchMails 原来的写法:
typescript
async function fetchMails() {
loading.value = true
try {
await new Promise(resolve => setTimeout(resolve, 500))
mails.value = [...]
} finally {
loading.value = false
}
}
以后 deleteMail、markAsRead 如果也接了真实接口,每一个都要重复这套"开始置 loading 为 true,结束置回 false,中间包一层 try/catch"的模板代码。
新增文件:src/composables/useAsyncState.ts
typescript
import { ref } from 'vue'
/**
* 功能:包装一个异步操作,自动管理它的 loading/error/data 状态
* 场景:任何"发起异步请求 → 等待 → 拿到结果或报错"的场景,
* 不用每次手动写 loading.value = true/false
*/
export function useAsyncState<T>(asyncFn: (...args: any[]) => Promise<T>) {
const loading = ref(false)
const error = ref<string | null>(null)
const data = ref<T | null>(null)
/**
* 功能:执行这个异步操作
* 场景:组件/Store 里调用,成功返回结果,失败会把错误记到 error 并继续往外抛
*/
async function execute(...args: any[]) {
loading.value = true
error.value = null
try {
const result = await asyncFn(...args)
data.value = result
return result
} catch (err: any) {
error.value = err?.message ?? '操作失败'
throw err // 继续往外抛,让调用方能感知到失败
} finally {
loading.value = false
}
}
return { loading, error, data, execute }
}
为什么要把 err 重新 throw 出去,不吞掉 :useAsyncState 已经把错误记到了 error.value 里(给界面展示用),但调用方有时候还需要知道"这次操作到底成不成功",来决定要不要继续做别的事(比如发送成功后清空表单,必须知道"成功了"才能执行)。如果把错误吞掉不抛出,调用方没法判断这次调用是否失败,只能去看 error.value,把"是否要往下走"和"要不要展示错误"这两件不同的事绑在了一起,不够灵活。
修改文件:mailStore.ts
typescript
// ❌ 删掉原来这一行
// const loading = ref<boolean>(false)
// ✅ 换成从 useAsyncState 拿
import { useAsyncState } from '@/composables/useAsyncState'
const { loading, error, execute: executeFetch } = useAsyncState(async () => {
await new Promise(resolve => setTimeout(resolve, 500))
return [
{ id: 1, subject: '周一例会通知', from: 'boss@company.com', to: ['you@company.com'], body: '明天上午 10 点开会,请准时参加', isRead: false, createdAt: new Date('2025-01-06') },
{ id: 2, subject: '项目进度同步', from: 'pm@company.com', to: ['you@company.com'], body: '本周进度汇报...', isRead: true, createdAt: new Date('2025-01-05') },
{ id: 3, subject: '团队建设活动', from: 'hr@company.com', to: ['you@company.com'], body: '本周五下午团建,请大家准时参加', isRead: false, createdAt: new Date('2025-01-04') },
]
// 第9周接入后端后改成:return await mailApi.getList()
})
/**
* 功能:加载邮件列表
* 场景:进入收件箱页面时调用
*/
async function fetchMails() {
const result = await executeFetch()
mails.value = result
}
return 语句里 loading 不用改声明方式(还是一个 ref<boolean>,用法不变),新增的 error 要一起暴露出去:
typescript
return {
mails, selectedMail, loading, error, mailClientConfig,
// ...其余不变
}
只有 fetchMails 适合改成这个写法------deleteMail、markAsRead 目前都是纯同步操作(改内存数据,不发请求),暂时不需要套 useAsyncState,等第9周它们真的变成异步接口调用时再套。
Day4:错误处理落地到现有业务
改造 ComposeMailView.vue 的 handleSend
typescript
import { useAsyncState } from '@/composables/useAsyncState'
const { loading: submitting, error: sendError, execute: executeSend } = useAsyncState(async () => {
// 第9周接后端后改成:await mailApi.send(form)
await new Promise(resolve => setTimeout(resolve, 800))
console.log('发送成功:', form)
})
async function handleSend() {
if (!validateAll()) return
if (submitting.value) return
try {
await executeSend()
form.to = []
form.subject = ''
form.body = ''
} catch {
// 错误已经在 useAsyncState 内部记到 sendError.value,
// 而且如果错误来自 request.ts 发起的真实请求,全局提示(Day2)已经弹出来了
// 这里不需要再做什么,留空即可
}
}
ProfileEditView.vue 的 handleSubmit 同理。
loading: submitting 是解构赋值时的重命名 :useAsyncState 返回的字段固定叫 loading,但这个组件语境里叫 submitting(提交中)更贴切,用别名解构既复用了逻辑,又不用改模板里已经写好的 :disabled="submitting" 相关代码。
空 catch {} 不是漏写,是有意为之 :executeSend 内部已经把错误处理完了该做的事,handleSend 这一层唯一需要知道的是"失败了,跳过清空表单这几行"。空 catch 块本身不是坏味道,"该说清楚为什么留空"才是关键 ------没有注释的空 catch 容易被误以为漏写了,加注释消除这种误解。
Day4 踩坑记录:useAsyncState 和 normalizeError 之间的职责边界
用手写 reject({ response: { status: 500 } }) 这种方式模拟接口失败,验证 sendError.value 会不会显示 normalizeError 里定义的精确文案("服务器出错了,请稍后重试"),结果显示的是兜底文案 '操作失败'。
排查后发现,useAsyncState 的 catch 块是这样写的:
typescript
catch (err: any) {
error.value = err?.message ?? '操作失败' // 只是简单取 err.message
throw err
}
手动 reject({ response: { status: 500 } }) 抛出的是一个普通对象 ,这个对象上根本没有 message 属性,所以落到了 ?? '操作失败' 这个兜底。原因是两层错误处理各自独立、责任不同:
lua
真实请求路径:
axios 请求失败 → request.ts 拦截器捕获 → normalizeError 转换成 { type, message, status }
→ reject 出去的已经带着精确文案 → useAsyncState 的 catch 拿到的 err.message 正好是那句精确文案
手动模拟路径:
手动 reject 一个普通对象 → 完全没有经过 request.ts,没有 normalizeError 转换这一步
→ useAsyncState 直接拿到原始对象,它没有 message 属性 → 落到兜底文案 '操作失败'
useAsyncState 设计上是通用的------它可以包装任何异步操作,不只是发请求,所以它不应该、也不能假设传进来的错误一定是 request.ts 处理过的格式。这不是代码 bug,err?.message ?? '操作失败' 这行不需要改 ------想要验证 normalizeError 分类逻辑对不对,必须让 useAsyncState 包一个真实的 request 调用,让错误真的经过拦截器转换一遍,单纯手写模拟对象只能测出"没有 message 时的兜底行为",测不出分类逻辑本身。
Day5:整合优化 + 补测试
给 normalizeError 补单元测试
normalizeError 现在需要从 request.ts 里 export 出来(之前只是模块内部用),测试文件才能 import 到它------这是"要不要给内部函数补测试"引出的常见权衡:多导出一个函数会让模块的公开接口变大,换来的是这个函数可以被独立验证。
typescript
// src/utils/request.test.ts
import { describe, it, expect } from 'vitest'
import { normalizeError } from './request'
describe('normalizeError', () => {
it('ECONNABORTED 应该识别为超时', () => {
const result = normalizeError({ code: 'ECONNABORTED' } as any)
expect(result.type).toBe('timeout')
expect(result.message).toBe('请求超时,请稍后重试')
})
it('没有 response 应该识别为网络错误', () => {
const result = normalizeError({} as any)
expect(result.type).toBe('network')
expect(result.message).toBe('网络连接异常,请检查网络')
})
it('401 应该提示登录过期', () => {
const result = normalizeError({ response: { status: 401 } } as any)
expect(result.message).toBe('登录已过期,请重新登录')
expect(result.status).toBe(401)
})
it('404 应该提示资源不存在', () => {
const result = normalizeError({ response: { status: 404 } } as any)
expect(result.message).toBe('请求的资源不存在')
})
it('500 应该提示服务器出错', () => {
const result = normalizeError({ response: { status: 500 } } as any)
expect(result.type).toBe('http')
expect(result.message).toBe('服务器出错了,请稍后重试')
})
it('后端返回的业务错误信息应该被透出', () => {
const result = normalizeError({
response: { status: 400, data: { message: '邮箱已被注册' } }
} as any)
expect(result.message).toBe('邮箱已被注册')
})
})
给 messageStore 补单元测试
typescript
// src/stores/messageStore.test.ts
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useMessageStore } from './messageStore'
describe('messageStore', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.useFakeTimers() // 用假的计时器,不用真的等 3000ms
})
it('push 应该新增一条消息', () => {
const store = useMessageStore()
store.error('测试错误')
expect(store.messages.length).toBe(1)
expect(store.messages[0].type).toBe('error')
})
it('消息应该在指定时间后自动移除', () => {
const store = useMessageStore()
store.error('测试错误')
vi.advanceTimersByTime(3000) // 快进时间,不用真的等待
expect(store.messages.length).toBe(0)
})
it('多条消息应该能同时存在,且各自独立超时移除', () => {
const store = useMessageStore()
store.error('第一条')
vi.advanceTimersByTime(1000)
store.error('第二条')
expect(store.messages.length).toBe(2)
vi.advanceTimersByTime(2000) // 第一条累计 3000ms 应被移除;第二条累计 2000ms 还在
expect(store.messages.length).toBe(1)
expect(store.messages[0].content).toBe('第二条')
})
})
新知识点:vi.useFakeTimers() + vi.advanceTimersByTime() ------messageStore 的自动移除依赖真实的 setTimeout(..., 3000),如果测试老实等 3 秒,测试套件很快会变得很慢。vi.useFakeTimers() 让 Vitest 接管所有计时器,vi.advanceTimersByTime(3000) 是"假装时间过去了 3000 毫秒",setTimeout 回调会立刻同步执行,测试瞬间跑完。这是测试"和时间相关的逻辑"(防抖、节流、定时器)的标准做法。
第三条用例专门验证了 Day2 提过的设计点------用 id 而不是数组下标标识消息,两条消息在不同时间点超时,能各自精确地被移除,不会因为前一条先消失导致下标错位删错。
给 useAsyncState 补单元测试
typescript
// src/composables/useAsyncState.test.ts
import { describe, it, expect } from 'vitest'
import { useAsyncState } from './useAsyncState'
describe('useAsyncState', () => {
it('成功时应该正确更新 loading 和 data,error 保持 null', async () => {
const { loading, error, data, execute } = useAsyncState(async () => '成功的结果')
const promise = execute()
expect(loading.value).toBe(true)
await promise
expect(loading.value).toBe(false)
expect(data.value).toBe('成功的结果')
expect(error.value).toBeNull()
})
it('失败时应该正确更新 error,并把错误继续往外抛', async () => {
const { loading, error, execute } = useAsyncState(async () => {
throw new Error('模拟失败')
})
await expect(execute()).rejects.toThrow('模拟失败')
expect(loading.value).toBe(false)
expect(error.value).toBe('模拟失败')
})
it('多次调用 execute,每次都应该重置 error', async () => {
let shouldFail = true
const { error, execute } = useAsyncState(async () => {
if (shouldFail) throw new Error('第一次失败')
return '第二次成功'
})
await expect(execute()).rejects.toThrow()
expect(error.value).toBe('第一次失败')
shouldFail = false
await execute()
expect(error.value).toBeNull() // 第二次成功后,上一次的错误应该被清空
})
})
第三条用例验证的是 execute 函数开头那句 error.value = null------如果没有这一行,用户第一次操作失败、第二次操作成功,界面上残留的还是第一次的错误提示,这是容易被忽略的一个重置细节。
四种错误类型对比
| 类型 | 触发场景 | axios 层面的识别方式 | 面向用户的提示 |
|---|---|---|---|
network |
请求发出去但完全没收到响应(断网、跨域、服务器没启动) | !error.response |
"网络连接异常,请检查网络" |
timeout |
请求超过设定的 timeout 时间 |
error.code === 'ECONNABORTED' |
"请求超时,请稍后重试" |
http |
收到响应,但状态码表示服务端/权限出了问题(401/403/404/500) | error.response.status |
按状态码给出对应文案 |
business |
后端明确返回"这次操作不合法"(比如 400 附带具体错误信息) | error.response.status + data.message |
直接透出后端给的具体原因 |
本周总结
最大的收获:错误处理要分层,每一层只管自己该管的事
这周的结构其实是三层职责分工:normalizeError 只负责"把 axios 原始错误翻译成统一格式",不关心谁用它、用来干什么;messageStore/MessageContainer 只负责"怎么把一条消息展示给用户",不关心这条消息是从哪来的;useAsyncState 只负责"管理一个异步操作的 loading/error/data 状态",不关心这个异步操作具体是不是发了网络请求。Day4 那次"操作失败"排查,本质上就是想清楚了这三层各自的边界------useAsyncState 不应该知道 normalizeError 的存在,它们能配合工作,是因为真实请求路径下错误对象经过了正确的转换顺序,而不是 useAsyncState 主动去理解了 ApiError 的结构。
掌握的知识点自检:
- 错误分四类(network/timeout/http/business),对应不同的用户提示策略
- 在响应拦截器里统一转换错误格式,业务代码不用各自判断
- 全局提示队列用自增
id而不是数组下标标识,避免超时移除时删错 -
useMessageStore()等依赖 Pinia 的调用必须写在真正执行的回调函数内部,不能写在模块加载就会执行的位置 -
useAsyncState组合式函数封装 loading/error/data,减少重复的 try/finally 模板代码 - 错误需要重新
throw出去,不能吞掉,调用方需要知道操作是否成功来决定后续逻辑 - 空
catch块配合注释说明"为什么留空",不是漏写 -
useAsyncState和normalizeError的职责边界:前者不假设错误格式,只有真实经过拦截器转换的错误才有精确文案 -
vi.useFakeTimers()+vi.advanceTimersByTime():测试和时间相关的逻辑不用真的等待
下周计划
第一阶段(TS + Vue3 核心)第 8 周的具体安排还没最终确认,第9周开始进入全栈项目阶段(Vue3 + Node.js/Express + PostgreSQL + Prisma)。下周开始前会重新确认具体学习内容,不在这里提前写死。
「Vue前端转全栈实战」系列持续更新,欢迎关注。 有问题欢迎评论区交流,遇到的问题和解决过程都会记录下来。