说实话,用 Vue3 组合式 API 写了半年项目之后,最大的感受就是:同样的逻辑,别再复制粘贴了。
我记得刚切换到 Vue3 那会儿,还是带着 Vue2 的思维习惯——一个页面里 data、methods、computed、watch 各占一块,功能相关的代码散落在各个角落。改一个功能得在四个选项之间跳来跳去,页面稍微复杂一点,一个文件就奔着五六百行去了-12。
后来慢慢悟了:组合式 API 最大的价值,就是把逻辑按“功能”而不是“类型”来组织。今天咱们就来聊聊,怎么通过自定义 Hooks 把重复的业务逻辑抽出来,让代码清爽起来。
1. 从 Options API 到 Composition API:到底变了什么?
先来看一个典型的场景:一个页面需要跟踪鼠标位置,同时还有一个计数器。
在 Vue2 的 Options API 里,代码可能是这样的:
export default { data() { return { count: 0, x: 0, y: 0 }; }, mounted() { window.addEventListener('mousemove', this.updateMouse); }, beforeUnmount() { window.removeEventListener('mousemove', this.updateMouse); }, methods: { increment() { this.count++; }, updateMouse(e) { this.x = e.pageX; this.y = e.pageY; } } };两个完全不相关的功能——计数和鼠标追踪——硬生生挤在同一个对象里。如果页面再复杂一点,还有搜索、分页、表单校验……那画面太美我不敢看-3。
Vue3 的组合式 API 允许我们把每个功能封装成一个独立的函数,然后在setup里按需组合:
<script setup> import { useCounter } from '@/composables/useCounter'; import { useMouse } from '@/composables/useMouse'; const { count, increment } = useCounter(); const { x, y } = useMouse(); </script>三行代码,功能一目了然。这就是自定义 Hooks 的魅力。
2. 自定义 Hook 到底是什么?怎么命名?
说白了,自定义 Hook 就是一个函数,它把一组相关的响应式状态和逻辑封装在一起,返回给组件使用-1-8。
有几个约定俗成的规矩:
文件名以
use开头:比如useCounter、useFetch、useLocalStorage返回一个对象或数组:包含响应式数据和操作方法
内部可以使用 Vue 的响应式 API 和生命周期钩子
来看一个最经典的例子——封装一个鼠标追踪器:
// composables/useMouse.js import { ref, onMounted, onUnmounted } from 'vue'; export function useMouse() { const x = ref(0); const y = ref(0); const update = (e) => { x.value = e.pageX; y.value = e.pageY; }; onMounted(() => { window.addEventListener('mousemove', update); }); onUnmounted(() => { window.removeEventListener('mousemove', update); }); return { x, y }; }组件里直接用:
<template> <p>鼠标位置:{{ x }}, {{ y }}</p> </template> <script setup> import { useMouse } from '@/composables/useMouse'; const { x, y } = useMouse(); </script>干净利落,鼠标相关的所有逻辑都在 Hook 里,组件只负责展示-2-9。
3. 实战场景一:封装通用的数据请求 Hook
项目中调接口是家常便饭,每个组件都写一遍loading、error、data的状态管理,太重复了。抽一个useFetch出来:
// composables/useFetch.ts import { ref } from 'vue'; export function useFetch<T>(url: string) { const data = ref<T | null>(null); const loading = ref(false); const error = ref<Error | null>(null); const fetchData = async () => { loading.value = true; error.value = null; try { const res = await fetch(url); if (!res.ok) throw new Error(`HTTP ${res.status}`); data.value = await res.json(); } catch (err) { error.value = err instanceof Error ? err : new Error('未知错误'); } finally { loading.value = false; } }; // 自动执行一次 fetchData(); return { data, loading, error, refetch: fetchData }; }组件里这样用:
<script setup> import { useFetch } from '@/composables/useFetch'; const { data, loading, error, refetch } = useFetch<User[]>('/api/users'); </script> <template> <div v-if="loading">加载中...</div> <div v-else-if="error">出错了:{{ error.message }}</div> <ul v-else> <li v-for="user in data" :key="user.id">{{ user.name }}</li> </ul> <button @click="refetch">刷新</button> </template>一个 Hook 搞定加载状态、错误处理、数据展示,所有调接口的地方都能复用-1-5。
4. 实战场景二:封装验证码倒计时
验证码倒计时也是高频场景。封装成 Hook 之后,任何需要发送验证码的组件都能直接用:
// composables/useCountDown.js import { ref, onUnmounted } from 'vue'; export function useCountDown() { const count = ref(0); let timer = null; const start = (seconds = 60, onEnd = () => {}) => { if (count.value > 0) return; count.value = seconds; timer = setInterval(() => { count.value--; if (count.value <= 0) { clearInterval(timer); timer = null; onEnd(); } }, 1000); }; onUnmounted(() => { if (timer) clearInterval(timer); }); return { count, start }; }组件里用起来就一行:
<script setup> import { useCountDown } from '@/composables/useCountDown'; const { count, start } = useCountDown(); const sendCode = () => { // 调发送验证码接口 start(60, () => console.log('倒计时结束')); }; </script> <template> <button :disabled="count > 0" @click="sendCode"> {{ count > 0 ? `${count}s后重试` : '发送验证码' }} </button> </template>这个 Hook 封装了定时器的创建、清除、倒计时状态,组件只需要关心“什么时候开始”和“结束后做什么”-7。
5. 进阶技巧:组合多个 Hook
Vue3 的 Hook 支持互相组合,这是它比 Vue2 mixins 强大的地方。比如你想做一个“鼠标是否在某个元素内”的判断,可以把useMouse和useElementBounding组合起来:
// composables/useMouseInElement.js import { computed, ref } from 'vue'; import { useMouse } from './useMouse'; export function useMouseInElement(elementRef) { const { x, y } = useMouse(); const isInside = computed(() => { if (!elementRef.value) return false; const rect = elementRef.value.getBoundingClientRect(); return x.value >= rect.left && x.value <= rect.right && y.value >= rect.top && y.value <= rect.bottom; }); return { x, y, isInside }; }这种“搭积木”式的组合,让代码复用达到了一个新的高度-6。
6. 封装 Hooks 的几个原则
踩了一些坑之后,我总结了几条实用的原则:
① 单一职责:一个 Hook 只做一件事。useFetch只管请求,useCounter只管计数,别揉在一起-12。
② 返回 readonly 状态:如果数据不需要外部直接修改,用readonly包一层,防止组件内部意外篡改-6:
import { readonly } from 'vue'; return { data: readonly(data), loading, error };③ 参数用对象形式:当 Hook 的参数超过 2 个时,用对象传参,比位置参数好维护得多:
// 不好 useFetch('/api/users', 'POST', { token: 'xxx' }, 5000); // 好 useFetch('/api/users', { method: 'POST', headers: { token: 'xxx' }, timeout: 5000 });④ 清理副作用:在onUnmounted里清理定时器、事件监听、请求取消等,防止内存泄漏-2。
7. 和 Mixins 相比,好在哪里?
用过 Vue2 mixins 的同学都知道,多个 mixin 之间的命名冲突、依赖不清晰、来源不明,调试起来简直是噩梦。
Hook 是纯粹的 JavaScript 函数,参数和返回值都是显式的,IDE 能自动提示,类型推导也友好得多-9。而且同一个 Hook 可以在一个组件里用多次,互不干扰:
const counter1 = useCounter(0); const counter2 = useCounter(10); // 两个计数器各自独立
这在 mixins 里是做不到的。
总结一下
自定义 Hooks 的核心思想,就是把“和某功能相关的所有代码”放在一起,而不是把“同类型的代码”放在一起。数据请求、表单校验、鼠标追踪、倒计时、滚动监听……这些业务逻辑一旦抽成 Hook,就能在多个组件间无缝复用,页面代码量肉眼可见地减少。
刚开始抽 Hooks 的时候可能会纠结“这要不要抽”,我的建议是:同一个逻辑出现两次以上,就值得抽。不用一步到位,慢慢来,写着写着手感就来了。
如果你也有自己封装过的实用 Hook,欢迎在评论区分享一下思路,互相学习学习。觉得有用的话,点个收藏,下次写组件的时候翻出来看看~