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

发布时间:2026/7/8 5:47:43
vue3组合式函数(Composables)的艺术——将业务逻辑从UI组件中连根拔起 上一篇文章我们把目录按 DDD 拆得很漂亮但打开组件一看script setup里还是塞满了接口请求、表单校验、定时器清理……这不就是换了个地方的意大利面吗目录结构只是物理层面的解耦真正的灵魂解耦在于你如何组织业务逻辑。​ 而 Vue3 给出的答案就是组合式函数Composables。这篇文章我将带你彻底掌握 Composables 的设计哲学与实战技巧让你的组件变成纯粹的视图壳让业务逻辑拥有独立的生命。一、 为什么你的script setup迟早会爆炸先来看一段真实项目中进化而来的代码!-- UserProfile.vue - 第 1 周 -- script setup langts 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 langts 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 超能力的逻辑胶囊。三、 从零开始把业务逻辑从组件中连根拔起我们用一个真实的业务场景来演示完整的重构过程。场景订单列表页需求如下分页加载订单列表支持按状态筛选支持下拉刷新和上拉加载更多自动轮询每 30 秒刷新一次组件卸载时停止轮询请求出错时显示错误信息支持重试Before上帝组件版本!-- OrderList.vue - 上帝组件 -- script setup langts import { ref, onMounted, onUnmounted, watch } from vue import { fetchOrders } from /features/order/api const orders ref([]) const loading ref(false) const error refstring | null(null) const page ref(1) const pageSize 10 const statusFilter refstring(all) const hasMore ref(true) let pollTimer: ReturnTypetypeof 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内存泄漏。AfterComposable 解耦版本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 refOrder[]([]) const loading ref(false) const error refstring | null(null) const page ref(1) const hasMore ref(true) const statusFilter refOrderStatus | all(all) let pollTimer: ReturnTypetypeof 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 langts import { useOrderList } from features/order/composables/useOrderList const { orders, loading, error, hasMore, statusFilter, retry, refresh, loadMore, } useOrderList({ pageSize: 10, pollInterval: 30000 }) /script template div classorder-list-page !-- 筛选器 -- div classfilters select v-modelstatusFilter option valueall全部/option option valuepending待付款/option option valuepaid已付款/option option valueshipped已发货/option /select button clickrefresh :disabledloading刷新/button /div !-- 错误状态 -- div v-iferror classerror-state p{{ error }}/p button clickretry重试/button /div !-- 加载中 -- div v-else-ifloading orders.length 0 classloading 加载中... /div !-- 空状态 -- div v-else-iforders.length 0 classempty 暂无订单 /div !-- 订单列表 -- div v-else classorder-list div v-fororder in orders :keyorder.id classorder-card h3订单号{{ order.id }}/h3 p状态{{ order.status }}/p p金额¥{{ order.total }}/p /div !-- 加载更多 -- div v-ifhasMore classload-more button clickloadMore :disabledloading {{ 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 UsePaginationOptionsT { url: string pageSize?: number // 可选参数都有默认值 autoLoad?: boolean pollInterval?: number debounceDelay?: number transformResponse?: (raw: any) T[] onSuccess?: (data: T[]) void onError?: (err: Error) void } export function usePaginationT(options: UsePaginationOptionsT) { 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 useAsyncTaskT( task: () PromiseT, options?: { immediate?: boolean onError?: (err: Error) void } ) { const data refT | null(null) const loading ref(false) const error refError | 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 Mapstring, WebSocket() const connect (url: string, protocols?: string | string[]) { const ws new WebSocket(url, protocols) connections.set(url, ws) const status refconnecting | open | closed | error(connecting) const messages refany[]([]) 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 调用 ComposableComposable 之间可以相互组合形成逻辑管道// 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 } }模式 3Provide/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: RefOrder[] toggleSelect: (order: Order) void clearSelection: () void } const OrderContextKey Symbol(OrderContext) as InjectionKeyOrderContext // 父组件调用provide 上下文 export function provideOrderContext() { const selectedOrders refOrder[]([]) 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 状态同步