Vue3项目里总觉得代码乱重复多?怎么封装实用又复用性强的Hooks?
最近帮几个朋友改Vue3的老项目重构,发现一个挺普遍的坑:大家把Composition API用成了「大号setup函数」,全堆在一起不说,同一个功能比如“防抖输入监听搜索框+loading状态”,可能三五个组件都写了一遍,改个防抖时间得去改五处,累得慌还容易错,其实这时候只要用好Vue3的Hooks封装,问题就能迎刃而解,不过Hooks也不是随便写个函数就行的,得踩过不少坑才能摸到门道,今天就结合我自己写和重构的经验,聊聊怎么搞这个事儿。
首先要搞懂:Vue3 Hooks到底是啥?为啥能解决问题?
很多刚转Vue3的同学,可能觉得“Hooks就是把setup里的逻辑抽成函数”——对,但不全对,它更准确的定义应该是“复用有状态/有副作用逻辑的纯JavaScript/TypeScript函数”。 核心是两点:一是复用性,抽出来的东西,换个组件甚至换个项目都能用;二是保留响应式和Vue的生命周期能力,不像普通函数那样只是个计算工具,之前Vue2解决复用的方式是Mixin,但Mixin的问题太明显了:命名冲突不知道哪来的data/methods、来源不透明,重构起来找不到依赖、嵌套Mixin逻辑混乱像套娃,Hooks就完美避开了这些:每个Hooks的状态、方法都是独立的,你显式导入显式使用,来源清清楚楚,想怎么组合就怎么组合。
实用Vue3 Hooks封装的5个核心原则,别瞎封装反添麻烦
刚开始封装Hooks的人,很容易走向两个极端:要么啥都抽,连个简单的数字加减都要写个useCount;要么封装得太死,只能在某一个特定组件里用,换个场景就得重写,要避免这两个问题,得记住这5个原则:
原则1:只抽“跨组件复用的、带状态/副作用的”逻辑
简单说,先判断这个逻辑有没有跨至少两个组件用的可能,再看它有没有涉及Vue的ref/reactive/computed/watch/watchEffect/onMounted/onUnmounted这些东西,如果只是纯计算(比如把时间戳转成YYYY-MM-DD HH:mm:ss),抽成普通的utils函数就行,别硬套Hooks的壳子,反而增加开销。 举个例子:判断用户滚动到底部加载更多,这个功能列表页、搜索页、动态流可能都要用,而且涉及scroll事件监听(副作用)、loading/refreshing/hasMore这些响应式状态,那就适合抽成useInfiniteScroll;但单纯的把用户昵称首字母大写,就扔utils里。
原则2:Hooks的命名一定要有use前缀,语义化要清晰
Vue官方推荐所有Hooks都以use开头,这不是硬性要求,但这是行业约定俗成的规范,能让你一眼就看出这个函数是Hooks,避免和普通函数混淆,命名要尽可能具体,别太宽泛,比如useRequest就比useApi好,useClickOutside就比useDomClick好,语义化越强,别人拿到你的Hooks就越容易用,自己隔段时间看也不会忘。
原则3:向外暴露的东西越少越好,保持封装的“黑盒性”
这个原则很重要!很多人封装Hooks的时候,把内部的所有ref、reactive、中间状态都暴露出去了,导致组件可以随意修改内部状态,逻辑耦合反而更严重,后续想优化Hooks内部实现都不敢动,怕影响外面的组件。 正确的做法是:只暴露组件必须用到的东西,比如useInfiniteScroll只暴露hasMore、loading、startLoadMore、reset这四个状态/方法,内部的滚动监听的移除函数、上次滚动的距离、累计加载的页数这些,都放在Hooks内部,不让外面碰。
原则4:参数要灵活,支持配置化,不要写死
Hooks要复用,参数就得灵活,不能太死,比如useRequest,你不能只支持GET请求,要支持GET/POST/PUT/DELETE所有常见的请求方法;不能只支持手动触发,要支持自动触发(页面加载就请求);不能只支持固定的loading状态,要支持自定义loading的初始值;不能不支持错误处理,要暴露error状态和onError回调。 怎么做到参数灵活呢?可以把参数分成“必传参数”和“可选配置对象”,可选配置对象给默认值,比如useRequest的签名可以写成: function useRequest(fetcher, options = { immediate: false, initialData: null, initialLoading: false, onSuccess: () => {}, onError: () => {} }) { // 内部逻辑 }
原则5:一定要清理副作用,防止内存泄漏
这是很多新手最容易踩的坑!比如useInfiniteScroll里加了scroll事件监听,useLocalStorage里监听了storage事件,useTimeout里开了定时器,如果组件销毁的时候不清理这些副作用,就会造成内存泄漏,页面用久了会越来越卡,甚至崩溃。 Vue3提供了onUnmounted生命周期钩子,专门用来清理组件销毁前的副作用,而且这个钩子在Hooks里用效果最好——因为副作用是在Hooks里注册的,清理逻辑也应该放在Hooks里,保持逻辑的一致性,比如useTimeout里的清理逻辑: let timer = null onUnmounted(() => { if (timer) clearTimeout(timer) })
从零封装2个高频实用的Vue3 Hooks,看完就能用
光说原则太干了,现在我们来从零封装两个我平时用得最多的Hooks:一个是处理本地存储的useLocalStorage,一个是处理防抖/节流输入的useDebouncedRef/useThrottledRef,这两个Hooks几乎是每个Vue3项目都会用到的,你可以直接抄过去改改就能用。
H3:第一个:支持响应式、JSON序列化的useLocalStorage
普通的localStorage是同步的,而且只能存字符串,每次用都要JSON.parse、JSON.stringify,还要处理解析错误,很麻烦,我们封装的useLocalStorage,要支持直接存对象/数组,自动JSON序列化和反序列化,而且存储的值是响应式的,修改值会自动同步到localStorage,localStorage的值被其他标签页修改了,也会自动同步到组件里。
// src/hooks/useLocalStorage.ts
import { ref, watch, onUnmounted } from 'vue'
// 可选配置的类型定义,用TypeScript的话更安全,不用的话可以删掉
interface UseLocalStorageOptions<T> {
defaultValue?: T
serializer?: (value: T) => string
deserializer?: (value: string) => T
}
export function useLocalStorage<T = any>(
key: string,
options: UseLocalStorageOptions<T> = {}
) {
// 解构配置,给默认值
const {
defaultValue = null,
serializer = JSON.stringify,
deserializer = JSON.parse
} = options
// 初始化响应式数据
const storedValue = ref<T | null>(null)
// 从localStorage读取初始值,处理解析错误
const init = () => {
try {
const rawValue = localStorage.getItem(key)
storedValue.value = rawValue ? deserializer(rawValue) : defaultValue
} catch (error) {
console.warn(`Failed to parse localStorage value for key "${key}":`, error)
storedValue.value = defaultValue
}
}
// 监听存储的值变化,自动同步到localStorage
const stopWatch = watch(
storedValue,
(newValue) => {
if (newValue === null) {
localStorage.removeItem(key)
} else {
try {
localStorage.setItem(key, serializer(newValue))
} catch (error) {
console.warn(`Failed to set localStorage value for key "${key}":`, error)
}
}
},
{ deep: true } // 监听对象/数组的深层变化
)
// 监听其他标签页修改localStorage的事件,自动同步到组件
const handleStorageChange = (event: StorageEvent) => {
if (event.key === key && event.newValue !== null) {
try {
storedValue.value = deserializer(event.newValue)
} catch (error) {
console.warn(`Failed to parse updated localStorage value for key "${key}":`, error)
}
} else if (event.key === key && event.newValue === null) {
storedValue.value = defaultValue
}
}
window.addEventListener('storage', handleStorageChange)
// 组件销毁时清理副作用
onUnmounted(() => {
stopWatch()
window.removeEventListener('storage', handleStorageChange)
})
// 初始化数据
init()
// 只暴露响应式值,其他都封装在内部
return storedValue
}
H3:第二个:通用的useDebouncedRef和useThrottledRef
防抖和节流是前端最常用的性能优化手段,比如输入框搜索用防抖,滚动加载用节流,窗口大小变化用节流,我们封装的useDebouncedRef和useThrottledRef,是基于ref的,传入一个ref值和延迟时间,返回一个新的响应式ref值,只有当原始值在延迟时间内不再变化(防抖)或者每隔延迟时间(节流),新值才会更新。
// src/hooks/useDebounceThrottle.ts
import { ref, watch, onUnmounted } from 'vue'
// 防抖ref
export function useDebouncedRef<T = any>(
value: T,
delay: number = 300
) {
const debouncedValue = ref<T>(value)
let timer: ReturnType<typeof setTimeout> | null = null
const stopWatch = watch(
ref(value), // 注意这里如果传入的是普通值,要先转成ref;如果传入的已经是ref,直接用就行
(newValue) => {
if (timer) clearTimeout(timer)
timer = setTimeout(() => {
debouncedValue.value = newValue
}, delay)
},
{ deep: true }
)
onUnmounted(() => {
stopWatch()
if (timer) clearTimeout(timer)
})
return debouncedValue
}
// 不过上面的useDebouncedRef有个小问题:如果传入的是普通值,就不会响应变化,更好的做法是支持传入ref或者普通值:
export function useBetterDebouncedRef<T = any>(
source: T | Ref<T>,
delay: number = 300
) {
const debouncedValue = ref<T>(unref(source))
let timer: ReturnType<typeof setTimeout> | null = null
const stopWatch = watch(
() => unref(source),
(newValue) => {
if (timer) clearTimeout(timer)
timer = setTimeout(() => {
debouncedValue.value = newValue
}, delay)
},
{ deep: true }
)
onUnmounted(() => {
stopWatch()
if (timer) clearTimeout(timer)
})
return debouncedValue
}
// 节流ref
export function useThrottledRef<T = any>(
source: T | Ref<T>,
delay: number = 300
) {
const throttledValue = ref<T>(unref(source))
let lastTime = 0
let timer: ReturnType<typeof setTimeout> | null = null
const stopWatch = watch(
() => unref(source),
(newValue) => {
const now = Date.now()
if (now - lastTime >= delay) {
throttledValue.value = newValue
lastTime = now
} else {
if (timer) clearTimeout(timer)
timer = setTimeout(() => {
throttledValue.value = newValue
lastTime = Date.now()
}, delay - (now - lastTime))
}
},
{ deep: true }
)
onUnmounted(() => {
stopWatch()
if (timer) clearTimeout(timer)
})
return throttledValue
}
怎么测试自己封装的Hooks好不好用?
封装完Hooks之后,别着急用到项目里,先做个简单的测试,看看有没有问题:
- 复用性测试:把Hooks用到至少两个不同的组件里,看看能不能正常工作;
- 黑盒性测试:只暴露组件必须的东西,修改Hooks内部实现,看看会不会影响外面的组件;
- 灵活性测试:修改可选配置,看看能不能达到预期的效果;
- 内存泄漏测试:打开浏览器的开发者工具,切换到Memory标签页,点击“Take heap snapshot”,然后销毁使用了Hooks的组件,再点击“Take heap snapshot”,对比两个快照,看看有没有多余的内存占用;
- 边界情况测试:比如useLocalStorage测试JSON解析错误的情况,useBetterDebouncedRef测试传入普通值和ref值的情况,测试延迟时间为0的情况,测试传入undefined/null的情况。
最后给新手的几个小建议
- 先模仿,后创新:刚开始封装Hooks的时候,可以先看看VueUse这个库(这个库是目前最流行、最全面的Vue3 Hooks库,有超过200个实用的Hooks),看看别人是怎么封装的,然后再根据自己的项目需求,封装自己的Hooks;
- 用TypeScript:TypeScript能给Hooks提供类型检查,减少运行时错误,而且语义化更强,别人拿到你的Hooks就知道怎么用;
- 写注释:给Hooks写清楚功能说明、参数说明、返回值说明、注意事项,这样自己隔段时间看也不会忘,别人拿到你的Hooks也能快速上手;
- 定期重构Hooks:随着项目的发展,你的需求可能会变化,Hooks的实现也可能会过时,所以要定期重构Hooks,优化性能,提高灵活性。
Vue3 Hooks封装是一个需要不断实践、不断踩坑、不断优化的过程,只要你记住这5个核心原则,多模仿、多测试、多总结,你就能封装出实用又复用性强的Hooks,让你的Vue3项目代码更清晰、更易维护、更易扩展。
版权声明
本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。
code前端网


