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 说"加个编辑功能"。你加了
isEditing、editForm、updateUser()。第 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 | ❌ 不用 | ✅ 大量使用 |
是否感知生命周期 | ❌ 不感知 | ✅ 自动跟随调用者 |
是否可复用 | 到处可用 | 只能在 |
一句话总结:Composables 是"带着 Vue 超能力的逻辑胶囊"。
三、 从零开始:把业务逻辑从组件中连根拔起
我们用一个真实的业务场景来演示完整的重构过程。
场景:订单列表页
需求如下:
分页加载订单列表
支持按状态筛选
支持下拉刷新和上拉加载更多
自动轮询(每 30 秒刷新一次)
组件卸载时停止轮询
请求出错时显示错误信息,支持重试
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 行 |
业务逻辑在哪 | 混在组件里 | 独立的 |
可复用性 | 复制粘贴 | 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 是为了解决"复用"和"解耦"问题,不是为了炫技。
七、 总结
回顾一下本文的核心要点:
<script setup>膨胀的本质是"视图与逻辑耦合",Composables 是解药一个 Composable 只做一件事,通过组合来构建复杂逻辑
生命周期自治:自己注册、自己清理,调用者零负担
返回值要克制:只暴露必要的接口,隐藏内部实现
参数用 Options 对象:清晰、可扩展、向后兼容
不要在条件语句中调用 Composable,始终在顶层调用
模块级状态 ≠ 全局状态,需要全局共享请用 Pinia
目录结构是骨架,Composables 是肌肉,组件是皮肤。 三者配合,才能构建出真正健壮的大型 Vue3 应用。
🔥 下期预告:《状态管理的终极形态——Pinia 架构设计与跨模块通信的最佳实践》
你将学到:
Setup Syntax 风格的 Pinia Store 如何与 Composables 完美配合
Store 之间如何做到零耦合通信
如何用 Pinia Plugin 实现自动 loading 管理和多 Tab 状态同步