Vue 3 的组合式 API 除了提供 ref、computed、watch 等基础工具外,最重要的能力就是将逻辑封装成可复用的函数,这类函数通常被称为“组合式函数”(Composables),也就是社区常说的“自定义 Hooks”。
一个设计良好的自定义 Hook 能让多个组件共享同一段业务逻辑,避免重复代码,同时保持组件的清晰和可测试。但随意封装也会带来“过度抽象”和“难以维护”的问题,所以需要遵循一些务实的原则和命名约定。
设计原则
1. 单一职责:一个 Hook 只做一件事
每个自定义 Hook 应该围绕一个明确的功能点展开,比如处理表单校验、管理分页状态、封装鼠标位置追踪。不要试图把多个不相关的逻辑塞进同一个 Hook,否则它会变成一个“杂物间”,时间一长谁都看不懂。
// ✅ 好的设计:职责单一
export function useMouse() {
const x = ref(0);
const y = ref(0);
// ... 跟踪鼠标位置
return { x, y };
}
// ❌ 坏的设计:把不相关的逻辑混在一起
export function useDashboard() {
const mouse = useMouse();
const users = useFetch('/api/users');
const form = useForm();
// ... 杂糅了鼠标、请求、表单,抽象层级混乱
return { mouse, users, form };
}
单一职责的 Hook 更容易组合:你可以在组件里按需引入多个局部的 Hook,而不是依赖一个巨大且耦合的“全家桶”。
2. 输入与输出清晰:像纯函数一样思考
虽然 Hook 内部通常包含副作用(如事件监听、数据请求),但它的对外接口应该尽可能“干净”:
- 参数:明确需要的配置项,尽量使用对象形式(options),避免参数位置记忆负担。
- 返回值:只暴露组件真正需要的数据和方法,不要把不需要的内部状态泄露出去。如果返回的值很多,考虑拆分成更小的 Hook。
// 使用 options 对象,调用意图更清晰
function usePagination({ pageSize = 10, total = 0 } = {}) {
const currentPage = ref(1);
const totalPages = computed(() => Math.ceil(total / pageSize));
// ...
return { currentPage, totalPages, nextPage, prevPage };
}
避免在 Hook 外部直接修改内部的 ref,而是提供明确的方法来改变状态,这样可以保持状态变更的可控性。
3. 避免过早抽象,保持真实
不是所有 ref 和 computed 的组合都必须封装成 Hook。有一个很实用的判断标准:当同一个逻辑至少在两个不同的组件中被复制粘贴时,再考虑抽离;如果只在一个地方用,那只管写在组件里。因为过早抽象会增加理解和修改的成本,你无法预测未来的复用模式。
4. 副作用要可清理
自定义 Hook 中如果注册了全局事件监听、定时器或者创建了某些外部资源,必须在合适的时机进行清理。Vue 的组合式 API 提供了 onBeforeUnmount、onDeactivated(配合 keep-alive)等生命周期钩子来执行清理工作。
export function useEventListener(target, event, callback) {
onMounted(() => target.addEventListener(event, callback));
onBeforeUnmount(() => target.removeEventListener(event, callback));
}
忘记清理是“内存泄漏”的头号元凶,必须养成习惯。
5. 响应式依赖要保持连接
自定义 Hook 返回的响应式数据(ref、reactive、computed)应该保持“活”的引用,而不是在返回前解构为普通值。这样组件才能继续追踪依赖,保持响应式更新。
// ✅ 正确:返回 ref 本身
export function useCounter() {
const count = ref(0);
const increment = () => { count.value++; };
return { count, increment };
}
// ❌ 错误:返回 .value,变成了普通值,失去了响应式
export function useBrokenCounter() {
const count = ref(0);
return count.value; // 只是一个静态的 0
}
命名规范
1. 必须使用 use 前缀
Vue 社区约定,所有组合式函数的文件名和函数名都以 use 开头。这样做的好处是:
- 一眼可分辨:
useMouse、useFetch显然是组合式函数,而formatDate、validateEmail则是普通工具函数。 - 工具支持:ESLint 插件和 IDE 能够识别这类函数,并正确应用组合式 API 的规则(比如必须放在
setup顶层、不能在条件内调用等)。
| 类型 | 命名示例 | 用途 |
|------|----------|------|
| 状态管理 | useUserStore、useCart | 全局或局部状态 |
| 数据请求 | useFetchUsers、useDebouncedRequest | 接口封装 |
| 浏览器能力 | useWindowSize、useLocalStorage、useEventListener | 操作外部 API |
| 业务逻辑 | useFormValidation、usePagination、useAuth | 具体业务抽象 |
2. 文件命名与函数名保持一致
文件名也应遵循 useXxx.js(或 .ts)的格式。例如:
src/composables/
useMouse.js
useFetch.js
useAuth.js
如果项目中有大量 hooks,可以按功能分目录:
src/composables/
core/ # 底层通用 hooks(浏览器 API、工具函数)
useEventListener.js
useLocalStorage.js
business/ # 业务相关 hooks
useOrderList.js
usePermission.js
3. 组件中使用时保持命名可读性
当在组件中调用 Hook 时,可以用 JavaScript 的解构语法提取需要的部分,但注意避免命名冲突。通常建议调用时保持函数名原样,或者根据上下文稍作别名:
// 直接使用
const { x, y } = useMouse();
// 有冲突时起别名
const { x: scrollX, y: scrollY } = useScroll();
const { x: mouseX, y: mouseY } = useMouse();
真实例子:一个分页 Hook 的完整形态
将以上原则用一个实际场景串联起来:封装一个可复用的分页逻辑。
// composables/usePagination.js
import { ref, computed } from 'vue';
export function usePagination({ total = ref(0), pageSize = 10 } = {}) {
const currentPage = ref(1);
const totalPages = computed(() => Math.ceil(total.value / pageSize));
const hasNext = computed(() => currentPage.value < totalPages.value);
const hasPrev = computed(() => currentPage.value > 1);
function nextPage() {
if (hasNext.value) currentPage.value++;
}
function prevPage() {
if (hasPrev.value) currentPage.value--;
}
function goToPage(page) {
if (page >= 1 && page <= totalPages.value) {
currentPage.value = page;
}
}
return {
currentPage, // 响应式当前页
totalPages, // 总页数
hasNext,
hasPrev,
nextPage,
prevPage,
goToPage,
};
}
组件中使用:
<script setup>
import { ref } from 'vue';
import { usePagination } from '@/composables/usePagination';
const totalItems = ref(100);
const { currentPage, totalPages, hasNext, hasPrev, nextPage, prevPage } =
usePagination({ total: totalItems, pageSize: 10 });
</script>
这样的 Hook 单一职责明确(只管分页计算),输入通过 options 配置,输出一组响应式状态和方法,命名以 use 开头,文件放在 composables/ 下,完全符合规范。
遵循这些原则和命名规范,自定义 Hook 就会成为你项目中逻辑复用的标准模块,而不会变成令人头疼的“黑盒包袱”。当你发现多个组件开始出现重复的 ref、computed 组合时,就可以问自己:“这块逻辑是否可以提炼成一个 useXxx?”