上一篇文章我们把目录按 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 | ❌ 不用 | ✅ 大量使用 |
| 是否感知生命周期 | ❌ 不感知 | ✅ 自动跟随调用者 |
| 是否可复用 | 到处可用 | 只能在 setup()或另一个 Composable 中调用 |
一句话总结: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 行 |
| 业务逻辑在哪 | 混在组件里 | **独立的 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 是为了解决"复用"和"解耦"问题,不是为了炫技。
七、 总结
回顾一下本文的核心要点:
-
<script setup>膨胀的本质是"视图与逻辑耦合",Composables 是解药 -
一个 Composable 只做一件事,通过组合来构建复杂逻辑
-
生命周期自治:自己注册、自己清理,调用者零负担
-
返回值要克制:只暴露必要的接口,隐藏内部实现
-
参数用 Options 对象:清晰、可扩展、向后兼容
-
不要在条件语句中调用 Composable,始终在顶层调用
-
模块级状态 ≠ 全局状态,需要全局共享请用 Pinia
**目录结构是骨架,Composables 是肌肉,组件是皮肤。** 三者配合,才能构建出真正健壮的大型 Vue3 应用。
🔥 下期预告:《状态管理的终极形态------Pinia 架构设计与跨模块通信的最佳实践》
你将学到:
-
Setup Syntax 风格的 Pinia Store 如何与 Composables 完美配合
-
Store 之间如何做到零耦合通信
-
如何用 Pinia Plugin 实现自动 loading 管理和多 Tab 状态同步