Vue3组合式API(Composition API)为前端开发提供了更灵活的逻辑组织和复用方案。通过自定义Hook函数(Composables),开发者可以将组件中的响应式状态、计算属性和副作用逻辑抽离为独立模块,实现跨组件复用。本文围绕Vue3.4+版本,系统讲解自定义Hook的封装模式、响应式数据管理策略以及生产环境的最佳实践。
组合式API核心响应式原理
Vue3的响应式系统基于Proxy实现,通过reactive、ref、computed三个核心API构建响应式数据流。理解响应式原理是编写高质量Hook函数的前提。
import { ref, reactive, computed, watch } from 'vue'
// ref:基本类型响应式
const count = ref(0)
count.value++ // 通过.value访问和修改
// reactive:对象类型响应式
const state = reactive({
user: { name: '张三', age: 25 },
list: []
})
state.user.name = '李四' // 直接修改
// computed:派生响应式
const doubleCount = computed(() => count.value * 2)
// watch:响应式副作用
watch(count, (newVal, oldVal) => {
console.log(`count: ${oldVal} -> ${newVal}`)
})
自定义Hook函数封装模式
自定义Hook本质上是一个以”use”为前缀的函数,内部使用组合式API封装逻辑,返回响应式状态和操作方法。标准封装模式如下:
// hooks/useCounter.ts
import { ref, computed } from 'vue'
export function useCounter(initialValue: number = 0, step: number = 1) {
const count = ref(initialValue)
const double = computed(() => count.value * 2)
function increment() {
count.value += step
}
function decrement() {
count.value -= step
}
function reset() {
count.value = initialValue
}
return {
count,
double,
increment,
decrement,
reset
}
}
在组件中使用:
<script setup lang="ts">
import { useCounter } from '@/hooks/useCounter'
const { count, double, increment, decrement } = useCounter(0, 2)
</script>
<template>
<div>
<p>Count: {{ count }} | Double: {{ double }}</p>
<button @click="increment">+</button>
<button @click="decrement">-</button>
</div>
</template>
异步数据请求Hook封装
数据请求是前端开发中最高频的场景。封装通用的useFetch Hook,统一处理loading、error和data状态:
// hooks/useFetch.ts
import { ref, watchEffect, isRef, unref } from 'vue'
interface UseFetchOptions<T> {
immediate?: boolean
initialData?: T
refetch?: boolean
}
export function useFetch<T>(
url: string | Ref<string>,
options: UseFetchOptions<T> = {}
) {
const {
immediate = true,
initialData = null as T,
refetch = false
} = options
const data = ref<T>(initialData)
const error = ref<string | null>(null)
const loading = ref(false)
async function execute() {
loading.value = true
error.value = null
try {
const response = await fetch(unref(url), {
headers: { 'Content-Type': 'application/json' }
})
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
data.value = await response.json()
} catch (err) {
error.value = err instanceof Error ? err.message : String(err)
} finally {
loading.value = false
}
}
if (immediate) execute()
if (refetch && isRef(url)) {
watchEffect(() => execute())
}
return { data, error, loading, execute, refresh: execute }
}
表单验证Hook封装
表单验证是另一个高频场景。封装响应式表单验证Hook,支持实时校验和提交校验:
// hooks/useForm.ts
import { reactive, computed } from 'vue'
type Validator = (value: any) => string | true
export function useForm<T extends Record<string, any>>(
initialValues: T,
validators: Partial<Record<keyof T, Validator[]>>
) {
const values = reactive({ ...initialValues }) as T
const errors = reactive({} as Record<keyof T, string>)
const touched = reactive({} as Record<keyof T, boolean>)
function validateField(field: keyof T): boolean {
const fieldValidators = validators[field]
if (!fieldValidators) return true
for (const validator of fieldValidators) {
const result = validator(values[field])
if (result !== true) {
errors[field] = result
return false
}
}
errors[field] = ''
return true
}
function validateAll(): boolean {
let valid = true
for (const field in validators) {
if (!validateField(field)) valid = false
}
return valid
}
function touch(field: keyof T) {
touched[field] = true
validateField(field)
}
function reset() {
Object.assign(values, initialValues)
Object.keys(errors).forEach(k => delete errors[k as keyof T])
Object.keys(touched).forEach(k => delete touched[k as keyof T])
}
const isValid = computed(() => {
return Object.keys(validators).every(f => validateField(f as keyof T))
})
return { values, errors, touched, isValid, validateField, validateAll, touch, reset }
}
使用示例:
const { values, errors, validateAll, touch } = useForm(
{ username: '', email: '', password: '' },
{
username: [
(v) => v.length >= 3 || '用户名至少3个字符',
(v) => /^[a-zA-Z0-9_]+$/.test(v) || '用户名只能包含字母数字下划线'
],
email: [
(v) => /^[^@]+@[^@]+\.[^@]+$/.test(v) || '邮箱格式不正确'
],
password: [
(v) => v.length >= 8 || '密码至少8位',
(v) => /[A-Z]/.test(v) || '密码需包含大写字母'
]
}
)
本地存储响应式Hook
将localStorage封装为响应式Hook,数据变化自动同步到浏览器存储:
// hooks/useStorage.ts
import { ref, watch, type Ref } from 'vue'
export function useStorage<T>(
key: string,
defaultValue: T,
storage: Storage = localStorage
): Ref<T> {
const stored = storage.getItem(key)
const data = ref<T>(stored ? JSON.parse(stored) : defaultValue) as Ref<T>
watch(data, (newVal) => {
try {
storage.setItem(key, JSON.stringify(newVal))
} catch (e) {
console.error('Storage write failed:', e)
}
}, { deep: true })
return data
}
防抖节流Hook封装
搜索输入、窗口resize等高频事件场景需要防抖/节流处理:
// hooks/useDebounce.ts
import { ref, watch, onUnmounted, type Ref } from 'vue'
export function useDebounce<T>(source: Ref<T>, delay: number = 300): Ref<T> {
const debounced = ref(source.value) as Ref<T>
let timer: ReturnType<typeof setTimeout>
watch(source, (newVal) => {
clearTimeout(timer)
timer = setTimeout(() => {
debounced.value = newVal
}, delay)
})
onUnmounted(() => clearTimeout(timer))
return debounced
}
// 使用
const keyword = ref('')
const debouncedKeyword = useDebounce(keyword, 500)
watch(debouncedKeyword, (val) => {
if (val) searchAPI(val)
})
组件生命周期与Hook注意事项
自定义Hook中使用的onMounted、onUnmounted等生命周期钩子,必须在setup函数或组件编译过程中同步调用。不能在异步回调中调用生命周期钩子。
正确用法:
// hooks/useEventListener.ts
import { onMounted, onUnmounted } from 'vue'
export function useEventListener(
target: EventTarget,
event: string,
callback: (e: Event) => void
) {
onMounted(() => target.addEventListener(event, callback))
onUnmounted(() => target.removeEventListener(event, callback))
}
错误用法(在异步函数中调用生命周期钩子):
// 错误示范
export function useWrongHook() {
setTimeout(() => {
onMounted(() => {}) // 报错:onMounted is called when there is no active component instance
}, 100)
}
TypeScript类型安全增强
生产环境建议为所有Hook函数添加完整的TypeScript类型定义。使用泛型约束确保类型推导的准确性:
// hooks/usePagination.ts
import { ref, computed } from 'vue'
interface PaginationOptions {
pageSize: number
total: number
initialPage?: number
}
export function usePagination(options: PaginationOptions) {
const currentPage = ref(options.initialPage ?? 1)
const pageSize = ref(options.pageSize)
const total = ref(options.total)
const totalPages = computed(() =>
Math.ceil(total.value / pageSize.value)
)
const hasNext = computed(() => currentPage.value < totalPages.value)
const hasPrev = computed(() => currentPage.value > 1)
const range = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
const end = Math.min(start + pageSize.value, total.value)
return { start, end }
})
function goTo(page: number) {
currentPage.value = Math.max(1, Math.min(page, totalPages.value))
}
function next() { goTo(currentPage.value + 1) }
function prev() { goTo(currentPage.value - 1) }
return { currentPage, pageSize, total, totalPages, hasNext, hasPrev, range, goTo, next, prev }
}
Hook函数组织与目录结构
推荐的项目Hook目录结构:
src/
├── hooks/
│ ├── useCounter.ts # 计数器
│ ├── useFetch.ts # 数据请求
│ ├── useForm.ts # 表单验证
│ ├── useStorage.ts # 本地存储
│ ├── useDebounce.ts # 防抖
│ ├── useEventListener.ts # 事件监听
│ ├── usePagination.ts # 分页
│ └── useTheme.ts # 主题切换
├── components/
└── views/
每个Hook文件保持单一职责,文件名以”use”开头,导出同名函数。跨项目复用的Hook可以发布为独立的npm包,按功能域划分(如@hooks/use-form、@hooks/use-fetch)。
原创文章,作者:小编,如若转载,请注明出处:https://www.yunthe.com/vue3-zu-he-shi-api-shi-zhan-zi-ding-yi-hook-han-shu-feng/