自定义 Composables 设计模式
内置 Composables 解决的是通用问题,而自定义 Composables 是你组织业务逻辑的核心手段。写得好,代码清晰、可测试、可复用;写得差,就是换了个形式的 Options API 或回调地狱。本章聚焦设计模式、SSR 安全规范、内存管理,以及 AI 视频项目中 Composable 库的实际架构。
1. 设计原则与命名规范
1.1 Composable 不是"抽取公共代码"
初学者最常犯的错误是把 Composable 等同于"把代码提到一个函数里"。好的 Composable 应满足:
- 封装一个独立的关注点(单一职责):不是"所有视频相关逻辑",而是
useVideoPlayer、useVideoList、useVideoUpload分别封装 - 返回响应式接口:调用者拿到的是 Ref/Computed,不需要关心内部何时更新
- 管理自己的生命周期:创建的定时器、监听器在组件卸载时自动清理
- SSR 安全:不依赖浏览器 API,或在服务端优雅降级
1.2 命名规范
Nuxt 社区约定俗成的命名:
| 模式 | 含义 | 示例 |
|---|---|---|
use<Name> | 依赖组件生命周期的 Composable | useVideoPlayer, useAuth |
create<Name> | 创建独立实例,不绑定生命周期 | createApiClient, createLogger |
on<Event> | 注册事件监听 | onVideoEnd, onNetworkChange |
反模式:getXxx、fetchXxx、handleXxx——这些不符合 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 | 编译时常量,服务端为 true | Tree-shake 客户端代码 |
import.meta.client | 编译时常量,客户端为 true | Tree-shake 服务端代码 |
onMounted() | 只在客户端执行 | DOM 操作、浏览器 API |
process.server | 运行时判断(兼容写法) | 动态逻辑分支 |
编译时常量的优势:import.meta.server 在构建时会被替换为 true 或 false,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