为什么需要系统化设计自定义Hook
Vue3的Composition API让逻辑复用变得灵活,但也带来了随意性——缺乏设计约束的Hook最终会退化成难以维护的函数堆。好的自定义Hook应该像服务层一样有清晰的输入输出契约、可预测的副作用管理、合理的响应式边界。实际项目中,逻辑复用的质量直接决定了组件层的复杂度上限。
自定义Hook的基本设计原则
一个结构良好的Hook遵循三个原则:单一职责、显式依赖、可控副作用。
// 不好的Hook:职责混杂
function useUser() {
const user = ref(null)
const orders = ref([])
const fetchUser = async (id) => { ... }
const fetchOrders = async (userId) => { ... }
const updateProfile = async (data) => { ... }
return { user, orders, fetchUser, fetchOrders, updateProfile }
}
// 好的Hook:职责单一
function useUser(id: Ref<string>) {
const user = ref(null)
const loading = ref(false)
const error = ref(null)
watchEffect(async () => {
loading.value = true
try {
user.value = await fetchUserApi(id.value)
} catch (e) {
error.value = e
} finally {
loading.value = false
}
})
return { user, loading, error }
}
useUser只管用户数据获取,useOrders独立管理订单逻辑。组件中按需组合,比一个巨型useUser包含所有逻辑清晰得多。
响应式数据在Hook中的边界控制
Vue3的ref和reactive在传递过程中的行为差异是性能问题的常见来源:
// reactive对象的解构会丢失响应式
function useConfig() {
const config = reactive({
theme: 'dark',
fontSize: 14,
locale: 'zh-CN'
})
// 解构后不再是响应式
const { theme, fontSize } = config
// 使用toRefs保持响应式
const { theme, fontSize, locale } = toRefs(config)
return { theme, fontSize, locale }
}
Hook返回值统一使用toRefs包装,这是避免消费者端踩坑的最简单方案。如果内部用ref管理,直接返回即可——ref本身就具备响应式传递能力。
异步数据获取Hook的完整模式
useFetch是最常见的Hook模式。生产级的实现需要处理竞态、缓存和错误重试:
import { ref, watch, toValue, type MaybeRef } from 'vue'
function useFetch<T>(url: MaybeRef<string>, options?: {
immediate?: boolean
refetch?: boolean
initialValue?: T
}) {
const data = ref<T | undefined>(options?.initialValue)
const error = ref<Error | null>(null)
const loading = ref(false)
const statusCode = ref<number | null>(null)
let controller: AbortController | null = null
async function execute() {
// 取消上一次未完成的请求
controller?.abort()
controller = new AbortController()
loading.value = true
error.value = null
try {
const response = await fetch(toValue(url), {
signal: controller.signal
})
statusCode.value = response.status
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
data.value = await response.json()
} catch (e: any) {
if (e.name !== 'AbortError') {
error.value = e
}
} finally {
loading.value = false
}
}
// 响应式URL变化自动重新请求
if (options?.refetch) {
watch(() => toValue(url), execute)
}
if (options?.immediate !== false) {
execute()
}
return { data, error, loading, statusCode, execute }
}
几个关键设计点:AbortController处理竞态——URL快速切换时自动取消前一次请求;toValue()统一处理ref和原始值;immediate选项允许延迟执行;statusCode暴露HTTP状态码供上层做条件判断。
计算属性的性能陷阱与shallowRef优化
Vue3的computed会缓存计算结果,但如果依赖的数据结构很深,触发频率会超出预期:
// 深层响应式 + computed = 性能杀手
const formData = reactive({
user: { name: '', email: '', phone: '' },
address: { city: '', street: '', zip: '' },
preferences: { theme: 'dark', notifications: true }
})
// 任何一个字段变化都会触发
const isValid = computed(() => {
return formData.user.name.length > 0
&& formData.user.email.includes('@')
&& formData.address.city.length > 0
})
当表单有100+字段时,每次输入都会触发computed重算。优化方案:用shallowRef管理表单数据,手动控制更新时机:
const formData = shallowRef({
user: { name: '', email: '', phone: '' },
address: { city: '', street: '', zip: '' },
preferences: { theme: 'dark', notifications: true }
})
// 手动触发更新,只在失焦或提交时
function updateField(path: string, value: any) {
formData.value = { ...formData.value } // 浅拷贝触发响应式
}
shallowRef只在.value引用变化时触发更新,内部深层修改不会触发。这对大型表单场景非常有效——输入过程中不触发任何watcher或computed,只在显式调用updateField时才更新。
provide/inject跨层级通信的类型安全封装
provide/inject缺少类型推断,封装为useContext模式可以解决:
// 定义InjectionKey
import type { InjectionKey } from 'vue'
interface UserContext {
user: Ref<User | null>
login: (credentials: Credentials) => Promise<void>
logout: () => void
isAuthenticated: ComputedRef<boolean>
}
const USER_KEY: InjectionKey<UserContext> = Symbol('user')
// Provider端
function provideUser() {
const user = ref<User | null>(null)
const isAuthenticated = computed(() => !!user.value)
async function login(credentials: Credentials) {
user.value = await loginApi(credentials)
}
function logout() {
user.value = null
}
provide(USER_KEY, { user, login, logout, isAuthenticated })
}
// Consumer端 - 类型完整
function useUserContext() {
const ctx = inject(USER_KEY)
if (!ctx) throw new Error('useUserContext must be used inside UserProvider')
return ctx
}
这种模式的好处:TypeScript类型完整、调用方不需要知道provide的key、找不到provider时给出明确错误信息而非静默返回undefined。
Hook测试策略与Vue Test Utils实践
自定义Hook需要独立测试,不依赖组件渲染:
import { describe, it, expect, vi } from 'vitest'
import { useFetch } from './useFetch'
// 使用withSetup辅助函数测试Hook
function withSetup<T>(hook: () => T): T {
let result: T
const app = createApp({
setup() {
result = hook()
return () => h('div')
}
})
const el = document.createElement('div')
app.mount(el)
return result!
}
describe('useFetch', () => {
it('should fetch data', async () => {
const { data, loading, execute } = withSetup(() =>
useFetch('/api/test', { immediate: false })
)
expect(loading.value).toBe(false)
await execute()
expect(data.value).toBeDefined()
})
it('should abort previous request on new execute', async () => {
const { execute } = withSetup(() =>
useFetch('/api/test', { immediate: false })
)
const p1 = execute()
const p2 = execute() // 应该取消p1
await Promise.allSettled([p1, p2])
})
})
测试重点:异步竞态取消、错误状态转换、loading状态时序。这些是运行时最容易出bug的地方。
实战中的Hook组合模式
复杂业务场景下,多个Hook组合使用时需要注意生命周期和清理:
function useDashboard(userId: Ref<string>) {
// 组合多个原子Hook
const { data: user, loading: userLoading } = useUser(userId)
const { data: stats, loading: statsLoading } = useStats(userId)
const { data: activities, loading: actLoading } = useActivities(userId)
// 派生状态
const isLoading = computed(() =>
userLoading.value || statsLoading.value || actLoading.value
)
// 整体刷新
function refresh() {
// 三个请求并行发出
return Promise.all([
useUser(userId).execute(),
useStats(userId).execute(),
useActivities(userId).execute()
])
}
return { user, stats, activities, isLoading, refresh }
}
组合Hook时不要在内部重复创建子Hook实例——每次调用useUser()都会创建新的响应式状态。正确的做法是在组件setup中创建一次,通过参数传递给组合Hook。Vue3的Hook设计核心在于:每个Hook是一个独立的响应式单元,组合时通过引用而非重复实例化来建立关系。遵循这个原则,复杂度随业务增长可控,不会陷入回调和状态同步的泥潭。
原创文章,作者:小编,如若转载,请注明出处:https://www.yunthe.com/vue3-zu-he-shi-api-shen-du-shi-jian-zi-ding-yi-hook-she-ji/