自定义 Hooks 的价值在于把反复出现的逻辑抽离成可复用的函数,让组件代码专注于视图和业务拼装。下面给出四个最常见的业务场景封装,你可以直接参考或稍作修改后用在项目里。
防抖与节流
在搜索框输入、窗口 resize、按钮高频点击等场景,防抖和节流是最基础的性能优化手段。把它们封装成 hooks,既能少写重复代码,又能保证行为一致。
// useDebounce.ts
import { ref, watch } from 'vue'
export function useDebounce<T>(source: Ref<T>, delay = 300) {
const debounced = ref(source.value) as Ref<T>
let timer: ReturnType<typeof setTimeout> | null = null
watch(source, (val) => {
if (timer) clearTimeout(timer)
timer = setTimeout(() => {
debounced.value = val
}, delay)
})
return debounced
}
// useThrottle.ts
import { ref } from 'vue'
export function useThrottle(fn: (...args: any[]) => void, interval = 300) {
let lastTime = 0
return (...args: any[]) => {
const now = Date.now()
if (now - lastTime >= interval) {
fn(...args)
lastTime = now
}
}
}
使用示例(结合响应式数据):
<script setup>
import { ref } from 'vue'
import { useDebounce } from './useDebounce'
const keyword = ref('')
const debouncedKeyword = useDebounce(keyword, 500)
watch(debouncedKeyword, (val) => {
// 仅当用户停止输入 500ms 后才触发搜索
fetchSearchResult(val)
})
</script>
节流更常用于事件监听,可以直接在模板里绑定返回的函数。
分页
列表页的分页逻辑几乎每个项目都会遇到:当前页码、每页条数、总条数、翻页方法等。一个通用的 usePagination 可以让你告别重复的页码计算。
// usePagination.ts
import { reactive, computed } from 'vue'
interface PaginationOptions {
pageSize?: number
}
export function usePagination(options: PaginationOptions = {}) {
const state = reactive({
current: 1,
size: options.pageSize || 10,
total: 0
})
const totalPages = computed(() => Math.ceil(state.total / state.size))
function setPage(page: number) {
if (page >= 1 && page <= totalPages.value) {
state.current = page
}
}
function setSize(size: number) {
state.size = size
state.current = 1 // 每页条数改变时重置到第一页
}
function setTotal(total: number) {
state.total = total
// 可选:如果当前页码超出总页数,重置为最后一页
if (state.current > Math.ceil(total / state.size)) {
state.current = Math.ceil(total / state.size) || 1
}
}
return {
current: computed(() => state.current),
size: computed(() => state.size),
total: computed(() => state.total),
totalPages,
setPage,
setSize,
setTotal
}
}
在实际组件中:
<script setup>
import { usePagination } from './usePagination'
import { fetchTableData } from '@/api'
const pagination = usePagination({ pageSize: 20 })
async function loadData() {
const res = await fetchTableData({
page: pagination.current.value,
size: pagination.size.value
})
tableData.value = res.list
pagination.setTotal(res.total)
}
watch([pagination.current, pagination.size], loadData, { immediate: true })
</script>
这样分页相关的状态和操作都内聚在一个 Hook 里,组件不需要再维护额外的 page、pageSize 等变量。
表单
中后台表单通常包含数据绑定、校验规则、提交状态(loading)、重置等能力。配合第三方校验库(如 VeeValidate)可以更灵活,但一个轻量的 useForm 足够覆盖大部分场景。
// useForm.ts
import { reactive, ref } from 'vue'
interface FormOptions<T> {
initialValues: T
validate?: (values: T) => Record<string, string> | null
onSubmit: (values: T) => Promise<void> | void
}
export function useForm<T extends Record<string, any>>(options: FormOptions<T>) {
const values = reactive({ ...options.initialValues }) as T
const errors = reactive<Record<string, string>>({})
const submitting = ref(false)
function reset() {
Object.assign(values, options.initialValues)
Object.keys(errors).forEach(key => delete errors[key])
}
async function submit() {
if (options.validate) {
const validation = options.validate(values)
if (validation) {
Object.assign(errors, validation)
return
}
}
// 清空之前的错误
Object.keys(errors).forEach(key => delete errors[key])
submitting.value = true
try {
await options.onSubmit(values)
} finally {
submitting.value = false
}
}
return { values, errors, submitting, reset, submit }
}
使用示例:
<script setup>
import { useForm } from './useForm'
const { values, errors, submitting, submit, reset } = useForm({
initialValues: { username: '', password: '' },
validate: (vals) => {
const errs: any = {}
if (!vals.username) errs.username = '用户名不能为空'
if (vals.password.length < 6) errs.password = '密码至少6位'
return Object.keys(errs).length ? errs : null
},
onSubmit: async (vals) => {
await loginApi(vals)
}
})
</script>
<template>
<form @submit.prevent="submit">
<input v-model="values.username" />
<span>{{ errors.username }}</span>
<input v-model="values.password" type="password" />
<span>{{ errors.password }}</span>
<button :disabled="submitting">登录</button>
</form>
</template>
这样页面组件里没有散落的状态和校验逻辑,表单行为高度内聚。
权限
权限控制最常见的形式是“根据用户角色/权限点判断某个按钮或菜单是否展示”。我们可以基于 Pinia 存储的用户权限,封装一个 usePermission。
// usePermission.ts
import { computed } from 'vue'
import { useUserStore } from '@/stores/user'
export function usePermission() {
const store = useUserStore()
// 假设 store 中有 permissions: string[] 和 roles: string[]
const hasPermission = computed(() => {
return (perm: string) => store.permissions.includes(perm)
})
const hasRole = computed(() => {
return (role: string) => store.roles.includes(role)
})
return { hasPermission, hasRole }
}
如果权限判断逻辑更复杂(如通配符、多条件),可以在 Hook 内实现。
组件中使用:
<script setup>
import { usePermission } from '@/hooks/usePermission'
const { hasPermission } = usePermission()
</script>
<template>
<button v-if="hasPermission('user:delete')">删除用户</button>
</template>
当需要批量判断时,也可以扩展一个 hasAnyPermission 或 hasAllPermissions 方法:
const hasAnyPermission = (...perms: string[]) => perms.some(p => store.permissions.includes(p))
封装后,权限逻辑的变更只集中在 Hook 和 Store 中,组件里面只是简单的函数调用,代码清晰且易于维护。
小结
以上四个封装都遵循同一种模式:把状态、逻辑、副作用收拢在一个函数里,对外暴露最小接口。项目里还可以继续扩展如 useRequest(数据请求/loading/错误)、useModal(对话框显隐/数据传递) 等。这些 Hooks 让组件回归“组装”角色,大幅提升代码的可复用性和可测试性。