Vue 3 自定义 Hooks 设计:可复用逻辑的组织与类型安全实践
Vue 3 自定义 Hooks 设计:可复用逻辑的组织与类型安全实践
Vue 3 的 Composition API 最大的好处是能让逻辑复用变得简单。Mixins、Scoped Slots 这些 Vue 2 时代的方案要么有命名冲突,要么有数据来源不清晰的问题。Hooks 函数(也叫 composables)解决了这些问题。
但 Hooks 写不好也会有问题:数据流不清晰、响应式丢失、类型推断失败、维护困难。我整理一下实际项目里摸索出来的设计原则和最佳实践。
什么是 Hooks(composables)
Hooks 本质上就是一个函数,内部用了 Vue 的响应式 API,返回的数据可以在组件里用:
// composables/useCounter.ts
import { ref } from 'vue'
export function useCounter(initialCount = 0) {
const count = ref(initialCount)
function increment() {
count.value++
}
function decrement() {
count.value--
}
return {
count,
increment,
decrement,
}
}
组件里这样用:
<script setup>
import { useCounter } from '@/composables/useCounter'
const { count, increment, decrement } = useCounter(10)
</script>
常见问题一:响应式丢失
问题代码
// 错误:返回普通对象而非响应式数据
export function useUser() {
const name = ref('张三')
const age = ref(25)
// 问题:解构后响应式丢失
return {
name, // ref 会自动解开,但 age 不会
age,
}
}
当 useUser() 返回后,name 和 age 在组件里虽然能用,但不再是响应式的了(虽然 ref 会自动解开)。
正确写法
// 方案一:直接返回 ref
export function useUser() {
const name = ref('张三')
const age = ref(25)
return {
name, // ref 自动解开,组件里用 name.value
age, // ref 自动解开,组件里用 age.value
}
}
// 方案二:用 toRefs 保持响应式关联
export function useUser() {
const state = reactive({
name: '张三',
age: 25,
})
return toRefs(state) // 解构后依然是响应式的
}
// 方案三:返回整个响应式对象
export function useUser() {
const state = reactive({
name: '张三',
age: 25,
})
return {
state, // 返回 reactive 对象本身
// 或者返回只读版本
readonlyState: readonly(state),
}
}
常见问题二:生命周期钩子位置不对
问题代码
// 错误:在 composable 里调用了 onMounted,但 composable 可能在 setup 外被调用
export function useOnlineStatus() {
const isOnline = ref(navigator.onLine)
onMounted(() => {
window.addEventListener('online', () => isOnline.value = true)
window.addEventListener('offline', () => isOnline.value = false)
})
return { isOnline }
}
正确写法
// 正确:用 onMounted 返回清理函数
export function useOnlineStatus() {
const isOnline = ref(navigator.onLine)
onMounted(() => {
const handleOnline = () => isOnline.value = true
const handleOffline = () => isOnline.value = false
window.addEventListener('online', handleOnline)
window.addEventListener('offline', handleOffline)
// 返回清理函数
onUnmounted(() => {
window.removeEventListener('online', handleOnline)
window.removeEventListener('offline', handleOffline)
})
})
return { isOnline }
}
或者更简洁的写法,用 useEventListener(如果你们项目封装了的话):
export function useOnlineStatus() {
const isOnline = ref(navigator.onLine)
useEventListener(window, 'online', () => isOnline.value = true)
useEventListener(window, 'offline', () => isOnline.value = false)
return { isOnline }
}
常见问题三:类型安全不足
问题代码
// 没有类型定义的 composable
export function usePagination() {
const page = ref(1)
const pageSize = ref(20)
const total = ref(0)
function nextPage() {
page.value++
}
return { page, pageSize, total, nextPage }
}
调用方不知道 page 和 pageSize 是什么类型,IDE 补全也不完整。
正确写法
// 定义完整的返回类型
interface UsePaginationReturn {
page: Ref<number>
pageSize: Ref<number>
total: Ref<number>
pageCount: ComputedRef<number>
nextPage: () => void
prevPage: () => void
setPage: (p: number) => void
}
export function usePagination(
initialPage = 1,
initialPageSize = 20
): UsePaginationReturn {
const page = ref(initialPage)
const pageSize = ref(initialPageSize)
const total = ref(0)
const pageCount = computed(() => Math.ceil(total.value / pageSize.value))
function nextPage() {
if (page.value < pageCount.value) {
page.value++
}
}
function prevPage() {
if (page.value > 1) {
page.value--
}
}
function setPage(p: number) {
page.value = Math.max(1, Math.min(p, pageCount.value))
}
return {
page,
pageSize,
total,
pageCount,
nextPage,
prevPage,
setPage,
}
}
这样 TypeScript 能完整推导类型,IDE 补全也正常。
常见问题四:异步 composable 不处理 loading 和 error
问题代码
// 没有 loading 和 error 状态
export function useUser(userId: string) {
const user = ref(null)
async function fetchUser() {
const res = await api.getUser(userId)
user.value = res
}
fetchUser()
return { user }
}
调用方不知道什么时候在加载、什么时候出错了。
正确写法
interface UseUserOptions {
immediate?: boolean
initialData?: User | null
}
interface UseUserReturn {
user: Ref<User | null>
loading: Ref<boolean>
error: Ref<Error | null>
execute: () => Promise<void>
}
export function useUser(
userId: MaybeRef<string>,
options: UseUserOptions = {}
): UseUserReturn {
const {
immediate = true,
initialData = null,
} = options
const user = ref<User | null>(initialData)
const loading = ref(false)
const error = ref<Error | null>(null)
async function execute() {
loading.value = true
error.value = null
try {
const id = isRef(userId) ? userId.value : userId
const res = await api.getUser(id)
user.value = res
} catch (e) {
error.value = e as Error
} finally {
loading.value = false
}
}
if (immediate) {
execute()
}
return {
user,
loading,
error,
execute,
}
}
使用方式:
<script setup>
import { useUser } from '@/composables/useUser'
const route = useRoute()
const { user, loading, error, execute } = useUser(
computed(() => route.params.id as string)
)
</script>
<template>
<div v-if="loading">加载中...</div>
<div v-else-if="error">
加载失败: {{ error.message }}
<button @click="execute">重试</button>
</div>
<div v-else-if="user">
{{ user.name }}
</div>
</template>
实际项目中的推荐模式
1. 基础状态型 hooks
// composables/useLocalStorage.ts
export function useLocalStorage<T>(key: string, defaultValue: T) {
const stored = localStorage.getItem(key)
const initial = stored ? JSON.parse(stored) : defaultValue
const data = ref(initial) as Ref<T>
watch(
data,
(newVal) => {
localStorage.setItem(key, JSON.stringify(newVal))
},
{ deep: true }
)
return data
}
// 使用
const theme = useLocalStorage('theme', 'light')
2. 业务数据获取型 hooks
// composables/useOrderList.ts
interface OrderListParams {
keyword?: string
status?: string
page?: number
pageSize?: number
}
interface UseOrderListReturn {
list: Ref<Order[]>
total: Ref<number>
loading: Ref<boolean>
error: Ref<Error | null>
refresh: () => Promise<void>
setParams: (params: Partial<OrderListParams>) => void
}
export function useOrderList(
initialParams: OrderListParams = {}
): UseOrderListReturn {
const params = reactive({ ...initialParams })
const list = ref<Order[]>([])
const total = ref(0)
const loading = ref(false)
const error = ref<Error | null>(null)
async function fetch() {
loading.value = true
error.value = null
try {
const res = await api.getOrders(params)
list.value = res.list
total.value = res.total
} catch (e) {
error.value = e as Error
} finally {
loading.value = false
}
}
function setParams(newParams: Partial<OrderListParams>) {
Object.assign(params, newParams)
fetch()
}
return {
list,
total,
loading,
error,
refresh: fetch,
setParams,
}
}
3. 工具型 hooks
// composables/useDebounce.ts
export function useDebounce<T extends (...args: any[]) => any>(
fn: T,
delay: number
) {
let timer: ReturnType<typeof setTimeout> | null = null
function debounced(...args: Parameters<T>) {
if (timer) clearTimeout(timer)
timer = setTimeout(() => {
fn(...args)
}, delay)
}
function cancel() {
if (timer) {
clearTimeout(timer)
timer = null
}
}
return { debounced, cancel }
}
// 使用
const { debounced } = useDebounce(search, 300)
Hooks 的测试
Hooks 是普通函数,测试起来比组件容易:
// composables/__tests__/useCounter.test.ts
import { describe, it, expect } from 'vitest'
import { useCounter } from '../useCounter'
describe('useCounter', () => {
it('should increment', () => {
const { count, increment } = useCounter()
expect(count.value).toBe(0)
increment()
expect(count.value).toBe(1)
})
it('should accept initial value', () => {
const { count } = useCounter(10)
expect(count.value).toBe(10)
})
})
总结
设计良好的 Hooks 应该具备:
- 类型安全:返回值的类型要完整定义,TypeScript 能完整推导
- 响应式完整:返回的数据保持响应式,解构后不会丢失
- 生命周期正确:异步清理逻辑放在正确的生命周期钩子里
- 状态完整:loading、error 等边界状态要考虑
- 单一职责:一个 Hook 只做一件事,不要把所有逻辑塞进一个 Hook
- 可测试:Hooks 应该是纯函数或者容易 mock 依赖的
Hooks 是 Composition API 最强大的用法之一。用好了,能把逻辑复用做到极致,同时保持代码的清晰和可维护性。
