为什么需要自定义Hook以及设计原则
Vue3的Composition API把逻辑组织从选项式的分散结构变成了可组合的函数块。自定义Hook是这个模型的核心产物——把可复用的响应式逻辑封装成独立函数,组件只需调用即可获得完整功能。好的Hook设计遵循单一职责、显式依赖、可测试三个原则。
不好的做法:把一个页面的所有逻辑写在一个setup里,然后在”重构”时粗暴抽成函数。好的做法:从需求分析阶段就把功能拆成独立Hook。
数据获取Hook的标准实现
远程数据获取是最高频的Hook场景。一个生产级的useFetch需要处理加载状态、错误重试、请求取消和缓存。
// composables/useFetch.ts
import { ref, shallowRef, watchEffect, onScopeDispose } from 'vue'
interface UseFetchOptions<T> {
immediate?: boolean
initialData?: T
refetch?: boolean
dedupe?: number
}
export function useFetch<T>(
urlGetter: () => string,
options: UseFetchOptions<T> = {}
) {
const { immediate = true, initialData, refetch = false, dedupe = 0 } = options
const data = shallowRef<T | undefined>(initialData)
const error = ref<Error | null>(null)
const loading = ref(false)
const lastFetchTime = ref(0)
let abortController: AbortController | null = null
async function execute() {
// 请求去重
if (dedupe > 0 && Date.now() - lastFetchTime.value < dedupe) {
return
}
// 取消上一个请求
abortController?.abort()
abortController = new AbortController()
loading.value = true
error.value = null
try {
const url = urlGetter()
const response = await fetch(url, {
signal: abortController.signal,
})
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
data.value = await response.json()
lastFetchTime.value = Date.now()
} catch (e: any) {
if (e.name !== 'AbortError') {
error.value = e
}
} finally {
loading.value = false
}
}
if (immediate) {
watchEffect(() => {
execute()
})
}
onScopeDispose(() => {
abortController?.abort()
})
return { data, error, loading, execute }
}
使用方式:
// 在组件中使用
const { data: users, error, loading } = useFetch(
() => `/api/users?page=${page.value}`,
{ dedupe: 2000 }
)
响应式性能陷阱与shallowRef
Vue3的reactive和ref默认深度代理整个对象树。当数据量大或嵌套深时,代理开销会成为性能瓶颈。
典型问题:从API拿到一个包含500条记录的数组,放进reactive后,首次渲染耗时翻倍。
解决方案:对只读的大对象使用shallowRef,避免深度代理。
// 不好的做法
const list = ref([]) // 深度代理每个元素
list.value = await fetchLargeList() // 每个元素的属性都被Proxy包装
// 好的做法
const list = shallowRef([]) // 只代理.value本身
list.value = await fetchLargeList() // 数组元素不被代理
// 如果需要修改数组中某个元素,整体替换触发更新
function updateItem(index: number, newData: any) {
const newList = [...list.value]
newList[index] = { ...newList[index], ...newData }
list.value = newList // 触发响应式更新
}
另一个常见陷阱:在v-for中对大列表使用computed做复杂转换。每次依赖变化都重新计算整个列表。
// 不好的做法
const filteredList = computed(() =>
hugeList.value.filter(expensivePredicate).map(transform)
)
// 好的做法:分页 + 手动触发
const pageSize = 50
const currentPage = ref(1)
const pagedList = computed(() => {
const start = (currentPage.value - 1) * pageSize
return hugeList.value.slice(start, start + pageSize)
})
跨组件状态共享的Hook模式
小型项目用Pinia当然没问题,但有时只需要在几个兄弟组件间共享状态,引入Pinia有些重。这时可以用Hook闭包实现轻量状态共享。
// composables/useCounter.ts
const counter = ref(0) // 模块级变量,跨组件共享
export function useCounter() {
function increment() { counter.value++ }
function decrement() { counter.value-- }
function reset() { counter.value = 0 }
return { count: counter, increment, decrement, reset }
}
// 任何组件调用useCounter()拿到的都是同一个ref
// 这比provide/inject更轻量,比全局变量更清晰
关键点:ref声明在函数外部(模块作用域),不是函数内部。这样每次调用useCounter返回的是同一个响应式引用。
Hook的可测试性设计
Hook应该可以脱离组件单独测试。使用@vue/test-utils的withSetup辅助函数。
// tests/useFetch.spec.ts
import { withSetup } from './test-utils'
describe('useFetch', () => {
it('should fetch data on mount', async () => {
const { data, loading } = withSetup(() =>
useFetch(() => '/api/test')
)
expect(loading.value).toBe(true)
await new Promise(r => setTimeout(r, 100))
expect(loading.value).toBe(false)
expect(data.value).toBeDefined()
})
})
Hook内如果有外部依赖(如fetch),通过参数注入替代硬编码,方便测试时mock。
原创文章,作者:小编,如若转载,请注明出处:https://www.yunthe.com/vue3-zu-he-shi-api-jin-jie-zi-ding-yi-hook-she-ji-mo-shi-yu/