抽出第一个 useXxx
自定义 Composables · 阅读后完成下方实验与检查题,再用「标记完成」记录进度。
学习目标
Composable 是 Vue 3 复用逻辑的官方答案,地位等同 React 的 custom hook。这一节掌握「何时抽、怎么抽、命名与契约」,并避开「把什么都抽成 composable」的过度设计。
问题场景
三个组件各自实现了一遍「媒体查询监听」:onMounted 注册 matchMedia、onUnmounted 移除、维护一个布尔 ref。第三个组件写完,你发现第二份代码里监听没清理、第一份忘了初始值同步。这类状态 + 副作用 + 清理的组合逻辑,就是 composable 的典型对象。
核心解释
Composable 是一个普通函数,约定以 use 开头,内部使用 Vue 的响应式 API 和生命周期钩子,返回一组响应式状态和方法:
// composables/useMediaQuery.js
import { onBeforeUnmount, onMounted, ref } from "vue";
export function useMediaQuery(query) {
const matches = ref(false);
let mql = null;
function update(e) {
matches.value = e.matches;
}
onMounted(() => {
mql = window.matchMedia(query);
matches.value = mql.matches;
mql.addEventListener("change", update);
});
onBeforeUnmount(() => {
mql?.removeEventListener("change", update);
});
return { matches };
}
<script setup>
import { useMediaQuery } from "../composables/useMediaQuery";
const { matches: isMobile } = useMediaQuery("(max-width: 640px)");
</script>
<template>
<p>{{ isMobile ? "移动端布局" : "桌面布局" }}</p>
</template>
三个设计约束:
- 必须在 setup 同步阶段调用。内部用了
onMounted/onBeforeUnmount,这些钩子依赖当前组件实例;在事件回调或setTimeout里调用会找不到实例(Vue 会警告「onMounted is called when there is no active component instance」)。 - 返回 ref 而不是解包后的值。调用方解构后仍是 ref,响应式不丢;如果返回
{ matches: matches.value }就退化成快照。 - 一个 composable 只做一件事。
useMediaQuery只管媒体查询;「媒体查询 + 主题 + 持久化」应该拆成三个再组合。
命名与位置:composables/useXxx.js,函数名与文件名一致。Vue 官方推荐这种命名让调用处一眼看出「这是组合式逻辑」。
和 React custom hook 的对照:结构几乎一样(函数封装 hooks),差异在调用规则——React 的 hook 必须在渲染期间调用且每次渲染都会重新执行;Vue 的 composable 只在 setup 时执行一次,内部用 ref 保存状态,更新靠响应式追踪而不是重新执行函数。这意味着 Vue 的 composable 不需要 useCallback/useMemo,也天然没有闭包快照问题。
什么时候不该抽:只在一个组件里用的逻辑(先留在组件里);纯函数工具(放 utils/,不需要响应式);只是几行简单计算(computed 就够)。
示例拆解
一个 useToggle 加一个 useLocalStorage,展示组合。useToggle 很小,核心就是 const value = ref(initial) 加 toggle = () => (value.value = !value.value),再返回 { value, toggle, set };下面重点看持久化那个:
// composables/useLocalStorage.js
import { ref, watch } from "vue";
export function useLocalStorage(key, defaultValue) {
const value = ref(defaultValue);
try {
const raw = localStorage.getItem(key);
if (raw !== null) value.value = JSON.parse(raw);
} catch {
/* 忽略脏数据 */
}
watch(
value,
(v) => {
try {
localStorage.setItem(key, JSON.stringify(v));
} catch {
/* 存储不可用时降级 */
}
},
{ deep: true }
);
return { value };
}
// 组合使用:持久化的开关
import { useLocalStorage } from "./useLocalStorage";
import { computed } from "vue";
export function usePersistentToggle(key, defaultValue = false) {
const { value } = useLocalStorage(key, defaultValue);
const toggle = () => (value.value = !value.value);
const isOn = computed(() => !!value.value);
return { value, isOn, toggle };
}
组合的关键:每个 composable 返回 ref,下一个 composable 接收并继续包装。调用方最终拿到的仍是普通 ref,不感知内部嵌套了几层。
动手任务
- 实现
useMediaQuery,在两个组件里复用,验证窗口缩放时同步更新、切换组件后监听被清理。 - 用
useToggle+useLocalStorage组合出「侧边栏折叠状态记忆」,刷新后保留。 - 把项目里重复两次以上的「状态 + 副作用」逻辑抽成 composable,记录抽取前后的代码行数。
常见错误
- 在事件回调里调用 composable:内部钩子找不到组件实例。composable 只在 setup 同步阶段调用。
- 返回解包后的值:
return { matches: matches.value }丢失响应式。返回 ref。 - composable 里写死具体业务:
useCourseList里硬编码接口地址和字段名,换个页面就不能用。参数化输入。 - 忘了清理副作用:定时器、监听器、observer 都要在 onBeforeUnmount 释放(V05)。
检查与复盘
- 能写出一个带清理逻辑的 composable 并说明调用时机约束
- 能说出 composable 与 React custom hook 的两点差异
- 能判断一段逻辑该抽 composable、放 utils 还是留在组件
参考资料
- Vue 官方文档「组合式函数」:命名、约定与最佳实践
- Vue 官方文档「组合式 API 常见问答」:与 React hooks 的对比
读完这一课了?
标记完成后,工作台会安排接下来的学习。