Chapter 06

组合式函数 Composables

Composition API 最强大的能力 — 将有状态的逻辑提取为可复用的函数,彻底告别 Mixins 的混乱

6.1 什么是 Composable?

Composable(组合式函数)是利用 Vue 的 Composition API 封装和复用有状态逻辑的函数。它是 Vue 3 对 Vue 2 Mixins 的根本性替代方案。

Mixins 的缺陷

  • 属性来源不明确(不知道某属性来自哪个 mixin)
  • 命名冲突(多个 mixin 有同名属性)
  • 难以传递参数
  • 隐式依赖,耦合度高

Composables 的优势

  • 明确的来源(const { x } = useMouse()
  • 无命名冲突(通过解构重命名解决)
  • 像普通函数一样传参
  • 纯函数,无隐式依赖

6.2 编写第一个 Composable

Composable 就是一个以 use 开头的函数,内部使用 Composition API,返回响应式状态和方法:

import { ref, onMounted, onUnmounted } from 'vue'

// useXxx 命名约定
export function useMouse() {
  const x = ref(0)
  const y = ref(0)

  function update(e: MouseEvent) {
    x.value = e.pageX
    y.value = e.pageY
  }

  // 生命周期钩子在 composable 内部也可以使用
  onMounted(() => window.addEventListener('mousemove', update))
  onUnmounted(() => window.removeEventListener('mousemove', update))

  return { x, y }  // 返回响应式 refs
}
<script setup>
import { useMouse } from '@/composables/useMouse'
const { x, y } = useMouse()  // 来源清晰
</script>
<template>
  <p>鼠标位置:{{ x }}, {{ y }}</p>
</template>

6.3 实用 Composable 模式

useAsyncState — 异步数据加载

import { ref, shallowRef } from 'vue'

export function useAsyncState<T>(
  asyncFn: () => Promise<T>,
  defaultValue: T
) {
  const state     = shallowRef<T>(defaultValue)
  const isLoading = ref(false)
  const error     = ref<Error | null>(null)

  async function execute() {
    isLoading.value = true
    error.value = null
    try {
      state.value = await asyncFn()
    } catch (e) {
      error.value = e as Error
    } finally {
      isLoading.value = false
    }
  }

  execute()  // 立即执行
  return { state, isLoading, error, execute }
}

// 使用
const { state: users, isLoading, error, execute: refresh } = useAsyncState(
  () => fetch('/api/users').then(r => r.json()),
  []
)

useLocalStorage — 持久化状态

import { ref, watch } from 'vue'

export function useLocalStorage<T>(key: string, defaultValue: T) {
  const stored = localStorage.getItem(key)
  const data = ref<T>(stored ? JSON.parse(stored) : defaultValue)

  // 自动同步到 localStorage
  watch(data, (val) => {
    if (val === null) {
      localStorage.removeItem(key)
    } else {
      localStorage.setItem(key, JSON.stringify(val))
    }
  }, { deep: true })

  return data
}

// 使用:自动持久化的主题偏好
const theme = useLocalStorage<'light' | 'dark'>('theme', 'light')

useDebounce — 防抖

import { ref, watch } from 'vue'
import type { Ref } from 'vue'

export function useDebounce<T>(value: Ref<T>, delay = 300) {
  const debouncedValue = ref<T>(value.value) as Ref<T>

  let timer: ReturnType<typeof setTimeout>

  watch(value, (val) => {
    clearTimeout(timer)
    timer = setTimeout(() => {
      debouncedValue.value = val
    }, delay)
  })

  return debouncedValue
}

// 使用
const searchInput = ref('')
const debouncedSearch = useDebounce(searchInput, 500)
watch(debouncedSearch, performSearch)  // 500ms 后才触发搜索

6.4 VueUse — 社区最强工具库

VueUse 是一个提供超过 200 个常用 Composables 的社区库,涵盖浏览器 API、传感器、动画、状态管理等各类场景。

npm install @vueuse/core
<script setup>
import {
  useDark,          // 暗色模式
  useToggle,        // 布尔切换
  useFetch,         // 数据请求
  useLocalStorage,  // 持久化存储
  useIntersectionObserver, // 元素可见性
  useGeolocation,   // 地理位置
  useClipboard,     // 剪贴板
  useEventListener, // 事件监听(自动清理)
  useWindowSize,    // 窗口尺寸
  useIntervalFn,    // 定时器
  useVModel,        // v-model 助手
} from '@vueuse/core'

// 暗色模式(自动同步 class 到 <html>)
const isDark   = useDark()
const toggleDark = useToggle(isDark)

// 自动请求(响应式 URL)
const userId = ref(1)
const { data, isFetching, error } = useFetch(
  () => `/api/users/${userId.value}`  // userId 变化自动重新请求
).json()

// 懒加载图片(进入视口才加载)
const imgRef = ref()
const { stop } = useIntersectionObserver(imgRef, ([entry]) => {
  if (entry.isIntersecting) {
    imgRef.value.src = imgRef.value.dataset.src
    stop()  // 加载后停止观察
  }
})

// 自动清理的事件监听
useEventListener('keydown', (e: KeyboardEvent) => {
  if (e.key === 'Escape') closeModal()
})  // 组件卸载时自动移除
</script>

6.5 Composable 的最佳实践

use 前缀
命名约定:Composable 函数以 use 开头(useFetch、useAuth、useCart),这有助于区分普通工具函数和包含响应式逻辑的 Composable。
返回响应式引用
返回 ref 而不是原始值,这样调用方可以解构并保持响应性。即使是对象也建议 toRefs 处理后返回。
接受 ref 参数
参数可以接受 ref 或普通值(使用 toValue() / unref() 处理),使 Composable 更灵活:const id = toValue(props)。
清理副作用
在 onUnmounted 中清理事件监听、定时器、WebSocket 连接等,防止内存泄漏。VueUse 的工具会自动处理这些。
避免在非 setup 中调用
Composable 必须在 setup() 或 <script setup> 的同步执行上下文中调用,不能在异步函数或事件处理器中调用生命周期钩子。
Composable vs Store

Composable 和 Pinia Store 都用于共享逻辑,区别在于:Composable 每次调用都创建独立的状态实例(适合组件级逻辑);Pinia Store 是单例,全局共享同一份状态(适合应用级状态)。在 Composable 中使用 ref 如果在模块作用域声明(不在函数内部),也会变成全局共享状态。

6.5 高级模式:带清理的 Composable

涉及副作用的 Composable(如事件监听、定时器、WebSocket)必须在组件卸载时清理资源,否则会造成内存泄漏。通过 onUnmounted 或 Vue 3.5 的 onWatcherCleanup 实现自动清理。

// composables/useEventListener.ts — 带自动清理的事件监听
export function useEventListener(
  target: EventTarget | Ref<EventTarget>,
  event: string,
  handler: EventListenerOrEventListenerObject
) {
  // 支持 ref 包裹的 DOM 元素
  const resolvedTarget = isRef(target) ? target : ref(target)

  watch(resolvedTarget, (el, _, onCleanup) => {
    if (!el) return
    el.addEventListener(event, handler)
    // Vue 3.5+:onCleanup 在 watcher 重新运行前自动调用
    onCleanup(() => el.removeEventListener(event, handler))
  }, { immediate: true })

  // 兜底:组件卸载时确保清理
  onUnmounted(() => {
    resolvedTarget.value?.removeEventListener(event, handler)
  })
}
// composables/useWebSocket.ts — WebSocket 连接管理
export function useWebSocket(url: string) {
  const data    = ref<string | null>(null)
  const status  = ref<'connecting' | 'open' | 'closed'>('connecting')
  let ws: WebSocket

  onMounted(() => {
    ws = new WebSocket(url)
    ws.onopen  = () => { status.value = 'open' }
    ws.onclose = () => { status.value = 'closed' }
    ws.onmessage = (e) => { data.value = e.data }
  })

  onUnmounted(() => ws?.close())  // 组件销毁时断开连接

  function send(msg: string) {
    if (status.value === 'open') ws.send(msg)
  }

  return { data, status, send }
}

6.6 测试 Composable

Composable 的可测试性是它相比 Options API Mixin 的重大优势。由于 Composable 是纯函数,可以直接在测试中调用,无需挂载组件。

import { describe, it, expect, vi } from 'vitest'
import { withSetup } from './test-utils'
import { useCounter } from '@/composables/useCounter'

// 辅助函数:创建模拟 setup 上下文
function withSetup<T>(composable: () => T): T {
  let result!: T
  defineComponent({
    setup() { result = composable() }
  })
  return result
}

describe('useCounter', () => {
  it('初始值为 0', () => {
    const { count } = withSetup(() => useCounter())
    expect(count.value).toBe(0)
  })

  it('increment 使 count +1', () => {
    const { count, increment } = withSetup(() => useCounter())
    increment()
    expect(count.value).toBe(1)
  })

  it('支持自定义初始值', () => {
    const { count } = withSetup(() => useCounter(10))
    expect(count.value).toBe(10)
  })
})
常见误区:在条件或循环中调用 Composable

Composable 内部可能使用 watchonMounted 等依赖调用顺序的 API。在条件语句或循环中调用 Composable 会破坏这种顺序一致性,导致生命周期钩子行为异常。规则:始终在 setup() 的同步代码顶层调用 Composable,不要在 iffor 或异步函数中调用。

本章小结

Composables 是 Vue 3 Composition API 的最佳实践——将有状态的逻辑封装为可复用的函数。命名约定 useXxx,放在 composables/ 目录。相较于 Vue 2 的 Mixin,Composable 解决了命名冲突、来源不清晰和类型推导困难的问题。核心技巧:通过 onUnmounted 清理副作用(事件监听、定时器、WebSocket),用 toRefs() 或直接返回 refs 保持响应性,用 VueUse 库(80+ 现成 Composable)提升开发效率。