人人都会AI编程

11.2 通用业务 Hooks 封装示例:防抖节流、分页、表单、权限

更新时间:2026-07-09

自定义 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 里,组件不需要再维护额外的 pagepageSize 等变量。


表单

中后台表单通常包含数据绑定、校验规则、提交状态(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>

当需要批量判断时,也可以扩展一个 hasAnyPermissionhasAllPermissions 方法:

const hasAnyPermission = (...perms: string[]) => perms.some(p => store.permissions.includes(p))

封装后,权限逻辑的变更只集中在 Hook 和 Store 中,组件里面只是简单的函数调用,代码清晰且易于维护。


小结

以上四个封装都遵循同一种模式:把状态、逻辑、副作用收拢在一个函数里,对外暴露最小接口。项目里还可以继续扩展如 useRequest(数据请求/loading/错误)、useModal(对话框显隐/数据传递) 等。这些 Hooks 让组件回归“组装”角色,大幅提升代码的可复用性和可测试性。