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 状态同步

相关推荐
uuai1 小时前
vue导出pdf
前端·vue.js·pdf
小爷毛毛_卓寿杰1 小时前
我和 TRAE AI聊了聊,它帮我为五迷老师们做了个城市漫游小程序!
前端·人工智能·后端
liuniansilence1 小时前
一次讲透 H5 唤起原生 App:Scheme 协议、平台限制与生产级封装
前端·javascript
heiyehk1 小时前
我用AI不断迭代到7W行代码的项目-像素画编辑器
前端·架构·设计
用户938515635071 小时前
从“问答机器”到“智能体”:AI Agent 是如何自主工作的?
javascript·人工智能·全栈
weedsfly1 小时前
前端懒加载全家桶:图片、路由、组件的按需加载
前端·javascript·面试
千帆_Evan1 小时前
🤖 AI 写代码总在重复造轮子?我们把 Utils 复用门禁做进了 Agent 运行时(已开源)
前端·agent
Kel1 小时前
多 Tab 聊天不翻车:纯前端实现跨标签页消息同步的三层协同架构
前端·javascript·人工智能
半个落月1 小时前
Promise 入门:理解 async/await 与 Promise.all
前端·javascript