vue3组合式函数(Composables)的艺术——将业务逻辑从UI组件中连根拔起

上一篇文章我们把目录按 DDD 拆得很漂亮,但打开组件一看,<script setup>里还是塞满了接口请求、表单校验、定时器清理……这不就是换了个地方的意大利面吗?

目录结构只是物理层面的解耦,真正的灵魂解耦,在于你如何组织业务逻辑。​ 而 Vue3 给出的答案,就是组合式函数(Composables)。

这篇文章,我将带你彻底掌握 Composables 的设计哲学与实战技巧,让你的组件变成纯粹的"视图壳",让业务逻辑拥有独立的生命。

一、 为什么你的<script setup>迟早会爆炸?

先来看一段真实项目中"进化"而来的代码:

<!-- UserProfile.vue - 第 1 周 --> <script setup lang="ts"> import { ref, onMounted } from 'vue' const user = ref(null) const loading = ref(false) const fetchUser = async () => { loading.value = true user.value = await fetch('/api/user').then(r => r.json()) loading.value = false } onMounted(fetchUser) </script>

看起来很清爽,对吧?好,接下来需求来了:

  • 第 2 周:PM 说"加个编辑功能"。你加了isEditingeditFormupdateUser()

  • 第 3 周:后端说"接口要鉴权"。你加了 token 读取、401 拦截、自动刷新 token。

  • 第 4 周:QA 说"快速切换用户时会闪屏"。你加了 AbortController 取消重复请求。

  • 第 6 周:PM 说"加个防抖保存"。你加了 debounce 逻辑。

  • 第 8 周:新需求"在其他页面也要展示用户信息"。你复制粘贴了 200 行代码到AnotherPage.vue

8 周后,这个组件变成了这样:

<script setup lang="ts"> import { ref, onMounted, computed, watch, onUnmounted } from 'vue' import { useRoute } from 'vue-router' import { debounce } from 'lodash-es' import { useUserStore } from '@/app/store' // 15 个 ref/reactive // 8 个 computed // 12 个方法 // 60+ 行生命周期钩子 // 300+ 行代码 // 祝你好运,新来的同事。 </script>

这就是所谓的"上帝组件"(God Component)。它什么都管,什么都做,什么都改不了——因为一动就炸。


二、 破局之道:什么是组合式函数?

Vue 官方对 Composables 的定义是:

组合式函数是一个利用 Vue 组合式 API 来封装和复用有状态逻辑的函数。

用人话说就是:把组件里那些"跟 DOM 无关的、处理数据和行为的代码"抽出来,变成一个独立的函数。

它的签名长这样:

// 约定俗成:以 use 开头 export function useXxx(options?: Options): UseXxxReturn { // 内部可以使用 ref, reactive, computed, watch, 生命周期钩子... // 返回响应式数据和方法 }

和普通函数的本质区别

普通工具函数

组合式函数

是否有状态

❌ 无状态(纯函数)

✅ 有状态(ref/reactive)

是否用 Vue API

❌ 不用

✅ 大量使用

是否感知生命周期

❌ 不感知

✅ 自动跟随调用者

是否可复用

到处可用

只能在setup()或另一个 Composable 中调用

一句话总结:Composables 是"带着 Vue 超能力的逻辑胶囊"。


三、 从零开始:把业务逻辑从组件中连根拔起

我们用一个真实的业务场景来演示完整的重构过程。

场景:订单列表页

需求如下:

  1. 分页加载订单列表

  2. 支持按状态筛选

  3. 支持下拉刷新和上拉加载更多

  4. 自动轮询(每 30 秒刷新一次)

  5. 组件卸载时停止轮询

  6. 请求出错时显示错误信息,支持重试

Before:上帝组件版本
<!-- OrderList.vue - 上帝组件 --> <script setup lang="ts"> import { ref, onMounted, onUnmounted, watch } from 'vue' import { fetchOrders } from '@/features/order/api' const orders = ref([]) const loading = ref(false) const error = ref<string | null>(null) const page = ref(1) const pageSize = 10 const statusFilter = ref<string>('all') const hasMore = ref(true) let pollTimer: ReturnType<typeof setInterval> | null = null const loadOrders = async (reset = false) => { if (loading.value) return loading.value = true error.value = null try { const params = { page: reset ? 1 : page.value, pageSize, status: statusFilter.value === 'all' ? undefined : statusFilter.value } const res = await fetchOrders(params) orders.value = reset ? res.list : [...orders.value, ...res.list] hasMore.value = res.list.length === pageSize if (!reset) page.value++ } catch (e: any) { error.value = e.message || '加载失败' } finally { loading.value = false } } const retry = () => loadOrders(true) const refresh = () => loadOrders(true) const loadMore = () => { if (!hasMore.value || loading.value) return loadOrders() } watch(statusFilter, () => loadOrders(true)) onMounted(() => { loadOrders(true) pollTimer = setInterval(() => loadOrders(true), 30000) }) onUnmounted(() => { if (pollTimer) clearInterval(pollTimer) }) </script> <template> <!-- 模板略,反正 script 已经有 60+ 行了 --> </template>

问题诊断:

  • 组件既要管数据获取、又要管分页、还要管轮询、还要管错误处理

  • 如果订单详情页也需要"加载订单"的逻辑,只能复制粘贴

  • 无法单独对业务逻辑进行测试

  • onUnmounted里忘记清 timer?内存泄漏。

After:Composable 解耦版本

Step 1:抽离业务逻辑

// src/features/order/composables/useOrderList.ts import { ref, watch, onUnmounted } from 'vue' import { fetchOrders } from '../api' import type { Order, OrderStatus } from '@/entities/Order/types' export interface UseOrderListOptions { pageSize?: number pollInterval?: number // 0 表示不轮询 } export function useOrderList(options: UseOrderListOptions = {}) { const { pageSize = 10, pollInterval = 0 } = options // 状态 const orders = ref<Order[]>([]) const loading = ref(false) const error = ref<string | null>(null) const page = ref(1) const hasMore = ref(true) const statusFilter = ref<OrderStatus | 'all'>('all') let pollTimer: ReturnType<typeof setInterval> | null = null // 核心方法:加载数据 const loadOrders = async (reset = false) => { if (loading.value) return loading.value = true error.value = null const currentPage = reset ? 1 : page.value try { const res = await fetchOrders({ page: currentPage, pageSize, status: statusFilter.value === 'all' ? undefined : statusFilter.value }) orders.value = reset ? res.list : [...orders.value, ...res.list] hasMore.value = res.list.length === pageSize if (!reset && res.list.length > 0) { page.value++ } } catch (e: any) { error.value = e.message || '加载失败' } finally { loading.value = false } } // 对外暴露的操作方法 const retry = () => loadOrders(true) const refresh = () => loadOrders(true) const loadMore = () => { if (!hasMore.value || loading.value) return loadOrders() } // 筛选条件变化时重置列表 watch(statusFilter, () => loadOrders(true)) // 自动轮询 if (pollInterval > 0) { const startPoll = () => { pollTimer = setInterval(() => loadOrders(true), pollInterval) } // 立即加载 + 启动轮询 loadOrders(true).then(() => { startPoll() }) } else { // 不轮询,只加载一次 loadOrders(true) } // 自动清理:组件卸载时停止轮询 onUnmounted(() => { if (pollTimer) { clearInterval(pollTimer) pollTimer = null } }) return { // 数据 orders, loading, error, hasMore, statusFilter, // 方法 retry, refresh, loadMore, } }

Step 2:组件变成纯粹的"视图壳"

<!-- OrderList.vue - 瘦组件 --> <script setup lang="ts"> import { useOrderList } from '@features/order/composables/useOrderList' const { orders, loading, error, hasMore, statusFilter, retry, refresh, loadMore, } = useOrderList({ pageSize: 10, pollInterval: 30000 }) </script> <template> <div class="order-list-page"> <!-- 筛选器 --> <div class="filters"> <select v-model="statusFilter"> <option value="all">全部</option> <option value="pending">待付款</option> <option value="paid">已付款</option> <option value="shipped">已发货</option> </select> <button @click="refresh" :disabled="loading">刷新</button> </div> <!-- 错误状态 --> <div v-if="error" class="error-state"> <p>{{ error }}</p> <button @click="retry">重试</button> </div> <!-- 加载中 --> <div v-else-if="loading && orders.length === 0" class="loading"> 加载中... </div> <!-- 空状态 --> <div v-else-if="orders.length === 0" class="empty"> 暂无订单 </div> <!-- 订单列表 --> <div v-else class="order-list"> <div v-for="order in orders" :key="order.id" class="order-card" > <h3>订单号:{{ order.id }}</h3> <p>状态:{{ order.status }}</p> <p>金额:¥{{ order.total }}</p> </div> <!-- 加载更多 --> <div v-if="hasMore" class="load-more"> <button @click="loadMore" :disabled="loading"> {{ loading ? '加载中...' : '加载更多' }} </button> </div> </div> </div> </template>

对比一下:

Before(上帝组件)

After(解耦后)

Script 行数

60+ 行

15 行

业务逻辑在哪

混在组件里

独立的useOrderList.ts

可复用性

复制粘贴

import 一下就行

可测试性

需要挂载组件

直接调用函数测试

内存泄漏风险

手动清理,容易忘

onUnmounted 自动处理

这就是"连根拔起"的力量。


四、 进阶心法:写出工业级 Composable 的 5 条军规

军规 1:单一职责——一个 Composable 只做一件事

反例:一个useUser什么都管

// 又管登录、又管信息获取、又管权限判断、又管头像上传 export function useUser() { // 200 行代码... }

正解:拆成多个专注的 Composable

// 登录逻辑 export function useLogin() { /* ... */ } // 用户信息 export function useUserProfile() { /* ... */ } // 权限判断 export function usePermission() { /* ... */ } // 头像上传 export function useAvatarUpload() { /* ... */ }

然后在需要的地方自由组合:

// 在组件中 const { login } = useLogin() const { user, updateProfile } = useUserProfile() const { hasPermission } = usePermission()

组合优于继承,这是 Composables 的核心哲学。


军规 2:生命周期自治——自己注册,自己清理

一个好的 Composable 应该像"即插即用"的插件,不需要调用者操心它的生命周期。

// src/shared/composables/useEventListener.ts import { onMounted, onUnmounted } from 'vue' export function useEventListener( target: EventTarget, event: string, handler: EventListener, options?: AddEventListenerOptions ) { onMounted(() => { target.addEventListener(event, handler, options) }) onUnmounted(() => { target.removeEventListener(event, handler, options) }) }

使用方完全不需要关心"什么时候移除监听器":

// 在组件中 useEventListener(window, 'resize', () => { console.log('窗口大小变了') }) // ✅ 组件销毁时自动移除,零内存泄漏风险

军规 3:返回值的接口设计——克制地暴露

Composable 内部可能有 10 个 ref,但对外只需要暴露 3 个。不要一股脑全返回。

// ❌ 暴露太多内部细节 export function useOrderList() { const internalPage = ref(1) const internalCache = new Map() return { orders, loading, error, internalPage, // ❌ 外部不应该碰这个 internalCache, // ❌ 也不应该碰这个 loadOrders, } }
// ✅ 只暴露必要的 export function useOrderList() { const page = ref(1) const cache = new Map() // 内部方法,不导出 const preloadNextPage = () => { /* ... */ } return { orders, loading, error, loadMore: () => { /* 内部用 page,外部只管调用 */ }, refresh, } }

记住:返回值是你的 API 契约,越少越好,越稳定越好。


军规 4:参数设计——用 Options 对象代替多参数

// ❌ 参数一多就懵逼 export function usePagination( url: string, pageSize: number, autoLoad: boolean, pollInterval: number, debounceDelay: number, transformResponse: (raw: any) => any, onSuccess?: (data: any) => void, onError?: (err: Error) => void, ) { /* ... */ }
// ✅ 一个 Options 对象搞定,清晰、可扩展、向后兼容 export interface UsePaginationOptions<T> { url: string pageSize?: number // 可选参数都有默认值 autoLoad?: boolean pollInterval?: number debounceDelay?: number transformResponse?: (raw: any) => T[] onSuccess?: (data: T[]) => void onError?: (err: Error) => void } export function usePagination<T>(options: UsePaginationOptions<T>) { const { url, pageSize = 10, autoLoad = true, pollInterval = 0, debounceDelay = 300, transformResponse = (raw) => raw, onSuccess, onError, } = options // ... }

军规 5:错误处理——不要让异常"静默消失"

// ❌ 吞掉错误 const loadData = async () => { try { data.value = await fetchData() } catch { // 啥也不做,调试时一脸懵逼 } }
// ✅ 提供多层级的错误处理 export function useAsyncTask<T>( task: () => Promise<T>, options?: { immediate?: boolean onError?: (err: Error) => void } ) { const data = ref<T | null>(null) const loading = ref(false) const error = ref<Error | null>(null) const execute = async () => { loading.value = true error.value = null try { data.value = await task() } catch (e) { const err = e instanceof Error ? e : new Error(String(e)) error.value = err options?.onError?.(err) // 回调通知 // 不 re-throw,让调用者通过 error.value 感知 } finally { loading.value = false } } if (options?.immediate) { execute() } return { data, loading, error, execute } }

五、 高阶实战:3 个企业级 Composable 设计模式

模式 1:工厂模式——动态创建实例

当你需要同时管理多个同类资源时(比如同时打开多个 WebSocket 连接):

// src/shared/composables/useWebSocket.ts import { ref, onUnmounted } from 'vue' export function createWebSocketFactory() { const connections = new Map<string, WebSocket>() const connect = (url: string, protocols?: string | string[]) => { const ws = new WebSocket(url, protocols) connections.set(url, ws) const status = ref<'connecting' | 'open' | 'closed' | 'error'>('connecting') const messages = ref<any[]>([]) ws.onopen = () => { status.value = 'open' } ws.onmessage = (e) => { messages.value.push(JSON.parse(e.data)) } ws.onerror = () => { status.value = 'error' } ws.onclose = () => { status.value = 'closed' } return { status, messages, send: (data: any) => ws.send(JSON.stringify(data)) } } // 统一清理 onUnmounted(() => { connections.forEach(ws => ws.close()) connections.clear() }) return { connect } } // 使用 const { connect } = createWebSocketFactory() const chatWs = connect('wss://api.example.com/chat') const notifyWs = connect('wss://api.example.com/notify')

模式 2:组合模式——Composable 调用 Composable

Composable 之间可以相互组合,形成逻辑管道:

// src/features/order/composables/useOrderDetail.ts import { ref, watch } from 'vue' import { useRoute } from 'vue-router' import { useAsyncTask } from '@shared/composables/useAsyncTask' import { fetchOrderDetail } from '../api' export function useOrderDetail() { const route = useRoute() const orderId = ref(route.params.id as string) // 组合 useAsyncTask const { data: order, loading, error, execute: fetchDetail } = useAsyncTask( () => fetchOrderDetail(orderId.value), { immediate: true } ) // 路由参数变化时重新加载 watch( () => route.params.id, (newId) => { orderId.value = newId as string fetchDetail() } ) return { order, loading, error, refetch: fetchDetail } }

模式 3:Provide/Inject 模式——跨层级共享逻辑

当多个深层嵌套的组件需要同一份逻辑时,不要一层层 prop drilling:

// src/features/order/composables/useOrderContext.ts import { provide, inject, ref, type InjectionKey } from 'vue' import type { Order } from '@/entities/Order/types' interface OrderContext { selectedOrders: Ref<Order[]> toggleSelect: (order: Order) => void clearSelection: () => void } const OrderContextKey = Symbol('OrderContext') as InjectionKey<OrderContext> // 父组件调用:provide 上下文 export function provideOrderContext() { const selectedOrders = ref<Order[]>([]) const toggleSelect = (order: Order) => { const index = selectedOrders.value.findIndex(o => o.id === order.id) if (index > -1) { selectedOrders.value.splice(index, 1) } else { selectedOrders.value.push(order) } } const clearSelection = () => { selectedOrders.value = [] } const context: OrderContext = { selectedOrders, toggleSelect, clearSelection, } provide(OrderContextKey, context) return context } // 子组件调用:inject 上下文 export function useOrderContext() { const context = inject(OrderContextKey) if (!context) { throw new Error('useOrderContext must be used within a component that provides OrderContext') } return context }

父组件:

<script setup> import { provideOrderContext } from '@features/order/composables/useOrderContext' provideOrderContext() </script>

深层子组件(无论嵌套多深):

<script setup> import { useOrderContext } from '@features/order/composables/useOrderContext' const { selectedOrders, toggleSelect } = useOrderContext() </script>

六、 避坑指南:Composables 的 4 个致命陷阱

❌ 陷阱 1:在条件语句中调用 Composable

// ❌ 致命错误!Vue 会报警告 if (props.type === 'admin') { const { user } = useUserProfile() // ❌ 不要在 if 里调用 }

原因:Composable 内部使用了getCurrentInstance()来绑定生命周期,条件调用会导致实例丢失。

正确做法:始终在顶层调用,用参数控制行为:

// ✅ 始终在顶层调用 const { user } = useUserProfile({ enabled: props.type === 'admin' // 内部根据这个参数决定是否发请求 })

❌ 陷阱 2:在普通 JS 文件(非 setup 上下文)中调用

// utils/someHelper.ts import { useUserStore } from '@/app/store' export const checkPermission = (role: string) => { const store = useUserStore() // ❌ 这里没有 Vue 组件实例! return store.user.role === role }

正确做法:把逻辑抽成纯函数,或者在 Composable 内部调用:

// entities/User/utils.ts import type { User } from './types' export const checkPermission = (user: User, role: string): boolean => { return user.role === role } // 在组件中 const { user } = useUserProfile() const hasAccess = computed(() => checkPermission(user.value, 'admin'))

❌ 陷阱 3:共享状态不小心变成了"全局污染"

// ❌ 模块级别的 ref 会被所有调用者共享! import { ref } from 'vue' const count = ref(0) // 所有组件共用同一个 count! export function useCounter() { return { count } }

正确做法:在函数内部创建,每次调用都是独立实例:

// ✅ 每次调用都创建新的 ref export function useCounter(initial = 0) { const count = ref(initial) const inc = () => count.value++ return { count, inc } }

如果需要真正的全局共享状态?​ 那应该用 Pinia,而不是 Composable。


❌ 陷阱 4:过度抽象——为了"优雅"而牺牲可读性

// ❌ 过度抽象:一个简单的值变更,硬套三层 Composable export function useField(initial: string) { const value = ref(initial) const setValue = (v: string) => value.value = v return { value, setValue } } export function useValidatedField(initial: string, validator: (v: string) => boolean) { const field = useField(initial) const valid = computed(() => validator(field.value.value)) return { ...field, valid } } export function useRequiredField(initial: string) { return useValidatedField(initial, v => v.trim().length > 0) } // 使用 const { value, setValue, valid } = useRequiredField('')
// ✅ 简单场景直接写,反而更清晰 const username = ref('') const isValid = computed(() => username.value.trim().length > 0)

记住:Composables 是为了解决"复用"和"解耦"问题,不是为了炫技。


七、 总结

回顾一下本文的核心要点:

  1. <script setup>膨胀的本质是"视图与逻辑耦合",Composables 是解药

  2. 一个 Composable 只做一件事,通过组合来构建复杂逻辑

  3. 生命周期自治:自己注册、自己清理,调用者零负担

  4. 返回值要克制:只暴露必要的接口,隐藏内部实现

  5. 参数用 Options 对象:清晰、可扩展、向后兼容

  6. 不要在条件语句中调用 Composable,始终在顶层调用

  7. 模块级状态 ≠ 全局状态,需要全局共享请用 Pinia

目录结构是骨架,Composables 是肌肉,组件是皮肤。​ 三者配合,才能构建出真正健壮的大型 Vue3 应用。

🔥 下期预告:《状态管理的终极形态——Pinia 架构设计与跨模块通信的最佳实践》

你将学到:

  • Setup Syntax 风格的 Pinia Store 如何与 Composables 完美配合

  • Store 之间如何做到零耦合通信

  • 如何用 Pinia Plugin 实现自动 loading 管理和多 Tab 状态同步