前言
Vue3 Composition API 的核心价值不只是"把代码按逻辑组织",更在于它让状态逻辑的提取和复用 变得前所未有的简单。组合式函数(Composable)是 Vue3 中复用逻辑的官方推荐方式,但很多开发者只是把 Options API 的代码"搬"到 setup() 里,并没有真正发挥组合式 API 的威力。
本文从实战出发,覆盖 Composable 的设计原则、常见模式、TypeScript 类型设计、测试策略,以及从简单到复杂的完整案例。无论你是刚上手 Composition API,还是想提升 Composable 设计能力,都能从中获益。
一、Composable 设计原则
1.1 核心原则
| 原则 | 说明 | 反模式 |
|---|---|---|
| 单一职责 | 一个 Composable 只做一件事 | useUserAndOrder() |
| 命名规范 | 以 use 开头,返回值语义清晰 |
getUserData() |
| 显式参数 | 使用 ref 或 getter 接收响应式参数 |
直接读取全局变量 |
| 清理副作用 | 在 onUnmounted 中清理定时器/监听器 |
忘记清理导致内存泄漏 |
| 返回只读数据 | 内部可变,外部只读 | 暴露可写 ref 给外部 |
1.2 基础模板
// composables/useCounter.ts
import { ref, computed, readonly, type Ref } from 'vue';
// 选项接口
interface UseCounterOptions {
min?: number;
max?: number;
initialValue?: number;
}
// 返回值接口
interface UseCounterReturn {
count: Readonly<Ref<number>>;
doubled: Readonly<Ref<number>>;
increment: () => void;
decrement: () => void;
reset: () => void;
}
export function useCounter(options: UseCounterOptions = {}): UseCounterReturn {
const { min = -Infinity, max = Infinity, initialValue = 0 } = options;
const count = ref(initialValue);
const doubled = computed(() => count.value * 2);
function increment() {
if (count.value < max) count.value++;
}
function decrement() {
if (count.value > min) count.value--;
}
function reset() {
count.value = initialValue;
}
return {
count: readonly(count),
doubled,
increment,
decrement,
reset,
};
}
二、异步数据获取模式
2.1 通用请求 Composable
// composables/useFetch.ts
import { ref, shallowRef, toValue, watchEffect, type Ref } from 'vue';
interface UseFetchOptions<T> {
immediate?: boolean;
initialData?: T;
onSuccess?: (data: T) => void;
onError?: (error: Error) => void;
}
interface UseFetchReturn<T> {
data: Readonly<Ref<T | null>>;
error: Readonly<Ref<Error | null>>;
isLoading: Readonly<Ref<boolean>>;
execute: () => Promise<void>;
}
export function useFetch<T = unknown>(
url: string | Ref<string> | (() => string),
options: UseFetchOptions<T> = {}
): UseFetchReturn<T> {
const { immediate = true, initialData, onSuccess, onError } = options;
const data = shallowRef<T | null>(initialData ?? null);
const error = shallowRef<Error | null>(null);
const isLoading = ref(false);
async function execute() {
isLoading.value = true;
error.value = null;
try {
const resolvedUrl = toValue(url);
const response = await fetch(resolvedUrl);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
data.value = (await response.json()) as T;
onSuccess?.(data.value);
} catch (err) {
error.value = err as Error;
onError?.(error.value);
} finally {
isLoading.value = false;
}
}
if (immediate) {
execute();
}
// 当 URL 是响应式时,自动重新请求
if (typeof url !== 'string') {
watchEffect(() => {
toValue(url); // 追踪依赖
execute();
});
}
return {
data: readonly(data) as Readonly<Ref<T | null>>,
error: readonly(error),
isLoading: readonly(isLoading),
execute,
};
}
2.2 带缓存的请求
// composables/useCachedFetch.ts
import { ref, shallowRef, toValue, type Ref } from 'vue';
const cache = new Map<string, { data: any; timestamp: number }>();
const CACHE_TTL = 5 * 60 * 1000; // 5 分钟
export function useCachedFetch<T = unknown>(
url: string | Ref<string> | (() => string),
ttl: number = CACHE_TTL
) {
const data = shallowRef<T | null>(null);
const error = shallowRef<Error | null>(null);
const isLoading = ref(false);
const fromCache = ref(false);
async function execute() {
const resolvedUrl = toValue(url);
const cached = cache.get(resolvedUrl);
// 命中缓存
if (cached && Date.now() - cached.timestamp < ttl) {
data.value = cached.data;
fromCache.value = true;
return;
}
isLoading.value = true;
error.value = null;
try {
const response = await fetch(resolvedUrl);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const result = (await response.json()) as T;
data.value = result;
fromCache.value = false;
// 写入缓存
cache.set(resolvedUrl, { data: result, timestamp: Date.now() });
} catch (err) {
error.value = err as Error;
} finally {
isLoading.value = false;
}
}
execute();
return { data, error, isLoading, fromCache, execute };
}
三、事件监听模式
3.1 通用事件绑定
// composables/useEventListener.ts
import { onMounted, onUnmounted, type Ref, isRef } from 'vue';
export function useEventListener(
target: Ref<EventTarget | null> | EventTarget | Window,
event: string,
handler: EventListener,
options?: AddEventListenerOptions
) {
onMounted(() => {
const el = isRef(target) ? target.value : target;
el?.addEventListener(event, handler, options);
});
onUnmounted(() => {
const el = isRef(target) ? target.value : target;
el?.removeEventListener(event, handler, options);
});
}
3.2 鼠标位置追踪
// composables/useMouse.ts
import { ref, readonly } from 'vue';
import { useEventListener } from './useEventListener';
export function useMouse() {
const x = ref(0);
const y = ref(0);
useEventListener(
window,
'mousemove',
(event) => {
x.value = event.clientX;
y.value = event.clientY;
}
);
return {
x: readonly(x),
y: readonly(y),
};
}
3.3 元素可见性检测
// composables/useIntersectionObserver.ts
import { ref, readonly, onUnmounted, type Ref } from 'vue';
export function useIntersectionObserver(
target: Ref<HTMLElement | null>,
options?: IntersectionObserverInit
) {
const isVisible = ref(false);
const intersectionRatio = ref(0);
let observer: IntersectionObserver | null = null;
const stop = () => {
if (observer) {
observer.disconnect();
observer = null;
}
};
const observe = () => {
stop();
if (!target.value) return;
observer = new IntersectionObserver(([entry]) => {
isVisible.value = entry.isIntersecting;
intersectionRatio.value = entry.intersectionRatio;
}, options);
observer.observe(target.value);
};
// 需要在组件中手动调用 observe()
onUnmounted(stop);
return {
isVisible: readonly(isVisible),
intersectionRatio: readonly(intersectionRatio),
observe,
stop,
};
}
四、表单处理模式
4.1 响应式表单
// composables/useForm.ts
import { ref, reactive, computed, readonly } from 'vue';
interface FieldConfig {
value: any;
required?: boolean;
validator?: (value: any) => string | null;
}
interface UseFormOptions {
[key: string]: FieldConfig;
}
export function useForm(fields: UseFormOptions) {
const form = reactive(
Object.fromEntries(
Object.entries(fields).map(([key, config]) => [key, config.value])
)
);
const errors = reactive<Record<string, string[]>>({});
const touched = reactive<Record<string, boolean>>({});
function validateField(key: string): boolean {
const config = fields[key];
const fieldErrors: string[] = [];
if (config.required && !form[key]) {
fieldErrors.push('此项必填');
}
if (config.validator) {
const error = config.validator(form[key]);
if (error) fieldErrors.push(error);
}
errors[key] = fieldErrors;
return fieldErrors.length === 0;
}
function validateAll(): boolean {
let valid = true;
for (const key of Object.keys(fields)) {
if (!validateField(key)) valid = false;
}
return valid;
}
function reset() {
for (const [key, config] of Object.entries(fields)) {
form[key] = config.value;
errors[key] = [];
touched[key] = false;
}
}
function handleBlur(key: string) {
touched[key] = true;
validateField(key);
}
const isValid = computed(() =>
Object.values(errors).every(e => e.length === 0)
);
const isDirty = computed(() =>
Object.keys(fields).some(key => form[key] !== fields[key].value)
);
return {
form,
errors: readonly(errors),
touched: readonly(touched),
isValid,
isDirty,
validateField,
validateAll,
handleBlur,
reset,
};
}
五、Composable 组合模式
5.1 组合多个 Composable
// composables/useUserSearch.ts
import { computed } from 'vue';
import { useFetch } from './useFetch';
import { useDebounce } from './useDebounce';
import { useIntersectionObserver } from './useIntersectionObserver';
export function useUserSearch() {
const keyword = ref('');
const page = ref(1);
const debouncedKeyword = useDebounce(keyword, 300);
// 组合 useFetch + useDebounce:关键词变化时自动请求
const { data, isLoading, error } = useFetch<UserListResponse>(
computed(() =>
debouncedKeyword.value
? `/api/users?q=${debouncedKeyword.value}&page=${page.value}`
: ''
),
{ immediate: false }
);
// 组合 useIntersectionObserver:滚动到底部自动加载下一页
const scrollTarget = ref<HTMLElement | null>(null);
const { isVisible } = useIntersectionObserver(scrollTarget);
watch(isVisible, (visible) => {
if (visible && data.value?.hasMore) {
page.value++;
}
});
const users = computed(() => data.value?.list ?? []);
const hasMore = computed(() => data.value?.hasMore ?? false);
return {
keyword,
users,
isLoading,
error,
hasMore,
scrollTarget,
};
}
5.2 提供者/消费者模式
// composables/useThemeProvider.ts
import { ref, provide, inject, readonly, type InjectionKey } from 'vue';
interface Theme {
mode: 'light' | 'dark';
primaryColor: string;
}
const THEME_KEY: InjectionKey<ReturnType<typeof createThemeProvider>> =
Symbol('theme');
function createThemeProvider() {
const theme = ref<Theme>({
mode: 'light',
primaryColor: '#409EFF',
});
function toggleMode() {
theme.value.mode = theme.value.mode === 'light' ? 'dark' : 'light';
}
function setPrimaryColor(color: string) {
theme.value.primaryColor = color;
}
return { theme: readonly(theme), toggleMode, setPrimaryColor };
}
// 在根组件使用
export function provideTheme() {
const themeProvider = createThemeProvider();
provide(THEME_KEY, themeProvider);
return themeProvider;
}
// 在子组件使用
export function useTheme() {
const themeProvider = inject(THEME_KEY);
if (!themeProvider) {
throw new Error('useTheme must be used inside a ThemeProvider');
}
return themeProvider;
}
六、Composable 测试
6.1 单元测试
// __tests__/useCounter.test.ts
import { describe, it, expect } from 'vitest';
import { useCounter } from '../composables/useCounter';
describe('useCounter', () => {
it('should initialize with default value', () => {
const { count } = useCounter();
expect(count.value).toBe(0);
});
it('should initialize with custom value', () => {
const { count } = useCounter({ initialValue: 10 });
expect(count.value).toBe(10);
});
it('should increment', () => {
const { count, increment } = useCounter();
increment();
expect(count.value).toBe(1);
});
it('should decrement', () => {
const { count, decrement } = useCounter({ initialValue: 5 });
decrement();
expect(count.value).toBe(4);
});
it('should respect max limit', () => {
const { count, increment } = useCounter({ max: 2 });
increment();
increment();
increment(); // 不应该超过 2
expect(count.value).toBe(2);
});
it('should reset to initial value', () => {
const { count, increment, reset } = useCounter({ initialValue: 5 });
increment();
increment();
reset();
expect(count.value).toBe(5);
});
});
6.2 异步 Composable 测试
// __tests__/useFetch.test.ts
import { describe, it, expect, vi } from 'vitest';
import { useFetch } from '../composables/useFetch';
describe('useFetch', () => {
it('should fetch data successfully', async () => {
const mockData = { id: 1, name: 'Test' };
vi.spyOn(global, 'fetch').mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockData),
} as Response);
const { data, isLoading, error } = useFetch('/api/test');
expect(isLoading.value).toBe(true);
await vi.waitFor(() => {
expect(isLoading.value).toBe(false);
});
expect(data.value).toEqual(mockData);
expect(error.value).toBeNull();
});
it('should handle fetch error', async () => {
vi.spyOn(global, 'fetch').mockRejectedValueOnce(
new Error('Network error')
);
const { data, error, isLoading } = useFetch('/api/fail', {
immediate: true,
});
await vi.waitFor(() => {
expect(isLoading.value).toBe(false);
});
expect(data.value).toBeNull();
expect(error.value?.message).toBe('Network error');
});
});
总结与推荐
Composable 的设计核心可以用三句话概括:
-
输入是响应式的,输出也是响应式的 --- 用
ref/computed包装输入输出,保证数据流可追踪 -
组合优于继承 --- 小 Composable 组合成大 Composable,每个只做一件事
-
副作用必须清理 --- 定时器、事件监听、WebSocket 连接,在
onUnmounted中清理
推荐 Composable 设计路径:
| 阶段 | 掌握内容 | 典型案例 |
|---|---|---|
| 入门 | 状态封装 | useCounter、useToggle |
| 进阶 | 异步数据 + 生命周期 | useFetch、useEventListener |
| 高级 | 组合模式 + 依赖注入 | useUserSearch、useThemeProvider |
| 专家 | 测试 + 类型设计 | 泛型 Composable、Vitest 测试 |
Composable 是 Vue3 最强大的代码组织方式,掌握它的设计模式,能让你的代码可维护性提升一个量级。