返回

Vue 3 Composition API 架构设计实践

Composable 设计模式、逻辑复用边界、与 Options API 的迁移策略及大型项目模块组织。

我们迁移 Vue 3 时,OrderList.vue 第一个版本把 Options API 原样塞进 setup()500 行单文件、三个 watch 互相触发。拆成 useOrderFilters + useOrderList + useOrderExport 后,单测覆盖率从 0 到 62%,bug 率明显下降。Composition API 是逻辑组织范式,不是大 setup 函数。

Composable 设计原则

一个好的 Composable 应该:

  1. 单一职责:一个 Composable 解决一个问题
  2. 显式输入输出:参数和返回值类型清晰
  3. 可组合:小 Composable 组合成大 Composable
  4. 副作用可控:明确何时创建/销毁资源
// ✅ 好的 Composable 设计
export function usePagination(options: {
  fetchFn: (page: number, size: number) => Promise<PaginatedResult>;
  pageSize?: number;
}) {
  const page = ref(1);
  const pageSize = ref(options.pageSize ?? 20);
  const total = ref(0);
  const data = ref<any[]>([]);
  const loading = ref(false);

  async function load() {
    loading.value = true;
    try {
      const result = await options.fetchFn(page.value, pageSize.value);
      data.value = result.items;
      total.value = result.total;
    } finally {
      loading.value = false;
    }
  }

  watch([page, pageSize], load, { immediate: true });

  return { page, pageSize, total, data, loading, refresh: load };
}

项目模块组织

src/
├── composables/          # 通用 Composable
│   ├── usePagination.ts
│   ├── usePermission.ts
│   └── useWebSocket.ts
├── features/             # 业务功能模块
│   ├── order/
│   │   ├── composables/useOrderForm.ts
│   │   ├── components/OrderForm.vue
│   │   └── api/orderApi.ts
│   └── user/
│       ├── composables/useUserProfile.ts
│       └── components/UserCard.vue

规则:通用 Composable 放 composables/,业务 Composable 放 features/*/composables/

与 React Hooks 的对比

维度Vue ComposableReact Hook
调用限制无限制(可在条件分支中调用)不可在条件/循环中调用
依赖追踪自动(ref/reactive)手动(deps 数组)
命名约定use 前缀use 前缀
状态隔离每次调用独立 ref每次调用独立 state

Vue Composable 的一个独特优势:可以在任何地方调用,不受 Hooks 规则限制。

Options API 迁移策略

不要一次性重写,按模块渐进迁移:

Phase 1: 新功能全部用 Composition API + <script setup>
Phase 2: 高频修改的旧模块逐步迁移
Phase 3: 稳定模块保持 Options API,不强制迁移

<script setup> 是推荐的默认写法:

<script setup lang="ts">
import { useOrderForm } from "./composables/useOrderForm";

const props = defineProps<{ orderId?: string }>();
const emit = defineEmits<{ submit: [order: Order] }>();

const { form, validate, submit, loading } = useOrderForm(props.orderId);

async function handleSubmit() {
  if (!(await validate())) return;
  const order = await submit();
  emit("submit", order);
}
</script>

常见反模式

  1. God Composable:一个 useApp() 返回所有状态 → 拆分为领域 Composable
  2. Composable 中直接操作 DOM → 封装为 useElementSize 等专用 Composable
  3. Composable 之间循环依赖 → 提取共享逻辑到第三个 Composable
  4. 忽略 cleanuponUnmounted 中清理定时器、事件监听、WebSocket

真实重构对比

Before:一个 setup 里 fetchOrdershandleFilterexportCsv、四个 watch

After

// features/order/composables/useOrderListPage.ts
export function useOrderListPage() {
  const filters = useOrderFilters(); // URL sync
  const { data, loading, refresh } = useOrderQuery(filters);
  const { exportCsv, exporting } = useOrderExport(filters);
  return { filters, data, loading, refresh, exportCsv, exporting };
}

页面组件只剩布局与事件绑定 < 80 行

测试策略

Composable 用 @vue/test-utils + vi.fn() mock API:

it('usePagination loads on mount', async () => {
  const fetchFn = vi.fn().mockResolvedValue({ items: [1], total: 1 });
  const { data, loading } = usePagination({ fetchFn });
  await flushPromises();
  expect(loading.value).toBe(false);
  expect(data.value).toEqual([1]);
});

业务 Composable 单测;纯展示组件用 Storybook。

为什么不全量迁移 Options API

Phase 3 稳定模块(如设置页)保持 Options API,ROI 不够。新功能强制 <script setup>,Code Review 拦截巨型 setup。