Vue 3 的组合式 API 让逻辑复用变得前所未有的简单,自定义 Hooks 是其中的核心手段。本文整理 10 个高频场景的 Hooks 实现,可直接复制到项目中使用。
一、useRequest:请求封装
封装 loading、error、data 的请求逻辑,支持手动和自动触发。
// composables/useRequest.ts
import { ref, shallowRef } from 'vue'
export function useRequest<T>(fetcher: () => Promise<T>, options?: { immediate?: boolean }) {
const data = shallowRef<T | undefined>()
const loading = ref(false)
const error = ref<Error | null>(null)
const run = async () => {
loading.value = true
error.value = null
try {
data.value = await fetcher()
return data.value
} catch (e) {
error.value = e as Error
throw e
} finally {
loading.value = false
}
}
if (options?.immediate !== false) {
run()
}
return { data, loading, error, run }
}使用示例:
<script setup>
const { data, loading, error, run } = useRequest(() =>
fetch('/api/user').then((r) => r.json())
)
</script>
<template>
<div v-if="loading">加载中...</div>
<div v-else-if="error">{{ error.message }}</div>
<div v-else>{{ data }}</div>
</template>二、useDebounce:防抖
// composables/useDebounce.ts
import { ref, watch } from 'vue'
export function useDebounce<T>(value: () => T, delay = 300) {
const debounced = ref(value()) as { value: T }
let timer: ReturnType<typeof setTimeout>
watch(value, (v) => {
clearTimeout(timer)
timer = setTimeout(() => {
debounced.value = v
}, delay)
}, { immediate: true })
return debounced
}更常用的写法是防抖函数:
export function useDebounceFn<T extends (...args: unknown[]) => unknown>(
fn: T,
delay = 300
) {
let timer: ReturnType<typeof setTimeout>
return (...args: Parameters<T>) => {
clearTimeout(timer)
timer = setTimeout(() => fn(...args), delay)
}
}三、useThrottle:节流
// composables/useThrottle.ts
export function useThrottleFn<T extends (...args: unknown[]) => unknown>(
fn: T,
interval = 300
) {
let last = 0
return (...args: Parameters<T>) => {
const now = Date.now()
if (now - last >= interval) {
last = now
fn(...args)
}
}
}四、useLocalStorage:持久化存储
// composables/useLocalStorage.ts
import { ref, watch } from 'vue'
export function useLocalStorage<T>(key: string, defaultValue: T) {
const data = ref<T>(
(() => {
try {
const raw = localStorage.getItem(key)
return raw ? JSON.parse(raw) : defaultValue
} catch {
return defaultValue
}
})()
) as { value: T }
watch(data, (v) => {
localStorage.setItem(key, JSON.stringify(v))
}, { deep: true })
return data
}五、useClickOutside:点击外部关闭
// composables/useClickOutside.ts
import { onMounted, onUnmounted } from 'vue'
export function useClickOutside(
target: () => HTMLElement | null,
callback: () => void
) {
const handler = (e: MouseEvent) => {
const el = target()
if (el && !el.contains(e.target as Node)) {
callback()
}
}
onMounted(() => document.addEventListener('click', handler))
onUnmounted(() => document.removeEventListener('click', handler))
}<script setup>
const dropdownRef = ref(null)
useClickOutside(() => dropdownRef.value, () => (showMenu.value = false))
</script>六、useInfiniteScroll:无限滚动
// composables/useInfiniteScroll.ts
import { ref, onMounted, onUnmounted } from 'vue'
export function useInfiniteScroll(
loadMore: () => Promise<void>,
options?: { root?: Element | null; threshold?: number }
) {
const loading = ref(false)
const sentinel = ref<HTMLElement | null>(null)
const observer = new IntersectionObserver(
async (entries) => {
if (loading.value || !entries[0]?.isIntersecting) return
loading.value = true
try {
await loadMore()
} finally {
loading.value = false
}
},
{ root: options?.root ?? null, threshold: options?.threshold ?? 0 }
)
onMounted(() => {
if (sentinel.value) observer.observe(sentinel.value)
})
onUnmounted(() => observer.disconnect())
return { sentinel, loading }
}七、useClipboard:剪贴板
// composables/useClipboard.ts
import { ref } from 'vue'
export function useClipboard() {
const copied = ref(false)
const copy = async (text: string) => {
try {
await navigator.clipboard.writeText(text)
copied.value = true
setTimeout(() => (copied.value = false), 2000)
return true
} catch {
return false
}
}
return { copied, copy }
}八、useDarkMode:暗色模式
// composables/useDarkMode.ts
import { ref, watch } from 'vue'
export function useDarkMode() {
const isDark = ref(
typeof document !== 'undefined' &&
document.documentElement.classList.contains('dark')
)
watch(isDark, (v) => {
document.documentElement.classList.toggle('dark', v)
localStorage.setItem('theme', v ? 'dark' : 'light')
}, { immediate: true })
const toggle = () => (isDark.value = !isDark.value)
return { isDark, toggle }
}九、useCounter:计数器
// composables/useCounter.ts
import { ref, computed } from 'vue'
export function useCounter(initial = 0, options?: { min?: number; max?: number }) {
const count = ref(initial)
const inc = () => {
if (options?.max !== undefined && count.value >= options.max) return
count.value++
}
const dec = () => {
if (options?.min !== undefined && count.value <= options.min) return
count.value--
}
const reset = () => (count.value = initial)
return {
count: computed(() => count.value),
inc,
dec,
reset,
}
}十、useForm:表单校验封装
// composables/useForm.ts
import { reactive, toRefs } from 'vue'
export function useForm<T extends Record<string, unknown>>(
initial: T,
validate?: (values: T) => Record<keyof T, string | undefined>
) {
const form = reactive({ ...initial }) as T
const errors = reactive({} as Record<keyof T, string>) as Record<keyof T, string>
const validateForm = () => {
if (!validate) return true
const res = validate(form)
let valid = true
for (const k of Object.keys(res) as (keyof T)[]) {
errors[k] = res[k] ?? ''
if (res[k]) valid = false
}
return valid
}
const reset = () => {
Object.assign(form, initial)
Object.keys(errors).forEach((k) => (errors[k as keyof T] = ''))
}
return { ...toRefs(form), errors, validateForm, reset }
}总结
自定义 Hooks 的核心是单一职责和可组合。把业务逻辑从组件中抽离,代码更清晰、复用性更强。