为什么需要封装请求Composable
Vue3项目中,每个页面组件都在重复写loading状态管理、错误处理、数据缓存逻辑。用useFetch这类组合式函数统一收口,可以把重复代码降到接近零,同时保证所有接口调用的行为一致性——统一的错误提示、统一的Token刷新、统一的请求取消。
核心类型定义与基础结构
用TypeScript定义核心类型:
interface UseFetchOptions<T> extends Omit<RequestInit, 'body'> {
immediate?: boolean
initialData?: T
refetch?: boolean
debounce?: number
onSuccess?: (data: T) => void
onError?: (error: FetchError) => void
}
interface FetchError {
code: number
message: string
detail?: unknown
}
interface UseFetchReturn<T> {
data: Ref<T | undefined>
error: Ref<FetchError | null>
loading: Ref<boolean>
execute: (body?: unknown) => Promise<T | undefined>
abort: () => void
refresh: () => Promise<T | undefined>
}
useFetch完整实现
import { ref, watch, onUnmounted, type Ref } from 'vue'
const BASE_URL = import.meta.env.VITE_API_BASE || '/api'
let refreshPromise: Promise<string> | null = null
async function ensureToken(): Promise<string> {
const token = localStorage.getItem('access_token')
if (!token) return ''
// Token过期检测与刷新
const payload = JSON.parse(atob(token.split('.')[1]))
if (payload.exp * 1000 < Date.now() - 5000) {
if (!refreshPromise) {
refreshPromise = fetch('/api/auth/refresh', {
method: 'POST',
credentials: 'include'
}).then(r => r.json()).then(data => {
localStorage.setItem('access_token', data.access_token)
refreshPromise = null
return data.access_token
})
}
return refreshPromise
}
return token
}
export function useFetch<T>(url: string, options: UseFetchOptions<T> = {}): UseFetchReturn<T> {
const {
immediate = true,
initialData,
debounce: debounceMs = 0,
onSuccess,
onError,
...fetchOptions
} = options
const data = ref<T | undefined>(initialData)
const error = ref<FetchError | null>(null)
const loading = ref(false)
let controller: AbortController | null = null
let debounceTimer: ReturnType<typeof setTimeout> | null = null
const execute = async (body?: unknown): Promise<T | undefined> => {
controller?.abort()
controller = new AbortController()
loading.value = true
error.value = null
try {
const token = await ensureToken()
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(fetchOptions.headers as Record<string, string> || {})
}
const response = await fetch(`${BASE_URL}${url}`, {
...fetchOptions,
headers,
signal: controller.signal,
body: body ? JSON.stringify(body) : fetchOptions.body
})
if (!response.ok) {
const errData = await response.json().catch(() => ({}))
throw { code: response.status, message: errData.message || response.statusText, detail: errData }
}
const result = await response.json()
data.value = result
onSuccess?.(result)
return result
} catch (e: unknown) {
if (e instanceof DOMException && e.name === 'AbortError') return
const fetchError = (e as FetchError) || { code: 0, message: 'Unknown error' }
error.value = fetchError
onError?.(fetchError)
return undefined
} finally {
loading.value = false
}
}
const debouncedExecute = debounceMs > 0
? (body?: unknown) => {
debounceTimer && clearTimeout(debounceTimer)
return new Promise<T | undefined>(resolve => {
debounceTimer = setTimeout(() => execute(body).then(resolve), debounceMs)
})
}
: execute
const abort = () => {
controller?.abort()
debounceTimer && clearTimeout(debounceTimer)
}
onUnmounted(abort)
if (immediate) execute()
return {
data: data as Ref<T | undefined>,
error,
loading,
execute: debouncedExecute,
abort,
refresh: execute
}
}
派生Composable:分页与表单场景
基于useFetch封装分页请求:
interface PaginationParams {
page: number
pageSize: number
[key: string]: unknown
}
interface PaginatedResult<T> {
list: T[]
total: number
page: number
pageSize: number
}
export function usePaginatedFetch<T>(url: string) {
const page = ref(1)
const pageSize = ref(20)
const total = ref(0)
const filters = ref<Record<string, unknown>>({})
const { data, loading, error, execute } = useFetch<PaginatedResult<T>>(url, {
immediate: false,
onSuccess: (res) => {
total.value = res.total
}
})
const fetchPage = (p?: number) => {
if (p !== undefined) page.value = p
const params: PaginationParams = {
page: page.value,
pageSize: pageSize.value,
...filters.value
}
const qs = new URLSearchParams(
Object.entries(params).reduce((acc, [k, v]) => ({ ...acc, [k]: String(v) }), {})
).toString()
return execute(undefined)
}
watch([page, pageSize], () => fetchPage())
return { data, loading, error, page, pageSize, total, filters, fetchPage }
}
组件中使用:
<script setup lang="ts">
const { data, loading, page, total, fetchPage } = usePaginatedFetch<UserItem>('/users')
fetchPage(1)
</script>
<template>
<el-table :data="data?.list" v-loading="loading">
<!-- 列定义 -->
</el-table>
<el-pagination
v-model:current-page="page"
:total="total"
@current-change="fetchPage"
/>
</template>
请求缓存与SWR策略
给useFetch加上缓存层,避免组件重新挂载时重复请求:
const cache = new Map<string, { data: unknown; timestamp: number }>()
const CACHE_TTL = 5 * 60 * 1000 // 5分钟
export function useCachedFetch<T>(url: string, options: UseFetchOptions<T> = {}) {
const cacheKey = `${options.method || 'GET'}:${url}`
const cached = cache.get(cacheKey)
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
// SWR:立即返回缓存,后台刷新
const fetchReturn = useFetch<T>(url, { ...options, initialData: cached.data as T })
fetchReturn.refresh()
return fetchReturn
}
return useFetch<T>(url, {
...options,
onSuccess: (data) => {
cache.set(cacheKey, { data, timestamp: Date.now() })
options.onSuccess?.(data)
}
})
}
Composable封装的本质是职责收口。当所有HTTP请求走同一管道,错误处理、Token刷新、缓存策略只需维护一处,任何组件都能零成本复用这套基础设施。
原创文章,作者:小编,如若转载,请注明出处:https://www.yunthe.com/vue3-zu-he-shi-han-shu-feng-zhuang-da-zao-ke-fu-yong-de-yi/