Vue3的组合式API(Composition API)改变了组件逻辑的组织方式,将同一功能关注点的代码集中在一起。自定义Hook(Composables)是组合式API的核心实践,将可复用的响应式逻辑从组件中抽离为独立函数。结合TypeScript的泛型推导,能实现类型安全的Hook复用。本文从基础Hook设计到复杂场景的异步状态管理,给出完整的工程化实现。
自定义Hook的基本结构设计
Vue3自定义Hook命名约定以use开头,返回响应式数据和方法。一个规范的Hook应包含状态定义、副作用处理和返回值导出三个部分:
// composables/useCounter.ts
import { ref, computed, watch } from 'vue'
export interface CounterOptions {
initial?: number
min?: number
max?: number
step?: number
}
export function useCounter(options: CounterOptions = {}) {
const { initial = 0, min = -Infinity, max = Infinity, step = 1 } = options
const count = ref(initial)
const isMin = computed(() => count.value <= min)
const isMax = computed(() => count.value >= max)
function increment(n: number = step) {
count.value = Math.min(count.value + n, max)
}
function decrement(n: number = step) {
count.value = Math.max(count.value - n, min)
}
function reset() {
count.value = initial
}
watch(
() => [min, max],
([newMin, newMax]) => {
if (count.value < newMin) count.value = newMin
if (count.value > newMax) count.value = newMax
}
)
return { count, isMin, isMax, increment, decrement, reset }
}
返回值中的ref和computed不能被解构后丢失响应性。在组件中使用时需通过.value访问,或在template中自动解包。如果需要解构使用,用toRefs包装返回对象。
异步数据请求Hook的类型安全实现
异步数据获取是前端最常见的场景。一个完善的异步数据Hook需要处理加载状态、错误处理、请求取消和重试机制:
// composables/useAsync.ts
import { ref, shallowRef, onUnmounted, type Ref } from 'vue'
export interface UseAsyncOptions {
immediate?: boolean
retries?: number
retryDelay?: number
onSuccess?: (data: T) => void
onError?: (error: Error) => void
}
export interface UseAsyncResult {
data: Ref
loading: Ref
error: Ref
execute: () => Promise
abort: () => void
}
export function useAsync(
fn: (signal: AbortSignal) => Promise,
options: UseAsyncOptions = {}
): UseAsyncResult {
const { immediate = true, retries = 3, retryDelay = 1000, onSuccess, onError } = options
const data = shallowRef(null)
const loading = ref(false)
const error = ref(null)
let abortController: AbortController | null = null
let retryCount = 0
async function execute() {
loading.value = true
error.value = null
abortController?.abort()
abortController = new AbortController()
try {
const result = await fn(abortController.signal)
data.value = result
retryCount = 0
onSuccess?.(result)
} catch (e) {
if (e instanceof DOMException && e.name === 'AbortError') return
if (retryCount < retries) {
retryCount++
setTimeout(() => execute(), retryDelay)
return
}
error.value = e as Error
onError?.(e as Error)
} finally {
loading.value = false
}
}
function abort() {
abortController?.abort()
loading.value = false
}
onUnmounted(() => abortController?.abort())
if (immediate) execute()
return { data, loading, error, execute, abort }
}
使用shallowRef而非ref存储异步数据。如果返回的是大型对象或数组,ref会深度递归代理整个数据结构,造成不必要的性能开销。shallowRef只代理.value本身,内部数据保持原始引用。
泛型T从fn的返回类型自动推导,无需手动指定:
// 类型自动推导为 UseAsyncResult
const { data, loading, error } = useAsync(async (signal) => {
const res = await fetch('/api/user/profile', { signal })
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.json() as Promise
})
表单验证Hook的响应式设计
表单验证是前端高频需求。将验证逻辑抽离为Hook,实现声明式校验:
// composables/useForm.ts
import { reactive, computed, type UnwrapNestedRefs } from 'vue'
export type Validator = (value: T, formData: Record) => string | true
export type ValidationSchema = { [K in keyof T]?: Validator[] }
export function useForm>(
initialValues: T,
schema: ValidationSchema
) {
const values = reactive({ ...initialValues }) as UnwrapNestedRefs
const errors = reactive({}) as Record
const touched = reactive({}) as Record
function validateField(field: keyof T): boolean {
const validators = schema[field]
if (!validators) return true
const value = values[field]
for (const validator of validators) {
const result = validator(value, values)
if (result !== true) {
errors[field] = result
return false
}
}
delete errors[field]
return true
}
function validateAll(): boolean {
let valid = true
for (const field in schema) {
if (!validateField(field)) valid = false
}
return valid
}
function setField(field: keyof T, value: any) {
values[field] = value
touched[field] = true
if (errors[field]) validateField(field)
}
function reset() {
Object.assign(values, initialValues)
Object.keys(errors).forEach(k => delete errors[k as keyof T])
Object.keys(touched).forEach(k => delete touched[k as keyof T])
}
const isValid = computed(() => Object.keys(errors).length === 0)
const isDirty = computed(() => Object.keys(touched).some(k => touched[k as keyof T]))
return { values, errors, touched, isValid, isDirty, validateField, validateAll, setField, reset }
}
// 使用示例
interface LoginForm { email: string; password: string }
const form = useForm(
{ email: '', password: '' },
{
email: [
(v) => v.trim() === '' ? '邮箱不能为空' : true,
(v) => /^[^@]+@[^@]+\.[^@]+$/.test(v) ? true : '邮箱格式不正确'
],
password: [
(v) => v === '' ? '密码不能为空' : true,
(v) => v.length >= 8 ? true : '密码至少8位'
]
}
)
验证函数的类型约束Validator<T>确保每个字段的验证器接收正确类型的值。ValidationSchema<T>使用映射类型,schema的字段必须与表单数据结构T的key一一对应,TypeScript编译器在编写阶段就能发现字段名拼写错误。
跨组件状态共享的Hook模式
当多个组件需要共享同一份状态时,使用provide/inject配合组合式函数实现轻量级状态管理,避免引入完整的状态管理库:
// composables/useTheme.ts
import { ref, provide, inject, type InjectionKey, type Ref } from 'vue'
export type Theme = 'light' | 'dark' | 'auto'
interface ThemeContext {
theme: Ref
resolvedTheme: Ref<'light' | 'dark'>
setTheme: (t: Theme) => void
toggleTheme: () => void
}
export const ThemeKey: InjectionKey = Symbol('theme')
export function provideTheme(initial: Theme = 'auto'): ThemeContext {
const theme = ref(initial)
const resolvedTheme = ref<'light' | 'dark'>('light')
function resolve() {
if (theme.value === 'auto') {
resolvedTheme.value = window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark' : 'light'
} else {
resolvedTheme.value = theme.value
}
document.documentElement.setAttribute('data-theme', resolvedTheme.value)
}
function setTheme(t: Theme) {
theme.value = t
localStorage.setItem('theme', t)
resolve()
}
function toggleTheme() {
setTheme(resolvedTheme.value === 'light' ? 'dark' : 'light')
}
window.matchMedia('(prefers-color-scheme: dark)')
.addEventListener('change', () => { if (theme.value === 'auto') resolve() })
const saved = localStorage.getItem('theme') as Theme | null
if (saved) theme.value = saved
resolve()
const context: ThemeContext = { theme, resolvedTheme, setTheme, toggleTheme }
provide(ThemeKey, context)
return context
}
export function useTheme(): ThemeContext {
const context = inject(ThemeKey)
if (!context) throw new Error('useTheme() must be used within a component that calls provideTheme()')
return context
}
使用InjectionKey作为provide/inject的key,确保注入值的类型安全。父组件调用provideTheme()提供状态,所有子孙组件通过useTheme()注入同一份响应式状态。避免了Pinia等状态管理库的学习成本和打包体积,同时保持了完整的TypeScript类型推导。
Hook的单元测试策略
自定义Hook应与组件一样编写单元测试。使用@vue/test-utils的withSetup辅助函数在测试环境中运行Hook:
// tests/useForm.test.ts
import { describe, it, expect } from 'vitest'
import { withSetup } from './helpers'
import { useForm } from '@/composables/useForm'
describe('useForm', () => {
it('验证必填字段', () => {
const { validateField, errors } = withSetup(() => useForm(
{ name: '' },
{ name: [(v) => v ? true : '必填'] }
))
expect(validateField('name')).toBe(false)
expect(errors.name).toBe('必填')
})
it('validateAll返回整体校验结果', () => {
const { validateAll } = withSetup(() => useForm(
{ name: '', age: 25 },
{
name: [(v) => v ? true : '必填'],
age: [(v) => v >= 18 ? true : '需年满18岁']
}
))
expect(validateAll()).toBe(false)
})
})
测试覆盖验证规则触发、错误消息生成、整体校验结果三个维度。异步Hook的测试需配合vi.useFakeTimers()控制定时器,验证重试逻辑和超时处理。
原创文章,作者:小编,如若转载,请注明出处:https://www.yunthe.com/vue3-zu-he-shi-api-gong-cheng-hua-shi-jian-zi-ding-yi-hook/