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 中初始化)
-
- 为什么需要延迟初始化?循环依赖问题详解
- [不在 main.ts 中导入 initLoadingStore](#不在 main.ts 中导入 initLoadingStore)
- [✅ 添加全局 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)
- 组件卸载时取消请求
- 开发环境打印请求日志
- 生产环境关闭日志