第 125 章:自定义 composables
学习目标
- 掌握 composables 的设计模式
- 学会封装常见的业务逻辑
- 理解 composables 的输入输出约定
- 能在项目中编写可复用的 composables
一、什么是 composable
composable = 封装响应式状态和副作用的函数,通常以 use 开头。
typescript
// 一个完整的 composable
export function useMouse() {
const x = ref(0);
const y = ref(0);
function update(e: MouseEvent) {
x.value = e.clientX;
y.value = e.clientY;
}
onMounted(() => window.addEventListener('mousemove', update));
onUnmounted(() => window.removeEventListener('mousemove', update));
return { x, y };
}1.1 composable 与普通函数的区别
typescript
// ❌ 普通函数:不依赖 Vue 响应式
function formatDate(d: Date): string {
return d.toLocaleDateString();
}
// ✅ composable:使用响应式 API
function useDate() {
const now = ref(new Date());
let timer: number;
function start() {
timer = window.setInterval(() => {
now.value = new Date();
}, 1000);
}
function stop() {
clearInterval(timer);
}
onUnmounted(stop);
return { now, start, stop };
}二、composable 设计原则
2.1 输入参数
typescript
// ✅ 简洁:接受原始值
export function useFetch<T>(url: string) {
// ...
}
// ✅ 灵活:接受 ref 或 getter
export function useEventListener(target: Ref<EventTarget>, event: string, handler: Function) {
// ...
}
// ✅ 高阶:接收 ref 或普通值
function useTitle(title: MaybeRef<string>) {
const titleRef = isRef(title) ? title : ref(title);
// ...
}2.2 返回值
typescript
// ✅ 返回多个 ref 对象
export function useCounter(initial = 0) {
return {
count: ref(initial),
increment: () => {},
decrement: () => {},
reset: () => {}
};
}
// ✅ 解构友好
const { count, increment } = useCounter();
// ✅ 也支持返回 reactive 对象
return reactive({ count, increment, decrement });2.3 副作用管理
typescript
// ✅ 推荐:把副作用封装在内部
export function useEventListener(target, event, handler) {
onMounted(() => target.addEventListener(event, handler));
onUnmounted(() => target.removeEventListener(event, handler));
}
// ❌ 不推荐:让用户管理生命周期
export function useMouse() {
// 不挂载事件,让用户手动调 start()
}三、常见 composables 实现
3.1 useFetch
typescript
import { ref } from 'vue';
interface FetchOptions {
method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
body?: any;
headers?: Record<string, string>;
immediate?: boolean;
}
export function useFetch<T>(url: string, options: FetchOptions = {}) {
const data = ref<T | null>(null);
const error = ref<Error | null>(null);
const loading = ref(false);
async function execute() {
loading.value = true;
error.value = null;
try {
const res = await fetch(url, {
method: options.method || 'GET',
headers: options.headers,
body: options.body ? JSON.stringify(options.body) : undefined
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
data.value = await res.json();
} catch (e) {
error.value = e as Error;
} finally {
loading.value = false;
}
}
if (options.immediate !== false) {
execute();
}
return { data, error, loading, execute };
}vue
<script setup>
const { data, loading, error, execute } = useFetch<User[]>('/api/users');
</script>3.2 useLocalStorage
typescript
import { ref, watch, type Ref } from 'vue';
export function useLocalStorage<T>(
key: string,
defaultValue: T
): Ref<T> {
const data = ref<T>(getStoredValue(key, defaultValue));
watch(
data,
(val) => {
localStorage.setItem(key, JSON.stringify(val));
},
{ deep: true }
);
return data;
}
function getStoredValue<T>(key: string, defaultValue: T): T {
const stored = localStorage.getItem(key);
if (stored === null) return defaultValue;
try {
return JSON.parse(stored);
} catch {
return defaultValue;
}
}3.3 useDebounce
typescript
import { ref, watch, type Ref } from 'vue';
export function useDebounce<T>(value: Ref<T>, delay = 300): Ref<T> {
const debounced = ref(value.value) as Ref<T>;
let timer: number;
watch(value, (val) => {
clearTimeout(timer);
timer = window.setTimeout(() => {
debounced.value = val;
}, delay);
});
return debounced;
}3.4 useEventListener
typescript
import { onMounted, onUnmounted, unref, type MaybeRef } from 'vue';
export function useEventListener(
target: MaybeRef<EventTarget>,
event: string,
handler: (e: Event) => void
) {
onMounted(() => {
unref(target).addEventListener(event, handler);
});
onUnmounted(() => {
unref(target).removeEventListener(event, handler);
});
}3.5 useIntersectionObserver
typescript
import { ref, onMounted, onUnmounted, type Ref } from 'vue';
export function useIntersectionObserver(target: Ref<HTMLElement | null>) {
const isIntersecting = ref(false);
let observer: IntersectionObserver | null = null;
onMounted(() => {
if (!target.value) return;
observer = new IntersectionObserver(([entry]) => {
isIntersecting.value = entry.isIntersecting;
});
observer.observe(target.value);
});
onUnmounted(() => {
observer?.disconnect();
});
return { isIntersecting };
}3.6 useMediaQuery
typescript
import { ref, onMounted, onUnmounted } from 'vue';
export function useMediaQuery(query: string) {
const matches = ref(false);
let mediaQuery: MediaQueryList;
function update(e: MediaQueryListEvent | MediaQueryList) {
matches.value = e.matches;
}
onMounted(() => {
mediaQuery = window.matchMedia(query);
update(mediaQuery);
mediaQuery.addEventListener('change', update);
});
onUnmounted(() => {
mediaQuery?.removeEventListener('change', update);
});
return matches;
}
// 使用
const isMobile = useMediaQuery('(max-width: 768px)');3.7 useTitle
typescript
import { ref, watch, isRef, type MaybeRef } from 'vue';
export function useTitle(title: MaybeRef<string>) {
const titleRef = isRef(title) ? title : ref(title);
watch(
titleRef,
(val) => {
document.title = val;
},
{ immediate: true }
);
}3.8 useScroll
typescript
import { ref, onMounted, onUnmounted } from 'vue';
export function useScroll(target?: HTMLElement) {
const x = ref(0);
const y = ref(0);
function handler(e: Event) {
const el = (e.target as HTMLElement) || document.documentElement;
x.value = el.scrollLeft;
y.value = el.scrollTop;
}
onMounted(() => {
const el = target || window;
el.addEventListener('scroll', handler as any);
});
onUnmounted(() => {
const el = target || window;
el.removeEventListener('scroll', handler as any);
});
return { x, y };
}3.9 useToggle
typescript
import { ref, type Ref } from 'vue';
export function useToggle(initial = false): [Ref<boolean>, () => void, (val: boolean) => void] {
const value = ref(initial);
const toggle = () => (value.value = !value.value);
const set = (val: boolean) => (value.value = val);
return [value, toggle, set];
}
// 使用
const [visible, toggleVisible, setVisible] = useToggle(false);四、业务级 composables
4.1 useTable - 表格通用逻辑
typescript
// composables/useTable.ts
import { ref, computed } from 'vue';
interface PageQuery {
page: number;
pageSize: number;
}
export function useTable<T>(
fetcher: (query: PageQuery) => Promise<{ items: T[]; total: number }>,
initialPageSize = 10
) {
const items = ref<T[]>([]);
const total = ref(0);
const loading = ref(false);
const error = ref<Error | null>(null);
const page = ref(1);
const pageSize = ref(initialPageSize);
const totalPages = computed(() => Math.ceil(total.value / pageSize.value));
async function load() {
loading.value = true;
error.value = null;
try {
const res = await fetcher({ page: page.value, pageSize: pageSize.value });
items.value = res.items;
total.value = res.total;
} catch (e) {
error.value = e as Error;
} finally {
loading.value = false;
}
}
function nextPage() {
if (page.value < totalPages.value) page.value++;
}
function prevPage() {
if (page.value > 1) page.value--;
}
function refresh() {
return load();
}
// 初始化加载
load();
return {
items,
total,
loading,
error,
page,
pageSize,
totalPages,
nextPage,
prevPage,
refresh
};
}vue
<script setup>
const { items, loading, page, totalPages, nextPage, prevPage } = useTable(fetchUsers);
</script>4.2 useForm - 表单管理
typescript
// composables/useForm.ts
import { reactive, computed } from 'vue';
type Validator<T> = (value: T) => string | null;
type Rules<T> = { [K in keyof T]?: Validator<T[K]> };
export function useForm<T extends object>(
initial: T,
rules: Rules<T> = {}
) {
const values = reactive({ ...initial }) as T;
const errors = reactive<Record<string, string>>({} as any);
const touched = reactive<Record<string, boolean>>({} as any);
function validateField(field: keyof T) {
const validator = rules[field];
if (!validator) return;
const error = validator(values[field]);
errors[field as string] = error || '';
}
function validateAll() {
let valid = true;
for (const field in rules) {
validateField(field);
if (errors[field]) valid = false;
}
return valid;
}
function reset() {
Object.assign(values, initial);
for (const key in errors) delete errors[key];
for (const key in touched) delete touched[key];
}
const isValid = computed(() =>
Object.values(errors).every((e) => !e)
);
return {
values,
errors,
touched,
isValid,
validateField,
validateAll,
reset
};
}4.3 useAuth - 认证管理
typescript
// composables/useAuth.ts
import { ref, computed } from 'vue';
const user = ref<User | null>(null);
const token = ref<string>(localStorage.getItem('token') || '');
const isLoggedIn = computed(() => !!token.value);
export function useAuth() {
async function login(credentials: LoginDTO) {
const res = await api.login(credentials);
user.value = res.data.user;
token.value = res.data.token;
localStorage.setItem('token', token.value);
}
function logout() {
user.value = null;
token.value = '';
localStorage.removeItem('token');
}
return {
user,
token,
isLoggedIn,
login,
logout
};
}五、composable 组合
5.1 嵌套使用
typescript
// 一个 composable 可以用其他 composables
export function useUser() {
const { data, loading } = useFetch<User>('/api/me');
return { user: data, loading };
}
export function useUserPosts() {
const { user } = useUser();
return useFetch<Post[]>(() => `/api/users/${user.value?.id}/posts`);
}5.2 业务模块封装
typescript
// composables/useUserModule.ts
import { useFetch } from './useFetch';
import { useLocalStorage } from './useLocalStorage';
export function useUserModule() {
const token = useLocalStorage('token', '');
const { data: user, execute: refreshUser } = useFetch<User>('/api/me', {
immediate: !!token.value
});
async function login(credentials: LoginDTO) {
const res = await api.login(credentials);
token.value = res.data.token;
await refreshUser();
}
function logout() {
token.value = '';
}
return { user, token, login, logout, refreshUser };
}六、TypeScript 技巧
6.1 MaybeRef 类型
typescript
import type { Ref } from 'vue';
type MaybeRef<T> = T | Ref<T>;
export function useTitle(title: MaybeRef<string>) {
// 接受 ref 或普通值
const titleRef = isRef(title) ? title : ref(title);
// ...
}6.2 函数重载
typescript
// 重载 1:接受 ref
export function useDebounce<T>(value: Ref<T>, delay?: number): Ref<T>;
// 重载 2:接受 getter
export function useDebounce<T>(getter: () => T, delay?: number): Ref<T>;
// 实现
export function useDebounce(value: any, delay = 300) {
// ...
}6.3 泛型推断
typescript
// ✅ 自动推断类型
const { data } = useFetch<User>('/api/users');
// data.value: User | null
// ✅ 不传泛型时显式标注
const { data } = useFetch('/api/users') as { data: Ref<User | null> };七、测试 composables
7.1 单元测试
typescript
// useCounter.test.ts
import { useCounter } from '@/composables/useCounter';
describe('useCounter', () => {
it('应该从初始值开始', () => {
const { count } = useCounter(10);
expect(count.value).toBe(10);
});
it('应该能自增', () => {
const { count, increment } = useCounter(0);
increment();
expect(count.value).toBe(1);
});
});7.2 组件测试
typescript
import { mount } from '@vue/test-utils';
import { defineComponent } from 'vue';
import { useMouse } from '@/composables/useMouse';
it('useMouse in component', () => {
const Component = defineComponent({
setup() {
const { x, y } = useMouse();
return { x, y };
},
template: '<div>{{ x }},{{ y }}</div>'
});
const wrapper = mount(Component);
expect(wrapper.text()).toBe('0,0');
});八、composable 库推荐
| 库 | 特点 |
|---|---|
| VueUse | 100+ 工具函数,必备 |
| Pinia | 状态管理(类似 composable) |
| Vue Query | 服务端状态管理 |
| Vue Router | 路由相关 composable |
九、本章小结
| 原则 | 关键 |
|---|---|
| 命名 | useXxx |
| 输入 | 接受 ref 或 getter |
| 输出 | 返回多个 ref 或 reactive 对象 |
| 副作用 | 封装在内部,自动清理 |
| 可组合 | composable 之间可嵌套 |
| TypeScript | 类型安全,泛型推断 |
动手练习
- useToggle:实现一个开关切换的 composable
- useFetch:完善 fetch composable,支持重试和取消
- useForm:实现完整的表单管理 composable
- useTable:实现带分页、排序、筛选的表格 composable
推荐阅读
- 📖 Vue 3 组合式函数 — 官方文档
- 📖 VueUse 文档 — 最佳实践参考
- 🌐 Composable 模式 — 模式指南
下一章:第 126 章:Vue Router 路由管理 →