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

前言

Vue3 Composition API 的核心价值不只是"把代码按逻辑组织",更在于它让状态逻辑的提取和复用 变得前所未有的简单。组合式函数(Composable)是 Vue3 中复用逻辑的官方推荐方式,但很多开发者只是把 Options API 的代码"搬"到 setup() 里,并没有真正发挥组合式 API 的威力。

本文从实战出发,覆盖 Composable 的设计原则、常见模式、TypeScript 类型设计、测试策略,以及从简单到复杂的完整案例。无论你是刚上手 Composition API,还是想提升 Composable 设计能力,都能从中获益。


一、Composable 设计原则

1.1 核心原则

原则 说明 反模式
单一职责 一个 Composable 只做一件事 useUserAndOrder()
命名规范 use 开头,返回值语义清晰 getUserData()
显式参数 使用 refgetter 接收响应式参数 直接读取全局变量
清理副作用 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 设计路径:

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

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

相关推荐
只会cv的前端攻城狮26 分钟前
动态组件架构设计:从配置到事件驱动全链路解析
前端·vue.js
飞天狗28 分钟前
Next.js 16 App Router 实战:服务端组件与流式渲染
前端·next.js
万敏31 分钟前
Vue3 全栈实战第二周:Pinia + Vite + Router + axios 完整记录
vue.js·node.js·全栈
OpenTiny社区32 分钟前
TinyEngine 2.11版本发布:AI 进一步融入画布,Vue 工程可一键转DSL
前端·vue.js·github
小雪_Snow1 小时前
JavaScript 中的三种常用事件
开发语言·前端·javascript
Hyyy1 小时前
Electron-Vite是什么,和Vite有什么区别
前端·面试
蓝胖子酱1 小时前
在 Electron 中用 electron-store 实现加密存储与自定义格式文件
前端·javascript·数据库
虚焦像素_1 小时前
海康 Web 插件版接入指南:用法与注意事项
前端
虚焦像素_1 小时前
海康 Web 无插件版接入指南:用法与注意事项
前端
aaron2 小时前
前端演进史:当你没留意时,前端发生了什么
前端