组合式API下业务逻辑复用的正确姿势
Vue3组合式API解决了Options API下逻辑分散在data、methods、computed中难以复用的问题。但实践中发现,不少团队对Hook的封装停留在”把setup代码搬进函数”的层面,缺少对依赖注入、生命周期管理和类型约束的考量。企业级业务Hook需要做到可测试、可配置、可组合,而非简单的代码搬移。
基础Hook封装:useRequest请求管理
数据请求是前端最常见的业务场景。封装一个通用的useRequest Hook,处理加载状态、错误处理和重复请求取消:
import { ref, shallowRef, onUnmounted } from 'vue'
import type { Ref, ShallowRef } from 'vue'
interface UseRequestOptions<T> {
immediate?: boolean
initialData?: T
onError?: (err: Error) => void
}
interface UseRequestReturn<T> {
data: ShallowRef<T | undefined>
loading: Ref<boolean>
error: Ref<Error | null>
run: (...args: any[]) => Promise<T>
cancel: () => void
}
export function useRequest<T>(
fn: (...args: any[]) => Promise<T>,
options: UseRequestOptions<T> = {}
): UseRequestReturn<T> {
const { immediate = true, initialData, onError } = options
const data = shallowRef<T | undefined>(initialData)
const loading = ref(false)
const error = ref<Error | null>(null)
let abortController: AbortController | null = null
const run = async (...args: any[]): Promise<T> => {
// 取消上一次未完成请求
if (abortController) {
abortController.abort()
}
abortController = new AbortController()
loading.value = true
error.value = null
try {
const result = await fn(...args)
data.value = result
return result
} catch (e) {
const err = e instanceof Error ? e : new Error(String(e))
error.value = err
onError?.(err)
throw err
} finally {
loading.value = false
abortController = null
}
}
const cancel = () => {
abortController?.abort()
loading.value = false
}
onUnmounted(cancel)
if (immediate) {
run()
}
return { data, loading, error, run, cancel }
}
关键设计:shallowRef避免深度响应大对象的性能开销;重复调用run时自动取消上一次请求防止竞态;onUnmounted中清理避免内存泄漏。
带缓存的分页Hook:usePagination
列表分页是中后台系统出现频率最高的场景。封装带缓存的分页Hook,避免切换页码后回退时重复请求:
import { ref, computed, watch } from 'vue'
interface PaginationState<T> {
list: T[]
total: number
page: number
pageSize: number
}
export function usePagination<T>(
fetchFn: (page: number, pageSize: number) => Promise<{ list: T[]; total: number }>,
pageSize = 20
) {
const page = ref(1)
const total = ref(0)
const list = ref<T[]>([])
const loading = ref(false)
// 页级缓存,避免回退翻页重复请求
const cache = new Map<string, { list: T[]; total: number }>()
const fetch = async (p: number) => {
const cacheKey = `${p}_${pageSize}`
if (cache.has(cacheKey)) {
const cached = cache.get(cacheKey)!
list.value = cached.list
total.value = cached.total
return
}
loading.value = true
try {
const res = await fetchFn(p, pageSize)
list.value = res.list
total.value = res.total
cache.set(cacheKey, res)
} finally {
loading.value = false
}
}
// 缓存上限100页,防止内存泄漏
const pruneCache = () => {
if (cache.size > 100) {
const firstKey = cache.keys().next().value
cache.delete(firstKey)
}
}
watch(page, (newPage) => {
pruneCache()
fetch(newPage)
}, { immediate: true })
const totalPages = computed(() => Math.ceil(total.value / pageSize))
return { list, total, page, pageSize, totalPages, loading, refresh: fetch }
}
页面切换时直接从缓存读取,用户体感上翻页”零延迟”。缓存上限设为100页,超过后LRU淘汰,平衡性能和内存。
跨组件状态共享:useGlobalState
多个组件共享同一份状态时,Pinia是最常见的方案。但轻量场景下,一个简单的Hook即可实现:
import { ref, readonly } from 'vue'
function createGlobalState<T>(initializer: () => T) {
let state: T | undefined
return (): T => {
if (state === undefined) {
state = initializer()
}
return state
}
}
// 全局用户状态Hook
const useUserState = createGlobalState(() => {
const userInfo = ref<{ name: string; role: string } | null>(null)
const permissions = ref<string[]>([])
const isAdmin = computed(() => userInfo.value?.role === 'admin')
return { userInfo, permissions, isAdmin }
})
// 任意组件中使用,状态自动共享
// const { userInfo, permissions, isAdmin } = useUserState()
闭包保证state只初始化一次,所有调用者共享同一个引用。返回readonly包装的值可防止外部意外修改。相比Pinia,这种方式零依赖、类型完备,适合3-5个组件共享的简单场景。
Hook的单元测试策略
企业级Hook必须有单元测试。使用@vue/test-utils的withSetup辅助函数模拟组件环境:
import { withSetup } from './test-utils'
import { useRequest } from './useRequest'
describe('useRequest', () => {
it('should handle loading state', async () => {
const mockFn = vi.fn().mockResolvedValue({ id: 1, name: 'test' })
const [result] = withSetup(() =>
useRequest(mockFn, { immediate: false })
)
expect(result.loading.value).toBe(false)
const promise = result.run()
expect(result.loading.value).toBe(true)
await promise
expect(result.loading.value).toBe(false)
expect(result.data.value).toEqual({ id: 1, name: 'test' })
})
it('should cancel previous request', async () => {
let callCount = 0
const mockFn = vi.fn().mockImplementation(async () => {
callCount++
await new Promise(r => setTimeout(r, 100))
return callCount
})
const [result] = withSetup(() =>
useRequest(mockFn, { immediate: false })
)
result.run() // 第一次请求
const promise = result.run() // 取消第一次,发起第二次
await promise
expect(callCount).toBe(2)
})
})
Hook封装的质量取决于三个维度:是否把所有副作用(定时器、事件监听、网络请求)都在onUnmounted中清理;是否通过TypeScript泛型约束输入输出类型;是否对边界情况(空值、错误、竞态)做了处理。做到这三点,Hook才具备企业级可维护性。
原创文章,作者:小编,如若转载,请注明出处:https://www.yunthe.com/vue3-zu-he-shi-api-feng-zhuang-qi-ye-ji-ye-wu-hook-fang-fa/