Vue3 组合式函数设计模式:从基础封装到高级复用实战

前言

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 的设计核心可以用三句话概括:

  1. 输入是响应式的,输出也是响应式的 --- 用 ref/computed 包装输入输出,保证数据流可追踪

  2. 组合优于继承 --- 小 Composable 组合成大 Composable,每个只做一件事

  3. 副作用必须清理 --- 定时器、事件监听、WebSocket 连接,在 onUnmounted 中清理

推荐 Composable 设计路径:

阶段 掌握内容 典型案例
入门 状态封装 useCounter、useToggle
进阶 异步数据 + 生命周期 useFetch、useEventListener
高级 组合模式 + 依赖注入 useUserSearch、useThemeProvider
专家 测试 + 类型设计 泛型 Composable、Vitest 测试

Composable 是 Vue3 最强大的代码组织方式,掌握它的设计模式,能让你的代码可维护性提升一个量级。

相关推荐
FRing038 小时前
JavaScript入门到精通三十二(深拷贝、浅拷贝、递归函数)
开发语言·前端·javascript
excel8 小时前
双 Token 与前端使用 Crypto 公私钥鉴权换 Token,哪种方案更好?
前端·后端
外收内放8 小时前
Python基础语法练习题(73)
java·前端·python
风骏时光牛马9 小时前
面向复杂场景的大模型应用落地与能力优化实践
前端
小小善后师9 小时前
我做了个病历归档小程序,被 AI 识别的"最后一公里"狠狠上了一课
前端
H_Lisa9 小时前
我用 Express 写了个 MCP,Claude 直接调用我的 API 了
前端
IT_陈寒9 小时前
React的useEffect依赖数组把我坑惨了
前端·人工智能·后端
兰亭妙微UI设计公司10 小时前
兰亭妙微ui设计公司分享:B端组件命名如何支持团队协作
前端·javascript·vue.js
云和数据.ChenGuang11 小时前
光哥说Python 异常深度解析:从异常模型到工程化实践
java·前端·人工智能·python·langchain
小大宇11 小时前
Vue知识点-ElementPlus-03提示框+询问+通知
前端·javascript·vue.js