组合式API的核心思维:逻辑关注点分离
Vue3的组合式API(Composition API)相比选项式API最大的优势在于逻辑关注点分离。选项式API中,同一个功能的代码被分散在data、methods、computed、watch等选项中,当组件变得复杂时,代码的维护成本急剧上升。组合式API允许将同一关注点的逻辑封装到一个函数中,这就是自定义Hook(Composable)。
一个标准的组合式函数应该遵循以下约定:以 use 开头命名;返回ref或reactive响应式数据;在setup函数或script setup中同步调用(不能在异步回调中使用);接收ref作为参数时,自动解包。
实战封装:useRequest异步请求Hook
异步数据获取是前端开发最高频的场景。下面封装一个生产可用的useRequest Hook,支持自动请求、手动触发、防抖、缓存、错误重试:
// composables/useRequest.ts
import { ref, watch, type Ref, onUnmounted } from 'vue'
interface UseRequestOptions<T> {
immediate?: boolean // 是否立即执行,默认true
debounce?: number // 防抖延迟ms
cache?: boolean // 是否启用缓存
cacheKey?: string // 缓存键
retry?: number // 重试次数
retryInterval?: number // 重试间隔ms
onSuccess?: (data: T) => void
onError?: (error: Error) => void
}
export function useRequest<T>(
fetchFn: () => Promise<T>,
options: UseRequestOptions<T> = {}
) {
const {
immediate = true,
retry = 0,
retryInterval = 1000,
} = options
const data = ref<T | null>(null) as Ref<T | null>
const error = ref<Error | null>(null)
const loading = ref(false)
let retryCount = 0
let debounceTimer: ReturnType<typeof setTimeout> | null = null
const execute = async () => {
loading.value = true
error.value = null
try {
data.value = await fetchFn()
retryCount = 0
options.onSuccess?.(data.value)
} catch (e) {
const err = e instanceof Error ? e : new Error(String(e))
if (retryCount < retry) {
retryCount++
setTimeout(execute, retryInterval)
return
}
error.value = err
options.onError?.(err)
} finally {
loading.value = false
}
}
const debouncedExecute = () => {
if (options.debounce) {
if (debounceTimer) clearTimeout(debounceTimer)
debounceTimer = setTimeout(execute, options.debounce)
} else {
execute()
}
}
if (immediate) {
execute()
}
onUnmounted(() => {
if (debounceTimer) clearTimeout(debounceTimer)
})
return { data, error, loading, execute: debouncedExecute }
}
在组件中使用:
<script setup lang="ts">
import { useRequest } from '@/composables/useRequest'
import { fetchUserList } from '@/api/user'
const { data: users, loading, error, execute: refresh } = useRequest(
() => fetchUserList({ page: 1, size: 20 }),
{
retry: 2,
retryInterval: 2000,
onError: (err) => console.error('获取用户列表失败:', err.message)
}
)
</script>
响应式参数与计算属性联动
高级场景下,Hook的参数需要响应式变化。例如分页组件中,页码改变时自动重新请求数据。封装一个支持响应式参数的usePagination Hook:
// composables/usePagination.ts
import { ref, watch, computed, type Ref } from 'vue'
import { useRequest } from './useRequest'
interface PaginationParams {
page: Ref<number>
pageSize: Ref<number>
}
export function usePagination<T>(
fetchFn: (page: number, pageSize: number) => Promise<{ list: T[]; total: number }>,
params: PaginationParams
) {
const { page, pageSize } = params
const total = ref(0)
const { data, loading, execute } = useRequest(
() => fetchFn(page.value, pageSize.value),
{ immediate: false }
)
// 页码或每页条数变化时自动请求
watch([page, pageSize], () => {
execute()
}, { immediate: true })
const totalPages = computed(() => Math.ceil(total.value / pageSize.value))
watch(data, (val) => {
if (val) total.value = val.total
})
return {
list: computed(() => data.value?.list ?? []),
total,
totalPages,
loading,
page,
pageSize,
}
}
Hook的组合与嵌套模式
组合式函数的核心价值在于可组合性——Hook之间可以相互调用形成更复杂的业务逻辑。以下是一个用户认证Hook组合Token管理和路由跳转的示例:
// composables/useAuth.ts
import { computed } from 'vue'
import { useStorage } from './useStorage'
import { useRouter } from 'vue-router'
export function useAuth() {
const router = useRouter()
const { value: token, set: setToken, remove: removeToken } = useStorage('auth_token', '')
const isLoggedIn = computed(() => !!token.value)
const logout = () => {
removeToken()
router.push('/login')
}
return { token, isLoggedIn, setToken, logout }
}
// composables/useStorage.ts
import { ref, watch } from 'vue'
export function useStorage<T>(key: string, defaultValue: T) {
const value = ref<T>(
JSON.parse(localStorage.getItem(key) ?? 'null') ?? defaultValue
)
watch(value, (val) => {
localStorage.setItem(key, JSON.stringify(val))
}, { deep: true })
return {
value,
set: (val: T) => { value.value = val },
remove: () => {
value.value = defaultValue
localStorage.removeItem(key)
}
}
}
前端工程化中Hook的测试策略
组合式函数的单元测试相对简单,因为它们是纯函数。使用Vue的 @vue/test-utils 配合ViTest进行测试:
// __tests__/useRequest.test.ts
import { describe, it, expect, vi } from 'vitest'
import { useRequest } from '@/composables/useRequest'
describe('useRequest', () => {
it('应在immediate=true时自动请求', async () => {
const mockFn = vi.fn().mockResolvedValue({ id: 1, name: 'test' })
const { data, loading } = useRequest(mockFn, { immediate: true })
expect(loading.value).toBe(true)
await vi.waitFor(() => {
expect(data.value).toEqual({ id: 1, name: 'test' })
expect(loading.value).toBe(false)
})
})
it('应在retry次后报告错误', async () => {
const mockFn = vi.fn().mockRejectedValue(new Error('fail'))
const { error } = useRequest(mockFn, { retry: 2, retryInterval: 100 })
await vi.waitFor(() => {
expect(error.value).toBeInstanceOf(Error)
expect(mockFn).toHaveBeenCalledTimes(3) // 1次初始 + 2次重试
})
})
})
测试中需要关注:响应式数据的初始值和变更时机、异步请求的loading状态转换、错误和重试逻辑、组件卸载时的清理操作。保持Hook职责单一、依赖明确(通过参数注入而非直接import全局状态),是提高可测试性的关键。
原创文章,作者:小编,如若转载,请注明出处:https://www.yunthe.com/vue3-zu-he-shi-han-shu-feng-zhuang-cong-zi-ding-yi-hook-dao/