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 最强大的代码组织方式,掌握它的设计模式,能让你的代码可维护性提升一个量级。

相关推荐
毕业设计7032 小时前
(免费领源码) 基于微信小程序的预制菜商城的设计与实现25172-java、PHP、python、C#、小程序、大数据、单片机、网络工程等)
vue.js·python·mysql·微信小程序·pycharm·微信开发者工具·推荐算法
xifangge20252 小时前
AGENTS.md 怎么写?涵盖 Java、Python、Vue、Go 的 8 套开箱即用模板
java·vue.js·python
几何心凉2 小时前
没有万能模型:我用 AiiOnly Token Plan,把多款大模型的长处拼进同一个项目
前端
kyriewen2 小时前
Claude Code 的额度今天缩水了17%——官方公告上写的是"永久提高25%"
前端·ai编程·claude
雪芽蓝域zzs3 小时前
vue解构平铺VS对象包裹
前端·javascript·vue.js
风骏时光牛马4 小时前
从零到实战,完整AI学习路线
前端
三十而立洋4 小时前
深入理解 Monorepo:子包安装依赖
前端·前端工程化
IT_陈寒4 小时前
Java Stream处理大集合,我的内存怎么就炸了
前端·人工智能·后端
Captaincc5 小时前
AI 用量桌面端-桌面宠物自定义指南
前端·人工智能
OpenTiny社区5 小时前
码力全开,智启前端新生态|OpenTiny 登陆华为全联接大会2026
前端·github