Vue3组合式API:构建可复用业务逻辑的架构模式与实践

cover

一、选项式API的困局:逻辑分散与复用壁垒

Vue2的选项式API在小型项目中足够清晰,但当一个组件膨胀到500行以上时,问题就暴露了。一个用户管理页面,data里散落着用户信息、分页状态、搜索条件,methods里混杂着CRUD操作、表单校验、权限判断,watchcomputed穿插其间。同一个业务逻辑的代码被选项式结构强制拆散到不同区块,阅读时需要在不同选项间反复跳转。

更致命的是复用问题。Mixin是Vue2唯一的逻辑复用机制,但它有三个硬伤:命名冲突(多个Mixin可能定义同名属性)、来源不清晰(模板里用的变量不知道来自哪个Mixin)、类型推导困难(TypeScript对Mixin的支持几乎为零)。

组合式API的出现,本质上是为了解决"逻辑关注点分离"的问题——让相关的代码聚合在一起,让可复用的逻辑独立于组件存在。

二、Composable的架构分层:从原子操作到业务编排

一个成熟的Vue3项目,其Composable应该按照职责分为三层:原子层、组合层、编排层。每层有明确的边界和依赖方向。

graph TB
    subgraph 编排层
        O1[useUserPage] --> O2[useOrderPage]
    end

    subgraph 组合层
        C1[useTable] --> C2[useForm]
        C3[useAuth] --> C4[usePermission]
    end

    subgraph 原子层
        A1[useRequest] --> A2[useLocalStorage]
        A3[useDebounce] --> A4[useEventBus]
    end

    O1 --> C1
    O1 --> C2
    O1 --> C3
    O2 --> C1
    O2 --> C4
    C1 --> A1
    C2 --> A1
    C3 --> A2

    style O1 fill:#722ed1,color:#fff
    style O2 fill:#722ed1,color:#fff
    style C1 fill:#1890ff,color:#fff
    style C2 fill:#1890ff,color:#fff
    style C3 fill:#1890ff,color:#fff
    style C4 fill:#1890ff,color:#fff
    style A1 fill:#52c41a,color:#fff
    style A2 fill:#52c41a,color:#fff
    style A3 fill:#52c41a,color:#fff
    style A4 fill:#52c41a,color:#fff

原子层提供最基础的能力:HTTP请求封装、本地存储操作、防抖节流、事件总线。它们不包含任何业务逻辑,是纯技术工具。组合层将原子层的能力组合成业务通用的模式:表格分页、表单校验、权限判断。编排层面向具体页面,将组合层的模块编排成完整的页面逻辑。

依赖方向严格自上而下:编排层依赖组合层,组合层依赖原子层。禁止反向依赖和跨层调用。

三、生产级Composable实现:表格与表单的通用模式

以下是经过多个项目验证的通用表格Composable,支持分页、搜索、排序和加载状态管理:

// composables/atoms/useRequest.ts
// 原子层:HTTP请求封装,处理加载状态和错误
import { ref, type Ref } from 'vue'

interface RequestState<T> {
  data: Ref<T | null>
  loading: Ref<boolean>
  error: Ref<Error | null>
}

export function useRequest<T>(
  requestFn: (...args: any[]) => Promise<T>
) {
  const data = ref<T | null>(null) as Ref<T | null>
  const loading = ref(false)
  const error = ref<Error | null>(null)

  async function execute(...args: any[]): Promise<T | null> {
    loading.value = true
    error.value = null
    try {
      const result = await requestFn(...args)
      data.value = result
      return result
    } catch (e) {
      error.value = e instanceof Error ? e : new Error(String(e))
      return null
    } finally {
      loading.value = false
    }
  }

  return { data, loading, error, execute }
}

// composables/composed/useTable.ts
// 组合层:通用表格逻辑,整合分页、搜索、排序
import { reactive, computed, watch } from 'vue'
import { useRequest } from '../atoms/useRequest'
import { useDebounce } from '../atoms/useDebounce'

interface Pagination {
  page: number
  pageSize: number
  total: number
}

interface SearchParams {
  keyword: string
  sortBy: string
  sortOrder: 'asc' | 'desc'
}

interface TableOptions<T, P> {
  // 数据请求函数,接收分页和搜索参数
  fetchFn: (pagination: Pagination, search: SearchParams, extra?: P) => Promise<{
    list: T[]
    total: number
  }>
  // 默认分页大小
  defaultPageSize?: number
  // 搜索防抖延迟(毫秒)
  debounceMs?: number
}

export function useTable<T, P = void>(options: TableOptions<T, P>) {
  const { fetchFn, defaultPageSize = 20, debounceMs = 300 } = options

  // 分页状态
  const pagination = reactive<Pagination>({
    page: 1,
    pageSize: defaultPageSize,
    total: 0,
  })

  // 搜索状态
  const search = reactive<SearchParams>({
    keyword: '',
    sortBy: '',
    sortOrder: 'desc',
  })

  // 使用原子层封装请求
  const { data, loading, error, execute } = useRequest(fetchFn)

  // 列表数据计算属性
  const list = computed(() => data.value?.list ?? [])

  // 防抖搜索
  const debouncedFetch = useDebounce(fetchData, debounceMs)

  // 核心数据加载函数
  async function fetchData(extra?: P) {
    const result = await execute(pagination, search, extra)
    if (result) {
      pagination.total = result.total
    }
  }

  // 页码变更
  function handlePageChange(page: number) {
    pagination.page = page
    fetchData()
  }

  // 每页条数变更
  function handleSizeChange(size: number) {
    pagination.pageSize = size
    pagination.page = 1 // 切换条数时重置页码
    fetchData()
  }

  // 排序变更
  function handleSortChange(sortBy: string, sortOrder: 'asc' | 'desc') {
    search.sortBy = sortBy
    search.sortOrder = sortOrder
    pagination.page = 1
    fetchData()
  }

  // 搜索关键词变更(防抖)
  function handleSearch(keyword: string) {
    search.keyword = keyword
    pagination.page = 1
    debouncedFetch()
  }

  // 重置所有状态
  function reset() {
    search.keyword = ''
    search.sortBy = ''
    search.sortOrder = 'desc'
    pagination.page = 1
    fetchData()
  }

  return {
    // 状态
    list,
    loading,
    error,
    pagination,
    search,
    // 方法
    fetchData,
    handlePageChange,
    handleSizeChange,
    handleSortChange,
    handleSearch,
    reset,
  }
}

编排层使用示例——一个用户管理页面只需要几行代码:

// composables/orchestration/useUserPage.ts
import { useTable } from '../composed/useTable'
import { fetchUserList } from '@/api/user'

export function useUserPage() {
  const table = useTable<UserItem, void>({
    fetchFn: fetchUserList,
    defaultPageSize: 15,
  })

  // 可以在此添加页面特有的逻辑
  // 比如导出、批量操作等

  return { ...table }
}

四、Composable的陷阱:过度抽象与隐式依赖

分层架构容易导致过度抽象。不是所有逻辑都需要抽成Composable——如果一个逻辑只在一个组件中使用一次,直接写在组件里更清晰。过早抽象比不抽象更危险,因为它增加了理解成本,却没有带来复用收益。

隐式依赖是另一个常见问题。Composable内部使用provide/inject获取全局状态,调用方无法从函数签名看出它依赖了什么。这导致测试困难,也容易在重构时遗漏依赖。更好的做法是将依赖通过参数显式传入,或者使用依赖注入容器统一管理。

禁用场景:SSR场景下依赖浏览器API的Composable(如useLocalStorage)需要在onMounted中延迟初始化;高频更新的响应式数据(如动画帧率、鼠标位置)不适合用Composable封装,因为ref的响应式开销会成为瓶颈;跨组件通信应该用事件总线或状态管理库,而不是通过Composable间接传递。

五、总结

Vue3组合式API的核心价值是逻辑关注点分离和可复用性。三层架构(原子层、组合层、编排层)提供了清晰的职责边界和依赖方向。生产实践中,useTable这类组合层Composable能将表格页面的重复代码减少80%以上。但要注意避免过度抽象和隐式依赖——Composable是工具,不是目的。当抽象不能带来复用收益时,保持简单才是最好的选择。

更多推荐