自定义 Composables 设计模式

内置 Composables 解决的是通用问题,而自定义 Composables 是你组织业务逻辑的核心手段。写得好,代码清晰、可测试、可复用;写得差,就是换了个形式的 Options API 或回调地狱。本章聚焦设计模式、SSR 安全规范、内存管理,以及 AI 视频项目中 Composable 库的实际架构。

1. 设计原则与命名规范

1.1 Composable 不是"抽取公共代码"

初学者最常犯的错误是把 Composable 等同于"把代码提到一个函数里"。好的 Composable 应满足:

  • 封装一个独立的关注点(单一职责):不是"所有视频相关逻辑",而是 useVideoPlayeruseVideoListuseVideoUpload 分别封装
  • 返回响应式接口:调用者拿到的是 Ref/Computed,不需要关心内部何时更新
  • 管理自己的生命周期:创建的定时器、监听器在组件卸载时自动清理
  • SSR 安全:不依赖浏览器 API,或在服务端优雅降级

1.2 命名规范

Nuxt 社区约定俗成的命名:

模式含义示例
use<Name>依赖组件生命周期的 ComposableuseVideoPlayer, useAuth
create<Name>创建独立实例,不绑定生命周期createApiClient, createLogger
on<Event>注册事件监听onVideoEnd, onNetworkChange

反模式getXxxfetchXxxhandleXxx——这些不符合 Composable 的语义,容易和普通函数混淆。

2. 单例模式 vs 工厂模式

2.1 工厂模式(默认)

每次调用都返回独立的状态实例,各组件互不干扰:

// app/composables/useVideoPlayer.ts
export function useVideoPlayer(videoId: Ref<string>) {
  const isPlaying = ref(false)
  const currentTime = ref(0)
  const duration = ref(0)
 
  function play() { isPlaying.value = true }
  function pause() { isPlaying.value = false }
 
  return { isPlaying, currentTime, duration, play, pause }
}

页面 A 和页面 B 各调用一次 useVideoPlayer,各自有独立的 isPlaying。这是绝大多数 Composable 的默认模式。

2.2 单例模式

所有调用者共享同一份状态,类似全局 Store。实现方式是把状态提升到模块作用域:

// app/composables/useGlobalAudio.ts
const currentTrack = ref<Track | null>(null)
const isPlaying = ref(false)
const volume = ref(0.8)
 
export function useGlobalAudio() {
  function play(track: Track) {
    currentTrack.value = track
    isPlaying.value = true
  }
 
  return { currentTrack: readonly(currentTrack), isPlaying, volume, play }
}

SSR 陷阱:模块级变量在服务端是全局共享的——所有请求看到同一份 currentTrack。这会导致状态跨请求泄漏(用户 A 的数据被用户 B 看到)。

安全做法:用 useState 替代模块级变量:

export function useGlobalAudio() {
  const currentTrack = useState<Track | null>('global-audio-track', () => null)
  const isPlaying = useState('global-audio-playing', () => false)
  // useState 在 SSR 阶段是请求级隔离的
  return { currentTrack, isPlaying }
}

2.3 选型决策

场景模式原因
视频播放器工厂页面可能有多个播放器
全局音频播放单例(useState)全站只有一个播放条
用户认证状态单例(useState)全局共享登录态
表单数据工厂每个表单独立
弹幕连接工厂每个视频一个连接
主题偏好单例(useState)全站统一

3. SSR 安全编写规范

3.1 核心原则

在 SSR 环境中,同一份代码会在 Node.js(服务端)和浏览器(客户端)各执行一次。你的 Composable 必须在两个环境都能安全运行

禁止在顶层访问浏览器 API

// ❌ 服务端会报错:window is not defined
export function useWindowSize() {
  const width = ref(window.innerWidth)
  // ...
}
 
// ✅ 延迟到客户端执行
export function useWindowSize() {
  const width = ref(0)
  const height = ref(0)
 
  onMounted(() => {
    width.value = window.innerWidth
    height.value = window.innerHeight
  })
 
  return { width, height }
}

3.2 环境检测工具

Nuxt 提供了多种环境检测方式:

检测方式说明适用场景
import.meta.server编译时常量,服务端为 trueTree-shake 客户端代码
import.meta.client编译时常量,客户端为 trueTree-shake 服务端代码
onMounted()只在客户端执行DOM 操作、浏览器 API
process.server运行时判断(兼容写法)动态逻辑分支

编译时常量的优势import.meta.server 在构建时会被替换为 truefalse,Vite 的 Tree-shaking 会移除不可达分支。这意味着服务端代码不会打进客户端 bundle,反之亦然。

3.3 服务端安全的默认值

如果一个 Composable 依赖浏览器 API 提供数据,需要在服务端给出合理默认值:

export function useMediaQuery(query: string) {
  // 服务端无法检测 media query,给出默认值
  const matches = ref(false)
 
  if (import.meta.client) {
    const mql = window.matchMedia(query)
    matches.value = mql.matches
    // 监听变化...
  }
 
  return { matches }
}

3.4 避免 SSR 状态泄漏清单

陷阱症状解决方案
模块级 ref()请求间共享状态改用 useState()
全局 Map / Set数据越积越多挂载到 event.context 或用请求级作用域
未清理的 setInterval服务端内存泄漏onBeforeUnmount 清理(但服务端不触发)
闭包捕获的 this指向错误实例改用组合式 API,避免 this

4. 内存管理与副作用清理

4.1 自动清理原则

一个好的 Composable 应当自管理——创建了什么资源,卸载时就清理什么:

export function usePolling(url: string, intervalMs = 5000) {
  const data = ref(null)
  let timer: ReturnType<typeof setInterval> | null = null
 
  function start() {
    // 立即请求一次
    refresh()
    timer = setInterval(refresh, intervalMs)
  }
 
  function stop() {
    if (timer) { clearInterval(timer); timer = null }
  }
 
  async function refresh() {
    data.value = await $fetch(url)
  }
 
  // 关键:组件卸载时自动停止
  onMounted(start)
  onBeforeUnmount(stop)
 
  return { data, refresh, stop }
}

4.2 watchEffect 的自动清理

watchEffect 返回的 stop handle 会在组件卸载时自动调用,无需手动管理。但如果在 watchEffect 内部创建了资源(如 WebSocket 连接),需要使用 onCleanup

export function useDanmaku(videoId: Ref<string>) {
  const messages = ref<Danmaku[]>([])
 
  watchEffect((onCleanup) => {
    const ws = new WebSocket(`/ws/danmaku/${videoId.value}`)
    ws.onmessage = (e) => messages.value.push(JSON.parse(e.data))
 
    // videoId 变化或组件卸载时,关闭旧连接
    onCleanup(() => ws.close())
  })
 
  return { messages }
}

4.3 服务端的内存泄漏

服务端没有"组件卸载"的概念——SSR 渲染完就结束了,onBeforeUnmount 不会触发。如果 Composable 在服务端创建了异步资源(定时器、连接),必须手动清理或用 import.meta.client 限制:

export function useRealtimePrice(symbol: string) {
  const price = ref(0)
 
  // 只在客户端建立 WebSocket
  if (import.meta.client) {
    const ws = new WebSocket(`/ws/price/${symbol}`)
    ws.onmessage = (e) => { price.value = JSON.parse(e.data).price }
    onBeforeUnmount(() => ws.close())
  }
 
  return { price }
}

5. 高级模式

5.1 组合 Composable(Composable 调用 Composable)

复杂 Composable 应该由更小的 Composable 组合而成:

// useVideoPage 组合了多个小 Composable
export function useVideoPage(videoId: string) {
  const video = useVideoDetail(videoId)       // 视频数据
  const comments = useComments(videoId)        // 评论列表
  const player = useVideoPlayer(video.data)    // 播放控制
  const danmaku = useDanmaku(ref(videoId))     // 弹幕连接
 
  // 组合逻辑:视频加载完成后自动播放
  watch(video.data, (v) => {
    if (v) player.play()
  })
 
  return { video, comments, player, danmaku }
}

这种模式下,每个小 Composable 都可以独立测试、独立复用。

5.2 可配置 Composable(Options Pattern)

通过参数对象让 Composable 行为可定制:

interface UseVideoListOptions {
  category?: string
  pageSize?: number
  autoLoad?: boolean
  sort?: 'newest' | 'popular'
}
 
export function useVideoList(options: UseVideoListOptions = {}) {
  const { category, pageSize = 20, autoLoad = true, sort = 'newest' } = options
 
  const page = ref(1)
  const query = computed(() => ({
    page: page.value, limit: pageSize, category, sort,
  }))
 
  const { data, pending, refresh } = useFetch('/api/videos', {
    query,
    immediate: autoLoad,
  })
 
  function loadMore() { page.value++ }
 
  return { videos: data, pending, refresh, loadMore, page }
}

5.3 带 TypeScript 泛型的 Composable

让 Composable 的类型跟随调用者的数据类型自动推导:

export function useLocalStorage<T>(key: string, defaultValue: T) {
  const data = ref<T>(defaultValue) as Ref<T>
 
  if (import.meta.client) {
    const stored = localStorage.getItem(key)
    if (stored) data.value = JSON.parse(stored)
 
    watch(data, (val) => {
      localStorage.setItem(key, JSON.stringify(val))
    }, { deep: true })
  }
 
  return data
}
 
// 使用时类型自动推导
const theme = useLocalStorage('theme', 'light')     // Ref<string>
const settings = useLocalStorage('settings', { a: 1 })  // Ref<{ a: number }>

6. AI 视频项目 Composable 架构

一个完整的 AI 视频项目的 Composable 层设计:

app/composables/
├── useAuth.ts             ← 认证:登录态、Token、用户信息(单例 useState)
├── useVideoList.ts        ← 视频列表:分页、筛选、搜索(工厂)
├── useVideoDetail.ts      ← 视频详情:数据获取 + 关联数据(工厂)
├── useVideoPlayer.ts      ← 播放控制:播放/暂停/进度/倍速(工厂)
├── useVideoUpload.ts      ← 视频上传:分片上传进度、取消(工厂)
├── useAIGenerate.ts       ← AI 生成:提交任务、SSE 进度、结果获取(工厂)
├── useDanmaku.ts          ← 弹幕:WebSocket 连接、发送、渲染(工厂)
├── useNotification.ts     ← 通知:SSE 流、未读计数(单例 useState)
└── useLocalStorage.ts     ← 本地存储:主题、播放偏好(通用工具)

架构原则

  • 认证和通知是单例(全局共享状态),用 useState 保证 SSR 安全
  • 视频列表、播放器、弹幕是工厂(每个页面/组件独立实例)
  • 组合 Composable 串联业务流:页面级 useVideoPage 组合小 Composable
  • 上传和 AI 生成是长生命周期:需要跨页面保持状态,挂载到 useState 或 Pinia

7. 测试 Composables

Composable 的一大优势是可以脱离组件测试。使用 @nuxt/test-utils 或直接用 @vue/test-utils

// tests/composables/useVideoList.test.ts
import { mountSuspended } from '@nuxt/test-utils/runtime'
import { useVideoList } from '~/composables/useVideoList'
 
it('should load first page on mount', async () => {
  const component = await mountSuspended(defineComponent({
    setup() {
      const { videos, page } = useVideoList({ pageSize: 10 })
      return { videos, page }
    },
    template: '<div />',
  }))
 
  expect(component.vm.page).toBe(1)
})

更轻量的做法是用 withSetup helper:

function withSetup<T>(composable: () => T) {
  let result: T
  const app = createApp({ setup() { result = composable(); return {} }, render: () => null })
  app.mount(document.createElement('div'))
  return { result: result!, app }
}
 
it('useLocalStorage should return default value', () => {
  const { result } = withSetup(() => useLocalStorage('test', 42))
  expect(result.value).toBe(42)
})

本章小结

  • 设计原则:单一职责、响应式接口、自管理生命周期、SSR 安全
  • 工厂 vs 单例:默认用工厂(独立实例),全局共享状态用单例 + useState
  • SSR 安全:不在顶层访问浏览器 API,用 import.meta.client 限制客户端代码,用 useState 代替模块级变量
  • 内存管理onBeforeUnmount 清理定时器/连接,watchEffect(onCleanup) 自动清理
  • 高级模式:Composable 组合、Options Pattern、TypeScript 泛型
  • 项目架构:认证/通知单例,列表/播放器/弹幕工厂,页面级 Composable 组合串联
  • 可测试性:脱离组件独立测试,withSetup@nuxt/test-utils