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

相关推荐
步十人1 小时前
【CSS】基础一篇过
前端·css
回眸一笑吟离歌1 小时前
edge浏览器更新后打开局域网服务报错:ERR_ADDRESS_UNREACHABLE
前端·edge
幽络源小助理1 小时前
在线图片处理工具源码, 多功能编辑格式转换HTML单文件版
前端·html
humcomm1 小时前
AI编程时代前端架构师的机遇和挑战
前端·架构·ai编程
adminwolf1 小时前
自研企业微信SCRM系统源码独立部署(Golang+Vue.js)
前端·vue.js·企业微信
小短腿的代码世界1 小时前
QwtPolar 与实时示波器级渲染优化:雷达图到示波器曲线的极限性能调优
前端·qt·架构·交互
早起傻一天~G1 小时前
vue2+element-UI上传文件
javascript·vue.js·ui
机器视觉知识推荐、就业指导2 小时前
npm 安装/运行报错及解决方案
前端·npm·node.js
摇滚侠2 小时前
12 移动端 WEB 前端 WEB 开发 HTML5 + CSS3 + 移动 WEB
前端·css3·html5