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

一、选项式API的困局:逻辑分散与复用壁垒
Vue2的选项式API在小型项目中足够清晰,但当一个组件膨胀到500行以上时,问题就暴露了。一个用户管理页面,data里散落着用户信息、分页状态、搜索条件,methods里混杂着CRUD操作、表单校验、权限判断,watch和computed穿插其间。同一个业务逻辑的代码被选项式结构强制拆散到不同区块,阅读时需要在不同选项间反复跳转。
更致命的是复用问题。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是工具,不是目的。当抽象不能带来复用收益时,保持简单才是最好的选择。
更多推荐
所有评论(0)