Skip to content
第 125 / 250 章前端⏱ 10 分钟阅读

第 125 章:自定义 composables

学习目标

  • 掌握 composables 的设计模式
  • 学会封装常见的业务逻辑
  • 理解 composables 的输入输出约定
  • 能在项目中编写可复用的 composables

一、什么是 composable

composable = 封装响应式状态和副作用的函数,通常以 use 开头。

typescript
// 一个完整的 composable
export function useMouse() {
  const x = ref(0);
  const y = ref(0);

  function update(e: MouseEvent) {
    x.value = e.clientX;
    y.value = e.clientY;
  }

  onMounted(() => window.addEventListener('mousemove', update));
  onUnmounted(() => window.removeEventListener('mousemove', update));

  return { x, y };
}

1.1 composable 与普通函数的区别

typescript
// ❌ 普通函数:不依赖 Vue 响应式
function formatDate(d: Date): string {
  return d.toLocaleDateString();
}

// ✅ composable:使用响应式 API
function useDate() {
  const now = ref(new Date());

  let timer: number;
  function start() {
    timer = window.setInterval(() => {
      now.value = new Date();
    }, 1000);
  }

  function stop() {
    clearInterval(timer);
  }

  onUnmounted(stop);

  return { now, start, stop };
}

二、composable 设计原则

2.1 输入参数

typescript
// ✅ 简洁:接受原始值
export function useFetch<T>(url: string) {
  // ...
}

// ✅ 灵活:接受 ref 或 getter
export function useEventListener(target: Ref<EventTarget>, event: string, handler: Function) {
  // ...
}

// ✅ 高阶:接收 ref 或普通值
function useTitle(title: MaybeRef<string>) {
  const titleRef = isRef(title) ? title : ref(title);
  // ...
}

2.2 返回值

typescript
// ✅ 返回多个 ref 对象
export function useCounter(initial = 0) {
  return {
    count: ref(initial),
    increment: () => {},
    decrement: () => {},
    reset: () => {}
  };
}

// ✅ 解构友好
const { count, increment } = useCounter();

// ✅ 也支持返回 reactive 对象
return reactive({ count, increment, decrement });

2.3 副作用管理

typescript
// ✅ 推荐:把副作用封装在内部
export function useEventListener(target, event, handler) {
  onMounted(() => target.addEventListener(event, handler));
  onUnmounted(() => target.removeEventListener(event, handler));
}

// ❌ 不推荐:让用户管理生命周期
export function useMouse() {
  // 不挂载事件,让用户手动调 start()
}

三、常见 composables 实现

3.1 useFetch

typescript
import { ref } from 'vue';

interface FetchOptions {
  method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
  body?: any;
  headers?: Record<string, string>;
  immediate?: boolean;
}

export function useFetch<T>(url: string, options: FetchOptions = {}) {
  const data = ref<T | null>(null);
  const error = ref<Error | null>(null);
  const loading = ref(false);

  async function execute() {
    loading.value = true;
    error.value = null;
    try {
      const res = await fetch(url, {
        method: options.method || 'GET',
        headers: options.headers,
        body: options.body ? JSON.stringify(options.body) : undefined
      });
      if (!res.ok) throw new Error(`HTTP ${res.status}`);
      data.value = await res.json();
    } catch (e) {
      error.value = e as Error;
    } finally {
      loading.value = false;
    }
  }

  if (options.immediate !== false) {
    execute();
  }

  return { data, error, loading, execute };
}
vue
<script setup>
const { data, loading, error, execute } = useFetch<User[]>('/api/users');
</script>

3.2 useLocalStorage

typescript
import { ref, watch, type Ref } from 'vue';

export function useLocalStorage<T>(
  key: string,
  defaultValue: T
): Ref<T> {
  const data = ref<T>(getStoredValue(key, defaultValue));

  watch(
    data,
    (val) => {
      localStorage.setItem(key, JSON.stringify(val));
    },
    { deep: true }
  );

  return data;
}

function getStoredValue<T>(key: string, defaultValue: T): T {
  const stored = localStorage.getItem(key);
  if (stored === null) return defaultValue;
  try {
    return JSON.parse(stored);
  } catch {
    return defaultValue;
  }
}

3.3 useDebounce

typescript
import { ref, watch, type Ref } from 'vue';

export function useDebounce<T>(value: Ref<T>, delay = 300): Ref<T> {
  const debounced = ref(value.value) as Ref<T>;
  let timer: number;

  watch(value, (val) => {
    clearTimeout(timer);
    timer = window.setTimeout(() => {
      debounced.value = val;
    }, delay);
  });

  return debounced;
}

3.4 useEventListener

typescript
import { onMounted, onUnmounted, unref, type MaybeRef } from 'vue';

export function useEventListener(
  target: MaybeRef<EventTarget>,
  event: string,
  handler: (e: Event) => void
) {
  onMounted(() => {
    unref(target).addEventListener(event, handler);
  });

  onUnmounted(() => {
    unref(target).removeEventListener(event, handler);
  });
}

3.5 useIntersectionObserver

typescript
import { ref, onMounted, onUnmounted, type Ref } from 'vue';

export function useIntersectionObserver(target: Ref<HTMLElement | null>) {
  const isIntersecting = ref(false);
  let observer: IntersectionObserver | null = null;

  onMounted(() => {
    if (!target.value) return;

    observer = new IntersectionObserver(([entry]) => {
      isIntersecting.value = entry.isIntersecting;
    });

    observer.observe(target.value);
  });

  onUnmounted(() => {
    observer?.disconnect();
  });

  return { isIntersecting };
}

3.6 useMediaQuery

typescript
import { ref, onMounted, onUnmounted } from 'vue';

export function useMediaQuery(query: string) {
  const matches = ref(false);
  let mediaQuery: MediaQueryList;

  function update(e: MediaQueryListEvent | MediaQueryList) {
    matches.value = e.matches;
  }

  onMounted(() => {
    mediaQuery = window.matchMedia(query);
    update(mediaQuery);
    mediaQuery.addEventListener('change', update);
  });

  onUnmounted(() => {
    mediaQuery?.removeEventListener('change', update);
  });

  return matches;
}

// 使用
const isMobile = useMediaQuery('(max-width: 768px)');

3.7 useTitle

typescript
import { ref, watch, isRef, type MaybeRef } from 'vue';

export function useTitle(title: MaybeRef<string>) {
  const titleRef = isRef(title) ? title : ref(title);

  watch(
    titleRef,
    (val) => {
      document.title = val;
    },
    { immediate: true }
  );
}

3.8 useScroll

typescript
import { ref, onMounted, onUnmounted } from 'vue';

export function useScroll(target?: HTMLElement) {
  const x = ref(0);
  const y = ref(0);

  function handler(e: Event) {
    const el = (e.target as HTMLElement) || document.documentElement;
    x.value = el.scrollLeft;
    y.value = el.scrollTop;
  }

  onMounted(() => {
    const el = target || window;
    el.addEventListener('scroll', handler as any);
  });

  onUnmounted(() => {
    const el = target || window;
    el.removeEventListener('scroll', handler as any);
  });

  return { x, y };
}

3.9 useToggle

typescript
import { ref, type Ref } from 'vue';

export function useToggle(initial = false): [Ref<boolean>, () => void, (val: boolean) => void] {
  const value = ref(initial);
  const toggle = () => (value.value = !value.value);
  const set = (val: boolean) => (value.value = val);
  return [value, toggle, set];
}

// 使用
const [visible, toggleVisible, setVisible] = useToggle(false);

四、业务级 composables

4.1 useTable - 表格通用逻辑

typescript
// composables/useTable.ts
import { ref, computed } from 'vue';

interface PageQuery {
  page: number;
  pageSize: number;
}

export function useTable<T>(
  fetcher: (query: PageQuery) => Promise<{ items: T[]; total: number }>,
  initialPageSize = 10
) {
  const items = ref<T[]>([]);
  const total = ref(0);
  const loading = ref(false);
  const error = ref<Error | null>(null);
  const page = ref(1);
  const pageSize = ref(initialPageSize);

  const totalPages = computed(() => Math.ceil(total.value / pageSize.value));

  async function load() {
    loading.value = true;
    error.value = null;
    try {
      const res = await fetcher({ page: page.value, pageSize: pageSize.value });
      items.value = res.items;
      total.value = res.total;
    } catch (e) {
      error.value = e as Error;
    } finally {
      loading.value = false;
    }
  }

  function nextPage() {
    if (page.value < totalPages.value) page.value++;
  }

  function prevPage() {
    if (page.value > 1) page.value--;
  }

  function refresh() {
    return load();
  }

  // 初始化加载
  load();

  return {
    items,
    total,
    loading,
    error,
    page,
    pageSize,
    totalPages,
    nextPage,
    prevPage,
    refresh
  };
}
vue
<script setup>
const { items, loading, page, totalPages, nextPage, prevPage } = useTable(fetchUsers);
</script>

4.2 useForm - 表单管理

typescript
// composables/useForm.ts
import { reactive, computed } from 'vue';

type Validator<T> = (value: T) => string | null;
type Rules<T> = { [K in keyof T]?: Validator<T[K]> };

export function useForm<T extends object>(
  initial: T,
  rules: Rules<T> = {}
) {
  const values = reactive({ ...initial }) as T;
  const errors = reactive<Record<string, string>>({} as any);
  const touched = reactive<Record<string, boolean>>({} as any);

  function validateField(field: keyof T) {
    const validator = rules[field];
    if (!validator) return;
    const error = validator(values[field]);
    errors[field as string] = error || '';
  }

  function validateAll() {
    let valid = true;
    for (const field in rules) {
      validateField(field);
      if (errors[field]) valid = false;
    }
    return valid;
  }

  function reset() {
    Object.assign(values, initial);
    for (const key in errors) delete errors[key];
    for (const key in touched) delete touched[key];
  }

  const isValid = computed(() =>
    Object.values(errors).every((e) => !e)
  );

  return {
    values,
    errors,
    touched,
    isValid,
    validateField,
    validateAll,
    reset
  };
}

4.3 useAuth - 认证管理

typescript
// composables/useAuth.ts
import { ref, computed } from 'vue';

const user = ref<User | null>(null);
const token = ref<string>(localStorage.getItem('token') || '');

const isLoggedIn = computed(() => !!token.value);

export function useAuth() {
  async function login(credentials: LoginDTO) {
    const res = await api.login(credentials);
    user.value = res.data.user;
    token.value = res.data.token;
    localStorage.setItem('token', token.value);
  }

  function logout() {
    user.value = null;
    token.value = '';
    localStorage.removeItem('token');
  }

  return {
    user,
    token,
    isLoggedIn,
    login,
    logout
  };
}

五、composable 组合

5.1 嵌套使用

typescript
// 一个 composable 可以用其他 composables
export function useUser() {
  const { data, loading } = useFetch<User>('/api/me');
  return { user: data, loading };
}

export function useUserPosts() {
  const { user } = useUser();
  return useFetch<Post[]>(() => `/api/users/${user.value?.id}/posts`);
}

5.2 业务模块封装

typescript
// composables/useUserModule.ts
import { useFetch } from './useFetch';
import { useLocalStorage } from './useLocalStorage';

export function useUserModule() {
  const token = useLocalStorage('token', '');
  const { data: user, execute: refreshUser } = useFetch<User>('/api/me', {
    immediate: !!token.value
  });

  async function login(credentials: LoginDTO) {
    const res = await api.login(credentials);
    token.value = res.data.token;
    await refreshUser();
  }

  function logout() {
    token.value = '';
  }

  return { user, token, login, logout, refreshUser };
}

六、TypeScript 技巧

6.1 MaybeRef 类型

typescript
import type { Ref } from 'vue';

type MaybeRef<T> = T | Ref<T>;

export function useTitle(title: MaybeRef<string>) {
  // 接受 ref 或普通值
  const titleRef = isRef(title) ? title : ref(title);
  // ...
}

6.2 函数重载

typescript
// 重载 1:接受 ref
export function useDebounce<T>(value: Ref<T>, delay?: number): Ref<T>;

// 重载 2:接受 getter
export function useDebounce<T>(getter: () => T, delay?: number): Ref<T>;

// 实现
export function useDebounce(value: any, delay = 300) {
  // ...
}

6.3 泛型推断

typescript
// ✅ 自动推断类型
const { data } = useFetch<User>('/api/users');
// data.value: User | null

// ✅ 不传泛型时显式标注
const { data } = useFetch('/api/users') as { data: Ref<User | null> };

七、测试 composables

7.1 单元测试

typescript
// useCounter.test.ts
import { useCounter } from '@/composables/useCounter';

describe('useCounter', () => {
  it('应该从初始值开始', () => {
    const { count } = useCounter(10);
    expect(count.value).toBe(10);
  });

  it('应该能自增', () => {
    const { count, increment } = useCounter(0);
    increment();
    expect(count.value).toBe(1);
  });
});

7.2 组件测试

typescript
import { mount } from '@vue/test-utils';
import { defineComponent } from 'vue';
import { useMouse } from '@/composables/useMouse';

it('useMouse in component', () => {
  const Component = defineComponent({
    setup() {
      const { x, y } = useMouse();
      return { x, y };
    },
    template: '<div>{{ x }},{{ y }}</div>'
  });

  const wrapper = mount(Component);
  expect(wrapper.text()).toBe('0,0');
});

八、composable 库推荐

特点
VueUse100+ 工具函数,必备
Pinia状态管理(类似 composable)
Vue Query服务端状态管理
Vue Router路由相关 composable

九、本章小结

原则关键
命名useXxx
输入接受 ref 或 getter
输出返回多个 ref 或 reactive 对象
副作用封装在内部,自动清理
可组合composable 之间可嵌套
TypeScript类型安全,泛型推断

动手练习

  1. useToggle:实现一个开关切换的 composable
  2. useFetch:完善 fetch composable,支持重试和取消
  3. useForm:实现完整的表单管理 composable
  4. useTable:实现带分页、排序、筛选的表格 composable

推荐阅读


下一章第 126 章:Vue Router 路由管理

本站基于 VitePress 构建 · 由 Codebook 团队维护