Vue3 全栈实战第七周:错误处理与请求层封装实战记录

Vue3 全栈实战第七周:错误处理与请求层封装实战记录

「Vue前端转全栈实战」系列第九篇。这周内容是错误处理与请求层封装:统一请求层的错误分类、全局提示组件、loading/error 状态标准化、把这套封装落地到已有的两个表单,最后补上单元测试。这篇把知识点、完整代码、真实踩坑都记录下来。


目录


本周项目结构

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.tsimport 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.tsfetchMails 原来的写法:

typescript 复制代码
async function fetchMails() {
  loading.value = true
  try {
    await new Promise(resolve => setTimeout(resolve, 500))
    mails.value = [...]
  } finally {
    loading.value = false
  }
}

以后 deleteMailmarkAsRead 如果也接了真实接口,每一个都要重复这套"开始置 loadingtrue,结束置回 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 适合改成这个写法------deleteMailmarkAsRead 目前都是纯同步操作(改内存数据,不发请求),暂时不需要套 useAsyncState,等第9周它们真的变成异步接口调用时再套。


Day4:错误处理落地到现有业务

改造 ComposeMailView.vuehandleSend

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.vuehandleSubmit 同理。

loading: submitting 是解构赋值时的重命名useAsyncState 返回的字段固定叫 loading,但这个组件语境里叫 submitting(提交中)更贴切,用别名解构既复用了逻辑,又不用改模板里已经写好的 :disabled="submitting" 相关代码。

catch {} 不是漏写,是有意为之executeSend 内部已经把错误处理完了该做的事,handleSend 这一层唯一需要知道的是"失败了,跳过清空表单这几行"。catch 块本身不是坏味道,"该说清楚为什么留空"才是关键 ------没有注释的空 catch 容易被误以为漏写了,加注释消除这种误解。

Day4 踩坑记录:useAsyncStatenormalizeError 之间的职责边界

用手写 reject({ response: { status: 500 } }) 这种方式模拟接口失败,验证 sendError.value 会不会显示 normalizeError 里定义的精确文案("服务器出错了,请稍后重试"),结果显示的是兜底文案 '操作失败'

排查后发现,useAsyncStatecatch 块是这样写的:

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.tsexport 出来(之前只是模块内部用),测试文件才能 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 块配合注释说明"为什么留空",不是漏写
  • useAsyncStatenormalizeError 的职责边界:前者不假设错误格式,只有真实经过拦截器转换的错误才有精确文案
  • vi.useFakeTimers() + vi.advanceTimersByTime():测试和时间相关的逻辑不用真的等待

下周计划

第一阶段(TS + Vue3 核心)第 8 周的具体安排还没最终确认,第9周开始进入全栈项目阶段(Vue3 + Node.js/Express + PostgreSQL + Prisma)。下周开始前会重新确认具体学习内容,不在这里提前写死。


「Vue前端转全栈实战」系列持续更新,欢迎关注。 有问题欢迎评论区交流,遇到的问题和解决过程都会记录下来。

相关推荐
请你吃div2 小时前
Electron 从零开始的新手开发教程
vue.js·windows·electron
AD_youyu3 小时前
Node.js 安装教程
node.js
咏方舟【长江支流】3 小时前
【前端1】单据编辑 -EasyUI/Vue/React/Bootstrap 主流框架实现订单单据主子表显示和编辑比较,哪个你最易入门?
前端·vue.js·easyui·咏方舟-长江支流·userbaodatagrid
m0_579146653 小时前
Element UI 表格合并单元格完全指南:从原理到实战
前端·vue.js·elementui
雪芽蓝域zzs4 小时前
Prettier (代码格式化)配置详解
前端·vue.js
运维全栈笔记7 小时前
Vue + Spring Boot 前后端分离项目部署笔记(若依 RuoYi-Vue 3.9.2)
运维·服务器·vue.js·spring boot·笔记·开源·开源软件
m0_3807438714 小时前
为 OpenAI 兼容接口配置教程
开发语言·python·node.js
GitLqr16 小时前
Flutter 实战:使用 local_auth 实现生物识别(指纹/Face ID)
安全·flutter·全栈
__zRainy__18 小时前
Node系列 · ORM:Sequelize 简介
数据库·后端·node.js