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

相关推荐
Setsuna_F_Seiei1 小时前
前端转型 Agent 开发 06 之 Agent Memory 记忆系统(让 Agent 更智能,更懂你)
前端·agent·ai编程
志尊宝1 小时前
Vue3 零基础每日笔记(073):keep-alive 页面缓存实战——列表页返回不丢状态
vue.js·笔记·缓存·#vue #前端 #前端开发·#javascript·#vue.js
zhangzeyuaaa2 小时前
Ruby 多线程、GVL(GIL)与 Mutex 完全指南
开发语言·前端·ruby
默_笙2 小时前
🌊 向量库和 ES 都查不出"关系",我只好给奶茶建了一张人脉网
前端·javascript
优选资讯2 小时前
标签打印软件怎么对接 Excel 数据
前端·excel
一个风轻云淡3 小时前
GCC 和 GDB命令简单解读
java·linux·前端
u0111026754 小时前
图片处理接口如何统一错误信息 前端提示与后端错误码设计
前端·状态模式
福兮说5 小时前
浏览器里把 iPhone 的 HEIC 转成 JPG:Chrome 解不开、转完大了七成、拍摄时间全丢,六个坑实测
前端·javascript·图像处理·chrome·ios·iphone·heic
libokaifa5 小时前
把语音模型塞进手机:sherpa-onnx 在 Android 上的 ASR / TTS
前端
deli0075 小时前
DLA 枝晶生长实测:5000 个粒子集体停摆之后,分形维数收敛到 1.70
前端