API 请求封装(Axios + 拦截器 + 错误处理)

API 请求封装(Axios + 拦截器 + 错误处理) -- pd的前端笔记

文章目录

    • [API 请求封装(Axios + 拦截器 + 错误处理) -- pd的前端笔记](#API 请求封装(Axios + 拦截器 + 错误处理) -- pd的前端笔记)
  • [一、为什么需要封装 Axios?](#一、为什么需要封装 Axios?)
  • [二、Axios 基础](#二、Axios 基础)
    • [📝 基础用法(不封装)](#📝 基础用法(不封装))
    • [🔍 Axios 响应结构](#🔍 Axios 响应结构)
  • [三、创建 Axios 实例:统一配置](#三、创建 Axios 实例:统一配置)
    • [📝 创建 Axios 实例(src/utils/request.ts)](#📝 创建 Axios 实例(src/utils/request.ts))
  • [四、封装 useFetch:Vue 组合式函数](#四、封装 useFetch:Vue 组合式函数)
    • [📝 创建 src/composables/useFetchNew.ts](#📝 创建 src/composables/useFetchNew.ts)
  • 五、在组件中使用
    • [📝 基础用法](#📝 基础用法)
    • [📝 带参数的用法](#📝 带参数的用法)
    • [📝 POST 请求用法](#📝 POST 请求用法)
  • [六、API 模块化:按业务组织](#六、API 模块化:按业务组织)
    • [📝 创建 src/api/user.ts](#📝 创建 src/api/user.ts)
    • [📝 在组件中使用 API 模块](#📝 在组件中使用 API 模块)
  • [七、与 Pinia 结合:管理全局加载状态](#七、与 Pinia 结合:管理全局加载状态)
    • [📝 创建 src/stores/loading.ts](#📝 创建 src/stores/loading.ts)
    • [📝 在 Axios 拦截器中使用](#📝 在 Axios 拦截器中使用)
    • [配套修改 useFetchNew.ts](#配套修改 useFetchNew.ts)
    • [📝 在 main.ts 中初始化](#📝 在 main.ts 中初始化)
    • [✅ 添加全局 Loading 组件](#✅ 添加全局 Loading 组件)
    • [✅ 在 App.vue 中使用](#✅ 在 App.vue 中使用)
      • [✅ 使用示例:控制是否显示全局 Loading](#✅ 使用示例:控制是否显示全局 Loading)
    • [📊 完整工作流程图](#📊 完整工作流程图)
      • [globalLoading 与 useFetch中的loading](#globalLoading 与 useFetch中的loading)
  • 八、常见误区与最佳实践

一、为什么需要封装 Axios?

🎯 场景:一个中型项目有 50+ 个 API 请求

html 复制代码
<!-- 组件 A -->
<script setup lang="ts">
import axios from 'axios'

const user = ref()
const loading = ref(false)
const error = ref()

async function fetchUser() {
  loading.value = true
  try {
    const res = await axios.get('/api/users/1')
    user.value = res.data
  } catch (e) {
    error.value = e
  } finally {
    loading.value = false
  }
}
</script>

<!-- 组件 B(重复代码!) -->
<script setup lang="ts">
import axios from 'axios'

const orders = ref()
const loading = ref(false)
const error = ref()

async function fetchOrders() {
  loading.value = true
  try {
    const res = await axios.get('/api/orders')
    orders.value = res.data
  } catch (e) {
    error.value = e
  } finally {
    loading.value = false
  }
}
</script>

🔴 问题:

  • 每个组件都要写 loading/error 处理
  • 错误处理逻辑不统一
  • 无法统一添加 token、处理 401 等
  • 代码重复,难以维护

✅ 封装后的写法

html 复制代码
<!-- 任何组件 -->
<script setup lang="ts">
import { useFetch } from '@/composables/useFetch'

const { data, loading, error } = useFetch('/api/users/1')
</script>

二、Axios 基础

🔎 什么是 Axios?

Axios 是什么?

它是一个基于 Promise 的 HTTP 客户端,用于浏览器和 Node.js 发送网络请求。

核心优势:

  • 支持 Promise/async-await
  • 自动转换 JSON 数据
  • 请求/响应拦截器
  • 取消请求
  • 客户端 CSRF 保护
  • 丰富的配置选项

📦 安装

bash 复制代码
npm install axios

📝 基础用法(不封装)

ts 复制代码
import axios from 'axios'

// GET 请求
const response = await axios.get('/api/users/1')
console.log(response.data) // 响应数据

// POST 请求
const response = await axios.post('/api/users', { name: '张三' })

// 带配置
const response = await axios.get('/api/users', {
  headers: { 'Authorization': 'Bearer token123' },
  timeout: 5000
})

🔍 Axios 响应结构

ts 复制代码
{
  data: any,           // 响应体(最常用)
  status: number,      // HTTP 状态码(200, 404, 500 等)
  statusText: string,  // 状态文本('OK', 'Not Found' 等)
  headers: object,     // 响应头
  config: object,      // 请求配置
  request: XMLHttpRequest // 原始请求对象
}

三、创建 Axios 实例:统一配置

📁 项目结构建议

text 复制代码
src/
├── utils/
│   └── request.ts      ← Axios 实例封装
├── composables/
│   └── useFetchNew.ts     ← Vue 组合式函数
├── api/
│   ├── user.ts         ← 用户相关 API
│   └── order.ts        ← 订单相关 API

📝 创建 Axios 实例(src/utils/request.ts)

ts 复制代码
// src/utils/request.ts
import axios, { 
  type AxiosInstance, 
  type AxiosRequestConfig, 
  type AxiosResponse, 
  AxiosError 
} from 'axios'

// ========== 1. 定义响应数据类型(TS 关键!)==========
// 假设后端统一返回格式
export interface ApiResponse<T = any> {
  code: number      // 业务状态码(0 表示成功)
  message: string   // 提示信息
  data: T           // 实际数据
}

// ========== 2. 创建 Axios 实例 ==========
const request: AxiosInstance = axios.create({
  baseURL: import.meta.env.VITE_API_BASE_URL || '/api', // 从环境变量读取
  timeout: 10000,           // 超时时间(毫秒)
  headers: {
    'Content-Type': 'application/json'
  }
})

// ========== 3. 请求拦截器 ==========
request.interceptors.request.use(
  (config) => {
    // 在发送请求之前做些什么
    
    // 3.1 添加 Token
    const token = localStorage.getItem('token')
    if (token) {
      config.headers.Authorization = `Bearer ${token}`
    }
    
    // 3.2 添加请求时间戳(防止缓存)
    if (config.method === 'get') {
      config.params = {
        ...config.params,
        _t: Date.now()
      }
    }
    
    // 3.3 打印请求信息(开发环境)
    if (import.meta.env.DEV) {
      console.log('📤 请求:', config.method?.toUpperCase(), config.url)
    }
    
    return config
  },
  (error: AxiosError) => {
    // 请求错误时做什么
    console.error('📤 请求错误:', error)
    return Promise.reject(error)
  }
)

// ========== 4. 响应拦截器 ==========
request.interceptors.response.use(
  (response: AxiosResponse<ApiResponse>) => {
    // 2xx 范围内的状态码都会触发
    
    // 4.1 打印响应信息(开发环境)
    if (import.meta.env.DEV) {
      console.log('📥 响应:', response.config.url, response.data)
    }
    
    // 4.2 统一处理业务错误
    const { code, message, data } = response.data
    
    if (code !== 0) {
      // 业务错误(如 token 过期、参数错误等)
      console.error('🔴 业务错误:', message)
      
      // 401:未登录/Token 过期
      if (code === 401) {
        localStorage.removeItem('token')
        // 跳转到登录页(需要 router)
        // router.push('/login')
      }
      
      return Promise.reject(new Error(message))
    }
    
    // 成功:直接返回 data,不用每次 .data.data
    return data
  },
  (error: AxiosError) => {
    // 超出 2xx 范围的状态码都会触发
    
    // 5.1 统一处理 HTTP 错误
    let message = '网络错误'
    
    if (error.response) {
      // 服务器返回了响应(状态码不是 2xx)
      const { status, statusText } = error.response
      
      switch (status) {
        case 400:
          message = '请求参数错误'
          break
        case 401:
          message = '未授权,请重新登录'
          break
        case 403:
          message = '拒绝访问'
          break
        case 404:
          message = '请求地址不存在'
          break
        case 500:
          message = '服务器内部错误'
          break
        case 502:
          message = '网关错误'
          break
        case 503:
          message = '服务不可用'
          break
        case 504:
          message = '网关超时'
          break
        default:
          message = statusText || '未知错误'
      }
    } else if (error.code === 'ECONNABORTED') {
      // 请求超时
      message = '请求超时,请检查网络'
    } else if (error.code === 'ERR_NETWORK') {
      // 网络错误
      message = '网络连接失败'
    }
    
    console.error('🔴 HTTP 错误:', message, error)
    
    // 可以在这里统一显示 Toast 提示
    // ElMessage.error(message)
    
    return Promise.reject(new Error(message))
  }
)

// ========== 6. 导出请求方法 ==========
export default request

// 方便使用的快捷方法
export const http = {
  get<T = any>(url: string, config?: AxiosRequestConfig) {
    return request.get<T, ApiResponse<T>>(url, config)
  },
  post<T = any>(url: string, data?: any, config?: AxiosRequestConfig) {
    return request.post<T, ApiResponse<T>>(url, data, config)
  },
  put<T = any>(url: string, data?: any, config?: AxiosRequestConfig) {
    return request.put<T, ApiResponse<T>>(url, data, config)
  },
  delete<T = any>(url: string, config?: AxiosRequestConfig) {
    return request.delete<T, ApiResponse<T>>(url, config)
  }
}

🔍 关键点解析

概念 说明
AxiosInstance Axios 实例类型,支持 TS 类型推断
interceptors.request 请求拦截器,每次请求前执行
interceptors.response 响应拦截器,每次响应后执行
AxiosError Axios 错误类型,包含 response/code 等信息
ApiResponse<T> 泛型接口,统一后端返回格式

四、封装 useFetch:Vue 组合式函数

📝 创建 src/composables/useFetchNew.ts

ts 复制代码
// src/composables/useFetchNew.ts
import { ref, watch, onUnmounted, type Ref } from 'vue'
import request, { type ApiResponse } from '@/utils/request'
import axios,  { type AxiosRequestConfig, type CancelTokenSource } from 'axios'

// ========== 定义返回类型 ==========
export interface UseFetchReturn<T> {
  data: Ref<T | null>
  loading: Ref<boolean>
  error: Ref<Error | null>
  execute: (config?: AxiosRequestConfig) => Promise<T | null>
  cancel: () => void
}

// ========== 主函数 ==========
export function useFetch<T>(
  url: string | Ref<string>,
  options?: {
    immediate?: boolean      // 是否立即执行
    method?: 'get' | 'post' | 'put' | 'delete'
    data?: any               // POST/PUT 数据
  }
): UseFetchReturn<T> {
  const data = ref<T | null>(null)
  const loading = ref(false)
  const error = ref<Error | null>(null)
  
  // 创建取消令牌(用于取消请求)
  let cancelTokenSource: CancelTokenSource | null = null

  // ========== 执行请求的函数 ==========
  const execute = async (config?: AxiosRequestConfig): Promise<T | null> => {
    // 1. 取消上一次未完成的请求
    if (cancelTokenSource) {
      cancelTokenSource.cancel('请求被取消')
    }
    
    // 2. 创建新的取消令牌
    cancelTokenSource = axios.CancelToken.source()
    
    loading.value = true
    error.value = null
    
    try {
      // 3. 获取当前 URL(支持 ref)
      const currentUrl = typeof url === 'string' ? url : url.value
      
      // 4. 根据 method 选择请求方式
      const method = options?.method || 'get'
      let response: T
      
      if (method === 'get') {
        response = await request.get(currentUrl, {
          ...config,
          cancelToken: cancelTokenSource.token
        })
      } else if (method === 'post') {
        response = await request.post(currentUrl, options?.data, {
          ...config,
          cancelToken: cancelTokenSource.token
        })
      } else if (method === 'put') {
        response = await request.put(currentUrl, options?.data, {
          ...config,
          cancelToken: cancelTokenSource.token
        })
      } else if (method === 'delete') {
        response = await request.delete(currentUrl, {
          ...config,
          cancelToken: cancelTokenSource.token
        })
      } else {
        throw new Error(`不支持的请求方法:${method}`)
      }
      
      data.value = response
      return response
    } catch (err) {
      // 忽略取消导致的错误
      if (axios.isCancel(err)) {
        console.log('⚠️ 请求已取消:', err.message)
        return null
      }
      
      error.value = err as Error
      return null
    } finally {
      loading.value = false
    }
  }

  // ========== 取消请求的函数 ==========
  const cancel = () => {
    if (cancelTokenSource) {
      cancelTokenSource.cancel('用户主动取消')
    }
  }

  // ========== 监听 URL 变化(如果是 ref) ==========
  if (typeof url !== 'string') {
    watch(url, () => {
      if (options?.immediate !== false) {
        execute()
      }
    })
  }

  // ========== 组件卸载时取消请求 ==========
  onUnmounted(() => {
    cancel()
  })

  // ========== 立即执行(可选) ==========
  if (options?.immediate !== false) {
    execute()
  }

  return {
    data,
    loading,
    error,
    execute,
    cancel
  }
}

五、在组件中使用

📝 基础用法

html 复制代码
<!-- src/views/UserList.vue -->
<script setup lang="ts">
import { useFetch } from '@/composables/useFetchNew'

// 定义用户类型
interface User {
  id: number
  name: string
  email: string
}

// 简单用法:自动执行 GET 请求
const { data, loading, error } = useFetch<User[]>('/api/users')
</script>

<template>
  <div style="padding: 20px">
    <h2>用户列表</h2>
    
    <div v-if="loading">加载中...</div>
    
    <div v-else-if="error" style="color: red">
      加载失败:{{ error.message }}
    </div>
    
    <div v-else-if="data">
      <ul>
        <li v-for="user in data" :key="user.id">
          {{ user.name }} ({{ user.email }})
        </li>
      </ul>
    </div>
  </div>
</template>

📝 带参数的用法

html 复制代码
<!-- src/views/UserDetail.vue -->
<script setup lang="ts">
import { ref } from 'vue'
import { useFetch } from '@/composables/useFetchNew'

interface User {
  id: number
  name: string
  email: string
}

const userId = ref(1)

// URL 是 ref,会自动监听变化重新请求
const { data, loading, error, execute } = useFetch<User>(
  () => `/api/users/${userId.value}`,
  { immediate: true }
)

// 手动刷新
function refresh() {
  execute()
}

// 切换用户
function switchUser(id: number) {
  userId.value = id
}
</script>

<template>
  <div style="padding: 20px">
    <h2>用户详情</h2>
    
    <button @click="switchUser(1)">用户 1</button>
    <button @click="switchUser(2)">用户 2</button>
    <button @click="refresh" style="margin-left: 10px">刷新</button>
    
    <div v-if="loading">加载中...</div>
    <div v-else-if="error" style="color: red">{{ error.message }}</div>
    <div v-else-if="data">
      <p>ID: {{ data.id }}</p>
      <p>姓名:{{ data.name }}</p>
      <p>邮箱:{{ data.email }}</p>
    </div>
  </div>
</template>

📝 POST 请求用法

html 复制代码
<!-- src/views/CreateUser.vue -->
<script setup lang="ts">
import { ref } from 'vue'
import { useFetch } from '@/composables/useFetchNew'

interface User {
  id: number
  name: string
  email: string
}

interface CreateUserDto {
  name: string
  email: string
}

const name = ref('')
const email = ref('')
const submitLoading = ref(false)

async function handleSubmit() {
  submitLoading.value = true
  
  // 使用 execute 手动执行 POST 请求
  const { execute } = useFetch<User>('/api/users', {
    immediate: false,
    method: 'post',
    data: { name: name.value, email: email.value } as CreateUserDto
  })
  
  const result = await execute()
  
  if (result) {
    alert('创建成功!')
    name.value = ''
    email.value = ''
  }
  
  submitLoading.value = false
}
</script>

<template>
  <div style="padding: 20px; max-width: 400px">
    <h2>创建用户</h2>
    
    <div style="margin: 10px 0">
      <label>姓名:</label>
      <input v-model="name" style="width: 100%; padding: 8px" />
    </div>
    
    <div style="margin: 10px 0">
      <label>邮箱:</label>
      <input v-model="email" style="width: 100%; padding: 8px" />
    </div>
    
    <button 
      @click="handleSubmit" 
      :disabled="submitLoading"
      style="width: 100%; padding: 10px; background: #42b883; color: white; border: none"
    >
      {{ submitLoading ? '提交中...' : '提交' }}
    </button>
  </div>
</template>

六、API 模块化:按业务组织

📝 创建 src/api/user.ts

ts 复制代码
// src/api/user.ts
import request from '@/utils/request'
import type { ApiResponse } from '@/utils/request'

// ========== 定义数据类型 ==========
export interface User {
  id: number
  name: string
  email: string
  role: 'admin' | 'user'
  createdAt: string
}

export interface LoginDto {
  email: string
  password: string
}

export interface LoginResponse {
  token: string
  user: User
}

export interface UpdateUserDto {
  name?: string
  email?: string
  role?: 'admin' | 'user'
}

// ========== 封装 API 函数 ==========
export const userApi = {
  // 获取用户列表
  getList(params?: { page?: number; size?: number }) {
    return request.get<User[]>('/users', { params })
  },
  
  // 获取用户详情
  getDetail(id: number) {
    return request.get<User>(`/users/${id}`)
  },
  
  // 登录
  login(data: LoginDto) {
    return request.post<LoginResponse>('/auth/login', data)
  },
  
  // 登出
  logout() {
    return request.post('/auth/logout')
  },
  
  // 创建用户
  create(data: Omit<User, 'id' | 'createdAt'>) {
    return request.post<User>('/users', data)
  },
  
  // 更新用户
  update(id: number, data: UpdateUserDto) {
    return request.put<User>(`/users/${id}`, data)
  },
  
  // 删除用户
  delete(id: number) {
    return request.delete(`/users/${id}`)
  }
}

📝 在组件中使用 API 模块

html 复制代码
<!-- src/views/UserManagement.vue -->
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { userApi, type User } from '@/api/user'

const users = ref<User[]>([])
const loading = ref(false)
const error = ref<string>('')

async function loadUsers() {
  loading.value = true
  error.value = ''
  
  try {
    users.value = await userApi.getList({ page: 1, size: 20 })
  } catch (e) {
    error.value = (e as Error).message
  } finally {
    loading.value = false
  }
}

async function handleDelete(id: number) {
  if (!confirm('确定删除该用户?')) return
  
  try {
    await userApi.delete(id)
    await loadUsers() // 刷新列表
  } catch (e) {
    alert('删除失败:' + (e as Error).message)
  }
}

onMounted(() => {
  loadUsers()
})
</script>

<template>
  <div style="padding: 20px">
    <h2>用户管理</h2>
    
    <div v-if="loading">加载中...</div>
    <div v-else-if="error" style="color: red">{{ error }}</div>
    
    <table v-else style="width: 100%; border-collapse: collapse">
      <thead>
        <tr style="background: #f5f5f5">
          <th style="padding: 12px; text-align: left">ID</th>
          <th style="padding: 12px; text-align: left">姓名</th>
          <th style="padding: 12px; text-align: left">邮箱</th>
          <th style="padding: 12px; text-align: left">角色</th>
          <th style="padding: 12px; text-align: left">操作</th>
        </tr>
      </thead>
      <tbody>
        <tr v-for="user in users" :key="user.id" style="border-bottom: 1px solid #eee">
          <td style="padding: 12px">{{ user.id }}</td>
          <td style="padding: 12px">{{ user.name }}</td>
          <td style="padding: 12px">{{ user.email }}</td>
          <td style="padding: 12px">
            <span :style="{ 
              padding: '4px 8px', 
              borderRadius: '4px',
              background: user.role === 'admin' ? '#ffe6e6' : '#e6f7ff',
              color: user.role === 'admin' ? '#f56c6c' : '#1890ff'
            }">
              {{ user.role === 'admin' ? '管理员' : '普通用户' }}
            </span>
          </td>
          <td style="padding: 12px">
            <button @click="handleDelete(user.id)" style="color: #f56c6c">删除</button>
          </td>
        </tr>
      </tbody>
    </table>
  </div>
</template>

七、与 Pinia 结合:管理全局加载状态

📝 创建 src/stores/loading.ts

ts 复制代码
// src/stores/loading.ts
import { defineStore } from 'pinia'
import { ref } from 'vue'

export const useLoadingStore = defineStore('loading', () => {
  const globalLoading = ref(false)
  const requestCount = ref(0)

  function showLoading() {
    requestCount.value++
    globalLoading.value = true
  }

  function hideLoading() {
    requestCount.value--
    if (requestCount.value <= 0) {
      globalLoading.value = false
      requestCount.value = 0
    }
  }

  function resetLoading() {
    globalLoading.value = false
    requestCount.value = 0
  }

  return {
    globalLoading,
    requestCount,
    showLoading,
    hideLoading,
    resetLoading
  }
})

📝 在 Axios 拦截器中使用

ts 复制代码
// src/utils/request.ts
import axios, { 
  type AxiosInstance, 
  type AxiosRequestConfig, 
  type AxiosResponse, 
  AxiosError 
} from 'axios'
import { useLoadingStore } from '@/stores/loading'

// ========== 1. 定义响应数据类型(TS 关键!)==========
export interface ApiResponse<T = any> {
  code: number      // 业务状态码(0 表示成功)
  message: string   // 提示信息
  data: T           // 实际数据
}

// ========== 2. 扩展 AxiosRequestConfig 类型 ==========
// 添加自定义配置选项(TS 声明合并)
declare module 'axios' {
  interface AxiosRequestConfig {
    showGlobalLoading?: boolean  // 是否显示全局 loading
  }
}

// ========== 3. 延迟初始化 Loading Store(关键!)==========
// 不能直接调用 useLoadingStore(),因为 Pinia 实例可能还没创建
let loadingStore: ReturnType<typeof useLoadingStore> | null = null

// 在 main.ts 中调用此函数初始化
export function initLoadingStore(store: ReturnType<typeof useLoadingStore>) {
  loadingStore = store
}

// ========== 4. 创建 Axios 实例 ==========
const request: AxiosInstance = axios.create({
  baseURL: import.meta.env.VITE_API_BASE_URL || '/api',
  timeout: 10000,
  headers: {
    'Content-Type': 'application/json'
  }
})

// ========== 5. 请求拦截器 ==========
request.interceptors.request.use(
  (config) => {
    // 5.1 添加 Token
    const token = localStorage.getItem('token')
    if (token) {
      config.headers.Authorization = `Bearer ${token}`
    }
    
    // 5.2 添加请求时间戳(防止缓存,GET 请求)
    if (config.method === 'get') {
      config.params = {
        ...config.params,
        _t: Date.now()
      }
    }
    
    // 5.3 显示全局 Loading(默认关闭,可通过配置开启)
    // 注意:这里用 loadingStore 而不是 useLoadingStore()
    if (config.showGlobalLoading === true && loadingStore) {
      loadingStore.showLoading()
    }
    
    // 5.4 打印请求信息(开发环境)
    if (import.meta.env.DEV) {
      console.log('📤 请求:', config.method?.toUpperCase(), config.url)
    }
    
    return config
  },
  (error: AxiosError) => {
    console.error('📤 请求错误:', error)
    // 请求失败也要隐藏 loading
    if (loadingStore) {
      loadingStore.hideLoading()
    }
    return Promise.reject(error)
  }
)

// ========== 6. 响应拦截器 ==========
request.interceptors.response.use(
  (response: AxiosResponse<ApiResponse>) => {
    // 6.1 隐藏全局 Loading
    if (loadingStore) {
      loadingStore.hideLoading()
    }
    
    // 6.2 打印响应信息(开发环境)
    if (import.meta.env.DEV) {
      console.log('📥 响应:', response.config.url, response.data)
    }
    
    // 6.3 统一处理业务错误
    const { code, message, data } = response.data
    
    if (code !== 0) {
      console.error('🔴 业务错误:', message)
      
      // 401:未登录/Token 过期
      if (code === 401) {
        localStorage.removeItem('token')
        // 如果有 router,可以在这里跳转
        // router.push('/login')
      }
      
      return Promise.reject(new Error(message))
    }
    
    // 成功:直接返回 data
    return data
  },
  (error: AxiosError) => {
    // 6.4 隐藏全局 Loading(错误时也要隐藏)
    if (loadingStore) {
      loadingStore.hideLoading()
    }
    
    // 6.5 统一处理 HTTP 错误
    let message = '网络错误'
    
    if (error.response) {
      const { status, statusText } = error.response
      
      switch (status) {
        case 400:
          message = '请求参数错误'
          break
        case 401:
          message = '未授权,请重新登录'
          break
        case 403:
          message = '拒绝访问'
          break
        case 404:
          message = '请求地址不存在'
          break
        case 500:
          message = '服务器内部错误'
          break
        case 502:
          message = '网关错误'
          break
        case 503:
          message = '服务不可用'
          break
        case 504:
          message = '网关超时'
          break
        default:
          message = statusText || '未知错误'
      }
    } else if (error.code === 'ECONNABORTED') {
      message = '请求超时,请检查网络'
    } else if (error.code === 'ERR_NETWORK') {
      message = '网络连接失败'
    }
    
    console.error('🔴 HTTP 错误:', message, error)
    
    return Promise.reject(new Error(message))
  }
)

// ========== 7. 导出请求方法 ==========
export default request

export const http = {
  get<T = any>(url: string, config?: AxiosRequestConfig) {
    return request.get<T, ApiResponse<T>>(url, config)
  },
  post<T = any>(url: string, data?: any, config?: AxiosRequestConfig) {
    return request.post<T, ApiResponse<T>>(url, data, config)
  },
  put<T = any>(url: string, data?: any, config?: AxiosRequestConfig) {
    return request.put<T, ApiResponse<T>>(url, data, config)
  },
  delete<T = any>(url: string, config?: AxiosRequestConfig) {
    return request.delete<T, ApiResponse<T>>(url, config)
  }
}

配套修改 useFetchNew.ts

  • 修改位置 1:options 参数类型
ts 复制代码
// src/composables/useFetchNew.ts

export function useFetch<T>(
  url: string | Ref<string>,
  options?: {
    immediate?: boolean
    method?: 'get' | 'post' | 'put' | 'delete'
    data?: any
    showGlobalLoading?: boolean  // 👈 新增这一行
  }
): UseFetchReturn<T> {
  // ...
}
  • 修改位置 2:传递 showGlobalLoading 给 request
ts 复制代码
// 在 execute 函数中,修改所有 request 调用
if (method === 'get') {
  response = await request.get(currentUrl, {
    ...config,
    cancelToken: cancelTokenSource.token,
    showGlobalLoading: options?.showGlobalLoading  // 👈 新增这一行
  })
} else if (method === 'post') {
  response = await request.post(currentUrl, options?.data, {
    ...config,
    cancelToken: cancelTokenSource.token,
    showGlobalLoading: options?.showGlobalLoading  // 👈 新增这一行
  })
} else if (method === 'put') {
  response = await request.put(currentUrl, options?.data, {
    ...config,
    cancelToken: cancelTokenSource.token,
    showGlobalLoading: options?.showGlobalLoading  // 👈 新增这一行
  })
} else if (method === 'delete') {
  response = await request.delete(currentUrl, {
    ...config,
    cancelToken: cancelTokenSource.token,
    showGlobalLoading: options?.showGlobalLoading  // 👈 新增这一行
  })
}

📝 在 main.ts 中初始化

ts 复制代码
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import { initLoadingStore } from '@/utils/request'
import { useLoadingStore } from '@/stores/loading'
import App from './App.vue'
import router from './router'

const app = createApp(App)
const pinia = createPinia()

app.use(pinia)
app.use(createPinia())
// 初始化 loading store
const loadingStore = useLoadingStore(pinia)
initLoadingStore(loadingStore)


app.use(router)

app.mount('#app')

为什么需要延迟初始化?循环依赖问题详解

text 复制代码
时间线 →

main.ts:  import { initLoadingStore } from './request'
              ↓
request.ts: let loadingStore = null  ✅ 只是声明,不报错
              ↓
main.ts:  const pinia = createPinia()  ✅ Pinia 创建
              ↓
main.ts:  const store = useLoadingStore(pinia)  ✅ store 创建
              ↓
main.ts:  initLoadingStore(store)  ✅ 赋值给 request.ts
              ↓
request.ts: loadingStore.showLoading()  ✅ 可以正常使用

可视化执行流程

text 复制代码
┌─────────────────────────────────────────────────────────────┐
│  main.ts 开始执行                                            │
│       ↓                                                     │
│  遇到 import { initLoadingStore } from './utils/request'    │
│       ↓                                                     │
│  ⏸️ main.ts 暂停执行                                         │
│       ↓                                                     │
│  加载 request.ts 模块                                        │
│       ↓                                                     │
│  执行 request.ts 顶层代码(import、const、export 等)         │
│       ↓                                                     │
│  request.ts 遇到 import { useLoadingStore } from './stores' │
│       ↓                                                     │
│  ⏸️ request.ts 暂停执行                                      │
│       ↓                                                     │
│  加载 loading.ts 模块                                        │
│       ↓                                                     │
│  执行 loading.ts 顶层代码                                    │
│       ↓                                                     │
│  loading.ts 执行完毕,返回 request.ts                        │
│       ↓                                                     │
│  request.ts 继续执行剩余顶层代码                              │
│       ↓                                                     │
│  request.ts 执行完毕,返回 main.ts                           │
│       ↓                                                     │
│  ▶️ main.ts 恢复执行                                         │
│       ↓                                                     │
│  const pinia = createPinia()  ← 此时 Pinia 才创建!          │
└─────────────────────────────────────────────────────────────┘

不在 main.ts 中导入 initLoadingStore

另一种方案:确实可以不在 main.ts 中导入 initLoadingStore,而是在拦截器内部动态获取 Pinia 实例。

修改request.ts

ts 复制代码
// src/utils/request.ts
import { getActivePinia } from 'pinia'  // 👈 新增导入
// ❌ 删除这两行(不再需要延迟初始化)
// let loadingStore: ReturnType<typeof useLoadingStore> | null = null
// export function initLoadingStore(store: ReturnType<typeof useLoadingStore>) { ... }

request.interceptors.request.use(
  (config) => {
    // 👇 在拦截器内部动态获取 Pinia 实例
    const pinia = getActivePinia()
    const loadingStore = pinia ? useLoadingStore(pinia) : null
    
    // ... Token、时间戳等其他逻辑不变 ...
  })

// ========== 响应拦截器 ==========
request.interceptors.response.use(
  (response: AxiosResponse<ApiResponse>) => {
    // 👇 在拦截器内部动态获取 Pinia 实例
    const pinia = getActivePinia()
    const loadingStore = pinia ? useLoadingStore(pinia) : null
    // ... 其他逻辑不变 ...
  })

修改main.ts

ts 复制代码
// ❌ 删除这两行(不再需要)
// const loadingStore = useLoadingStore(pinia)
// initLoadingStore(loadingStore)

两种方案对比

对比项 方案 A(延迟初始化) 方案 B(动态获取)✅
main.ts 代码 需要调用 initLoadingStore 不需要额外代码
request.ts 复杂度 需要声明 + 初始化函数 拦截器内直接获取
代码重复 拦截器中重复 getActivePinia()
理解难度 需要理解模块执行顺序 更直观
推荐度 ⭐⭐⭐⭐ ⭐⭐⭐⭐⭐

✅ 添加全局 Loading 组件

html 复制代码
<!-- src/components/GlobalLoading.vue -->
<script setup lang="ts">
import { useLoadingStore } from '@/stores/loading'

const loadingStore = useLoadingStore()
</script>

<template>
  <Teleport to="body">
    <Transition name="fade">
      <div v-if="loadingStore.globalLoading" class="global-loading">
        <div class="spinner"></div>
        <p class="loading-text">加载中...</p>
      </div>
    </Transition>
  </Teleport>
</template>

<style scoped>
.global-loading {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  background: rgba(0, 0, 0, 0.5);
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
  z-index: 9999;
  color: white;
}

.spinner {
  width: 50px;
  height: 50px;
  border: 4px solid rgba(255, 255, 255, 0.3);
  border-top: 4px solid #42b883;
  border-radius: 50%;
  animation: spin 1s linear infinite;
}

@keyframes spin {
  0% { transform: rotate(0deg); }
  100% { transform: rotate(360deg); }
}

.loading-text {
  margin-top: 16px;
  font-size: 14px;
}

.fade-enter-active,
.fade-leave-active {
  transition: opacity 0.3s;
}

.fade-enter-from,
.fade-leave-to {
  opacity: 0;
}
</style>

✅ 在 App.vue 中使用

html 复制代码
<!-- src/App.vue -->
<script setup lang="ts">
import { RouterView } from 'vue-router'
import GlobalLoading from '@/components/GlobalLoading.vue'  // 👈 导入
</script>

<template>
  <div id="app">
    <!-- 全局 Loading 组件 -->
    <GlobalLoading />
    
    <!-- 路由出口 -->
    <RouterView />
  </div>
</template>

✅ 使用示例:控制是否显示全局 Loading

ts 复制代码
import { useFetch } from '@/composables/useFetchNew'
// ❌ 默认不显示全局 loading
const { data, loading, error } = useFetch<User[]>('/api/users')
// ✅ 显式传 true → 显示全局 loading
const { data, loading, error } = useFetch<User[]>('/api/users', { showGlobalLoading: true })

📊 完整工作流程图

text 复制代码
┌─────────────────────────────────────────────────────────────┐
│  1. main.ts 启动                                             │
│       ↓                                                     │
│  createPinia() → app.use(pinia)                             │
│       ↓                                                     │
│  useLoadingStore(pinia) → initLoadingStore(store)           │
│       ↓                                                     │
│  request.ts 中的 loadingStore 被赋值                          │
└─────────────────────────────────────────────────────────────┘
                          ↓
┌─────────────────────────────────────────────────────────────┐
│  2. 组件发起请求                                              │
│       ↓                                                     │
│  useFetch('/api/users') 或 http.get('/api/users')           │
│       ↓                                                     │
│  Axios 请求拦截器                                            │
│       ↓                                                     │
│  if (loadingStore) loadingStore.showLoading()  ✅           │
│       ↓                                                     │
│  GlobalLoading.vue 显示全屏遮罩                               │
└─────────────────────────────────────────────────────────────┘
                          ↓
┌─────────────────────────────────────────────────────────────┐
│  3. 服务器响应                                                │
│       ↓                                                     │
│  Axios 响应拦截器                                            │
│       ↓                                                     │
│  if (loadingStore) loadingStore.hideLoading()  ✅           │
│       ↓                                                     │
│  GlobalLoading.vue 隐藏遮罩                                  │
└─────────────────────────────────────────────────────────────┘

✅ 修改总结

文件 修改内容
request.ts 添加 loadingStore 延迟初始化 + 拦截器中调用 showLoading/hideLoading
main.ts app.use(pinia) 后调用 initLoadingStore()
GlobalLoading.vue 新建全局 loading 组件
App.vue 引入 GlobalLoading 组件

globalLoading 与 useFetch中的loading

两种 Loading 的本质区别

对比项 useFetch 中的 loading Pinia Store 中的 globalLoading
作用域 组件级别 全局应用级别
用途 当前组件的请求状态 整个应用的加载遮罩
使用场景 局部 loading 提示 全屏 loading 遮罩
生命周期 组件卸载后消失 应用运行期间一直存在
并发处理 独立计数 请求计数(支持并发)
text 复制代码
┌─────────────────────────────────────────────────┐
│  全局 Loading (Pinia Store)                      │
│  ┌───────────────────────────────────────────┐  │
│  │  页面内容                                   │  │
│  │  ┌─────────────┐  ┌─────────────┐         │  │
│  │  │ 组件 A       │  │ 组件 B       │         │  │
│  │  │ loading: ✅ │  │ loading: ❌ │         │  │
│  │  └─────────────┘  └─────────────┘         │  │
│  │  ┌─────────────┐  ┌─────────────┐         │  │
│  │  │ 组件 C       │  │ 组件 D       │         │  │
│  │  │ loading: ❌ │  │ loading: ✅ │         │  │
│  │  └─────────────┘  └─────────────┘         │  │
│  └───────────────────────────────────────────┘  │
│  globalLoading: ✅ (只要有一个请求就在转)          │
└─────────────────────────────────────────────────┘

两种 Loading 的配合关系

text 复制代码
┌──────────────────────────────────────────────────────────┐
│  用户发起请求                                              │
│       ↓                                                   │
│  ┌────────────────────────────────────────────────────┐  │
│  │ useFetch.execute()                                 │  │
│  │   ↓                                                │  │
│  │ loading.value = true  ← 组件级 loading 开启         │  │
│  │   ↓                                                │  │
│  │ request.get/post/...                               │  │
│  └────────────────────────────────────────────────────┘  │
│       ↓                                                   │
│  ┌────────────────────────────────────────────────────┐  │
│  │ Axios 请求拦截器                                     │  │
│  │   ↓                                                │  │
│  │ if (config.showGlobalLoading !== false)           │  │
│  │   loadingStore.showLoading()  ← 全局 loading 开启   │  │
│  └────────────────────────────────────────────────────┘  │
│       ↓                                                   │
│  服务器处理...                                            │
│       ↓                                                   │
│  ┌────────────────────────────────────────────────────┐  │
│  │ Axios 响应拦截器                                     │  │
│  │   ↓                                                │  │
│  │ loadingStore.hideLoading()  ← 全局 loading 关闭     │  │
│  └────────────────────────────────────────────────────┘  │
│       ↓                                                   │
│  ┌────────────────────────────────────────────────────┐  │
│  │ useFetch finally 块                                 │  │
│  │   ↓                                                │  │
│  │ loading.value = false  ← 组件级 loading 关闭        │  │
│  └────────────────────────────────────────────────────┘  │
└──────────────────────────────────────────────────────────┘

总结:是否需要修改?

需求 是否需要修改 useFetch 说明
基本使用 ❌ 不需要 当前代码已可用
某些请求不显示全局 loading ✅ 添加 showGlobalLoading 选项 如上所示
自定义全局 loading 行为 ❌ 不需要 在拦截器中控制即可
并发请求计数 ❌ 不需要 Pinia Store 已处理

八、常见误区与最佳实践

误区 正确做法
每个组件自己写 axios 统一封装 Axios 实例
不处理取消请求 组件卸载时取消未完成请求
错误处理分散 在响应拦截器统一处理
不定义 TS 类型 定义 ApiResponse 泛型接口
硬编码 baseURL 从环境变量读取
不区分业务错误和 HTTP 错误 分别处理 code 和 status

✅ 最佳实践 Checklist

  • 创建 Axios 实例,统一配置 baseURL、timeout
  • 添加请求拦截器(Token、日志等)
  • 添加响应拦截器(统一错误处理)
  • 定义 ApiResponse 泛型接口
  • 封装 useFetch Composable
  • 按业务模块组织 API(api/user.ts、api/order.ts)
  • 组件卸载时取消请求
  • 开发环境打印请求日志
  • 生产环境关闭日志
相关推荐
德育处主任2 小时前
JS 大数值处理和金额格式化处理方案
前端·javascript·前端框架
渔舟唱晚@4 小时前
React 19 核心 Hooks 深度解析
前端·react.js·前端框架
Swift社区5 小时前
Flutter 中如何优雅地处理复杂表单
前端·flutter·前端框架
zihan032119 小时前
若依(RuoYi)框架核心升级:全面适配 SpringData JPA,替换 MyBatis 持久层方案
java·开发语言·前端框架·mybatis·若依升级springboot
hypnos_xy1 天前
Vue3 工程构建
vue.js·前端框架
享誉霸王2 天前
15、告别混乱!Vue3复杂项目的规范搭建与基础库封装实战
前端·javascript·vue.js·前端框架·json·firefox·html5
biyezuopinvip2 天前
基于Spring Boot的投资理财系统设计与实现(毕业论文)
java·spring boot·vue·毕业设计·论文·毕业论文·投资理财系统设计与实现
biyezuopinvip2 天前
基于Spring Boot的投资理财系统设计与实现(任务书)
java·spring boot·vue·毕业设计·论文·任务书·投资理财系统设计与实现
huohuopro2 天前
Vue3 Webview 转 Android 虚拟导航栏遮挡问题记录
android·vue