学完这篇,你能掌握 Vue 3 自定义 Hook 的通用封装套路,并能直接抄走 10 个在项目里高频使用的 Hook 实现。
第一步:先判断什么逻辑值得抽成 Hook
不是所有函数都要变成 Hook。判断标准只有一条:**这段逻辑带响应式状态,或者带需要清理的副作用,并且在两个以上组件里重复出现**。
典型该抽的:监听窗口尺寸、点击外部关闭、防抖搜索、请求加载状态。
不该抽的:纯格式化函数(formatDate)、纯计算(sum)——这些写成普通 util 就行,加个 use 前缀反而误导人。
第二步:守住三条封装约定
- 命名以
use 开头,文件放 src/hooks/useXxx.js,一眼能看出这是组合式函数。
- 返回值统一用对象,不用数组。
const { loading, data, run } = useRequest(...) 比按位置解构好维护,将来加字段不会破坏调用方。
- 副作用必须自己清理。定时器、事件监听、
IntersectionObserver、watch 都要在 onUnmounted 或 onScopeDispose 里收尾。
注意:如果你的 Hook 可能在组件外被调用(比如在 pinia store 里),onUnmounted 不会触发,这时要用 onScopeDispose,或者用 getCurrentScope() 判断当前是否有活跃的 effect scope。
第三步:10 个可直接用的例子
1. useToggle —— 开关状态
import { ref } from 'vue'
export function useToggle(initial = false) {
const state = ref(initial)
const toggle = () => (state.value = !state.value)
const set = (v) => (state.value = v)
return { state, toggle, set }
}
2. useLocalStorage —— 带持久化的 ref
import { ref, watch } from 'vue'
export function useLocalStorage(key, defaultValue) {
const raw = localStorage.getItem(key)
const data = ref(raw ? JSON.parse(raw) : defaultValue)
watch(data, (v) => localStorage.setItem(key, JSON.stringify(v)), { deep: true })
return data
}
注意:JSON.parse 一定要包 try/catch,用户手动改过 localStorage 或存的是旧格式时会直接抛错白屏。
3. useDebounceFn —— 防抖函数(不是值)
import { onUnmounted } from 'vue'
export function useDebounceFn(fn, delay = 300) {
let timer = null
const run = (...args) => {
clearTimeout(timer)
timer = setTimeout(() => fn(...args), delay)
}
const cancel = () => clearTimeout(timer)
onUnmounted(cancel)
return { run, cancel }
}
4. useThrottleFn —— 节流函数
import { onUnmounted } from 'vue'
export function useThrottleFn(fn, interval = 300) {
let last = 0
let timer = null
const run = (...args) => {
const now = Date.now()
const rest = interval - (now - last)
if (rest <= 0) {
last = now
fn(...args)
} else if (!timer) {
timer = setTimeout(() => {
last = Date.now()
timer = null
fn(...args)
}, rest)
}
}
onUnmounted(() => clearTimeout(timer))
return { run }
}
注意:防抖返回"函数"而不是"值",是因为组件里常见场景是绑定到 @input、@scroll 上。如果你要的是"值防抖",再加一层 watch + ref 包一下即可。
5. useRequest —— 加载态、错误态、重试一把梭
import { ref, shallowRef } from 'vue'
export function useRequest(service, { immediate = true } = {}) {
const data = shallowRef(null)
const loading = ref(false)
const error = ref(null)
const run = async (...args) => {
loading.value = true
error.value = null
try {
data.value = await service(...args)
return data.value
} catch (e) {
error.value = e
throw e
} finally {
loading.value = false
}
}
if (immediate) run()
return { data, loading, error, run }
}
6. useEventListener —— 自动解绑的监听
import { onMounted, onUnmounted } from 'vue'
export function useEventListener(target, event, handler) {
onMounted(() => target.addEventListener(event, handler))
onUnmounted(() => target.removeEventListener(event, handler))
}
7. useClickOutside —— 点外面关闭弹层
import { onMounted, onUnmounted } from 'vue'
export function useClickOutside(elRef, callback) {
const listener = (e) => {
if (elRef.value && !elRef.value.contains(e.target)) callback(e)
}
onMounted(() => document.addEventListener('click', listener, true))
onUnmounted(() => document.removeEventListener('click', listener, true))
}
注意:第三个参数 true(捕获阶段)别漏,否则内部元素 @click.stop 会让事件冒泡不到 document,点击面板内部也会被误判成"点外面"。