第 21 章

Composables 设计哲学:状态所有权、副作用清理与高频易错陷阱

Vue 3 的 Composable 规范了一件在 Vue 2 中几乎无法优雅完成的事:把"有状态逻辑"从组件中提取出来,在多个组件间复用,同时确保状态和副作用的生命周期与使用它的组件完全对齐。它并不是 React Hooks 的翻版——Composable 基于 Vue 响应式系统,没有调用顺序限制,也没有闭包过期问题。

Level 1 · 你需要知道的(1-3年经验)

Composable 的定义与约定

Composable 是满足以下条件的函数:

  1. 函数名以 use 开头(约定,不是强制)
  2. 在组件的 setup() 或另一个 Composable 内部调用(同步上下文)
  3. 封装了有状态逻辑,通常返回响应式状态和相关操作
// useCounter.ts — 最简单的 Composable
import { ref } from 'vue'

export function useCounter(initialValue = 0) {
  const count = ref(initialValue)
  
  function increment() { count.value++ }
  function decrement() { count.value-- }
  function reset() { count.value = initialValue }
  
  return { count, increment, decrement, reset }
}
<!-- 使用 -->
<script setup>
import { useCounter } from './useCounter'

const { count, increment, decrement } = useCounter(10)
</script>

<template>
  <button @click="decrement">-</button>
  <span>{{ count }}</span>
  <button @click="increment">+</button>
</template>

每次调用 useCounter(),都会创建一套独立的状态。ComponentA 和 ComponentB 各自调用,各自拥有独立的 count

副作用必须清理

Composable 内部的副作用(事件监听、定时器、WebSocket 连接)必须在组件卸载时清理:

// ❌ 有内存泄漏:组件卸载后监听仍然存在
export function useMouse() {
  const x = ref(0)
  const y = ref(0)
  
  window.addEventListener('mousemove', (e) => {
    x.value = e.clientX
    y.value = e.clientY
  })
  
  return { x, y }
}

// ✅ 正确:onUnmounted 清理
import { ref, onUnmounted } from 'vue'

export function useMouse() {
  const x = ref(0)
  const y = ref(0)
  
  const handler = (e: MouseEvent) => {
    x.value = e.clientX
    y.value = e.clientY
  }
  
  window.addEventListener('mousemove', handler)
  
  onUnmounted(() => {
    window.removeEventListener('mousemove', handler)
  })
  
  return { x, y }
}

useEventListener:生产级实现

更健壮的版本,支持任意目标元素,自动清理:

import { onScopeDispose, watch, isRef } from 'vue'
import type { Ref } from 'vue'

type EventTarget = HTMLElement | Window | Document | null | undefined

export function useEventListener<K extends keyof WindowEventMap>(
  target: EventTarget | Ref<EventTarget>,
  event: K,
  listener: (e: WindowEventMap[K]) => void,
  options?: AddEventListenerOptions,
) {
  const add = (el: EventTarget) => {
    el?.addEventListener(event, listener as EventListener, options)
  }
  const remove = (el: EventTarget) => {
    el?.removeEventListener(event, listener as EventListener, options)
  }

  if (isRef(target)) {
    // 目标是 ref(可能是 null,比如 templateRef 在挂载前)
    watch(target, (newVal, oldVal) => {
      remove(oldVal)
      add(newVal)
    }, { immediate: true })
  } else {
    add(target)
  }

  // 当组件(或 effectScope)销毁时清理
  onScopeDispose(() => {
    remove(isRef(target) ? target.value : target)
  })
}

状态所有权:组件级 vs 单例

组件级(每次调用独立):

// 每个组件调用都创建自己的状态
export function useLocalStorage<T>(key: string, defaultValue: T) {
  const stored = localStorage.getItem(key)
  const value = ref<T>(stored ? JSON.parse(stored) : defaultValue)
  
  watch(value, (newVal) => {
    localStorage.setItem(key, JSON.stringify(newVal))
  }, { deep: true })
  
  return value
}

单例模式(跨组件共享,模块级状态):

// 模块级 ref,所有组件共享同一个实例
const globalUser = ref<User | null>(null)

export function useCurrentUser() {
  // 注意:这里没有创建新的 ref,而是返回共享的那个
  return {
    user: globalUser,
    setUser: (u: User | null) => { globalUser.value = u }
  }
}

单例模式要特别注意:副作用不能依赖 onUnmounted,因为模块级状态在应用生命周期内永远存在。


Level 2 · 它是怎么运行的(3-5年经验)

Composable 中为什么可以使用生命周期钩子

这是最常被问到的问题。onMountedonUnmounted 不是组件方法——它们是全局函数,是怎么知道"注册到哪个组件"的?

答案:currentInstance(当前活跃组件实例)传递机制

组件 setup() 开始执行
        │
        ▼
Vue 设置全局变量 currentInstance = 本组件实例
        │
        ▼
setup() 调用 useCounter()
        │
        ▼
useCounter() 内部调用 onMounted(fn)
        │
        ▼
onMounted 读取 currentInstance(仍然是本组件!)
并将 fn 注册到 currentInstance.lifecycle.mounted
        │
        ▼
useCounter() 返回
        │
        ▼
setup() 执行完毕
Vue 清除 currentInstance = null

这个机制的前提是同步调用。如果在 async setup 的 await 之后调用 Composable,currentInstance 已经被清除,生命周期钩子无法注册到正确的组件。

onUnmounted vs 返回清理函数

两种清理模式的对比:

// 模式一:onUnmounted 自动清理(推荐,组件内使用)
export function useWebSocket(url: string) {
  const ws = ref<WebSocket | null>(null)
  const messages = ref<string[]>([])
  
  ws.value = new WebSocket(url)
  ws.value.onmessage = (e) => messages.value.push(e.data)
  
  onUnmounted(() => {
    ws.value?.close()
    ws.value = null
  })
  
  return { ws, messages }
}

// 模式二:返回清理函数(适用于非组件环境)
export function createWebSocketConnection(url: string) {
  const ws = new WebSocket(url)
  const messages: string[] = []
  
  ws.onmessage = (e) => messages.push(e.data)
  
  return {
    messages,
    cleanup: () => ws.close() // 调用方负责清理
  }
}

模式一更便捷——自动与组件生命周期对齐;模式二更灵活——可在测试、Node.js 环境或手动控制生命周期时使用。

生命周期钩子传递的内部机制

调用栈示意图:

ComponentA.setup()
  │ currentInstance = ComponentA
  ├─ useDataFetch()
  │     │ currentInstance 仍然是 ComponentA
  │     ├─ onMounted(fetchData)
  │     │     → 注册到 ComponentA.lifecycle.mounted[]
  │     ├─ onUnmounted(cleanup)
  │     │     → 注册到 ComponentA.lifecycle.unmounted[]
  │     └─ 返回 { data, error, loading }
  │
  └─ 继续 setup()...
  currentInstance = null(setup 结束)

源码来自 packages/runtime-core/src/apiLifecycle.ts

export function injectHook(
  type: LifecycleHooks,
  hook: Function & { __weh?: Function },
  target: ComponentInternalInstance | null = currentInstance,
  prepend = false,
) {
  if (target) {
    const hooks = target[type] || (target[type] = [])
    // ...将 hook 注册到 target 实例
  }
}

target = currentInstance — 默认取当前全局活跃实例。只要 Composable 是在 setup() 执行期间同步调用的,currentInstance 就是正确的组件。

useFetch 生产级实现

import { ref, watch, shallowRef } from 'vue'

interface FetchOptions {
  immediate?: boolean
  retries?: number
  retryDelay?: number
}

interface FetchState<T> {
  data: Ref<T | null>
  error: Ref<Error | null>
  loading: Ref<boolean>
  execute: () => Promise<void>
  abort: () => void
}

export function useFetch<T>(
  url: MaybeRefOrGetter<string>,
  options: FetchOptions = {}
): FetchState<T> {
  const { immediate = true, retries = 0, retryDelay = 1000 } = options
  
  const data = shallowRef<T | null>(null)
  const error = ref<Error | null>(null)
  const loading = ref(false)
  
  let abortController: AbortController | null = null
  
  async function fetchWithRetry(retriesLeft: number): Promise<T> {
    const controller = new AbortController()
    abortController = controller
    
    try {
      const response = await fetch(toValue(url), { signal: controller.signal })
      if (!response.ok) throw new Error(`HTTP ${response.status}`)
      return await response.json()
    } catch (err) {
      if ((err as Error).name === 'AbortError') throw err
      if (retriesLeft > 0) {
        await new Promise(resolve => setTimeout(resolve, retryDelay))
        return fetchWithRetry(retriesLeft - 1)
      }
      throw err
    }
  }
  
  async function execute() {
    // 竞态处理:取消上一次请求
    abortController?.abort()
    
    loading.value = true
    error.value = null
    
    try {
      data.value = await fetchWithRetry(retries)
    } catch (err) {
      if ((err as Error).name !== 'AbortError') {
        error.value = err as Error
      }
    } finally {
      loading.value = false
    }
  }
  
  function abort() {
    abortController?.abort()
    loading.value = false
  }
  
  // 监听 URL 变化,自动重新请求
  watch(() => toValue(url), () => {
    if (immediate) execute()
  }, { immediate })
  
  // 组件卸载时取消请求
  onUnmounted(abort)
  
  return { data, error, loading, execute, abort }
}

三态管理loading(请求中)、error(失败)、data(成功)三个状态互斥,覆盖所有请求状态。

effectScope:Composable 的生命周期容器

effectScope 是 Vue 3.2 引入的 API,为 Composable 提供了另一种副作用管理方式:

import { effectScope, ref, watch } from 'vue'

export function useAnalytics() {
  const scope = effectScope()
  
  const pageViews = ref(0)
  
  scope.run(() => {
    // scope 内部的所有 watch、computed、副作用
    // 会在 scope.stop() 时统一清理
    watch(pageViews, (views) => {
      sendAnalytics({ event: 'pageview', count: views })
    })
  })
  
  onUnmounted(() => scope.stop()) // 清理所有 scope 内的副作用
  
  return { pageViews }
}

effectScope 的优势在于:不需要逐个追踪每个副作用,一个 scope.stop() 清理所有内部副作用。

执行流程对比图

组件级 Composable(推荐):

ComponentA mount
  │
  ├─ useCounter() → count_A (ref)
  ├─ useMouse()  → {x_A, y_A}
  │     └─ addEventListener → 注册 handler_A
  │
ComponentA unmount
  │
  └─ onUnmounted → removeEventListener(handler_A)
                   count_A, {x_A, y_A} 被 GC

ComponentB(独立实例):
  ├─ useCounter() → count_B (独立 ref)
  └─ ...

单例 Composable:

Module load → globalCount = ref(0)

ComponentA mount
  └─ useGlobalCounter() → globalCount(共享引用)

ComponentB mount
  └─ useGlobalCounter() → globalCount(同一引用!)

ComponentA unmount → globalCount 仍存在
ComponentB unmount → globalCount 仍存在(应用级生命周期)

Level 3 · 设计文档与源码(资深开发者)

currentInstance 的设置与清除

// packages/runtime-core/src/component.ts
export let currentInstance: ComponentInternalInstance | null = null

export const setCurrentInstance = (instance: ComponentInternalInstance) => {
  currentInstance = instance
  instance.scope.on() // 激活 effectScope
}

export const unsetCurrentInstance = () => {
  currentInstance && currentInstance.scope.off()
  currentInstance = null
}
// packages/runtime-core/src/renderer.ts
// 在 setupStatefulComponent 中调用
function setupStatefulComponent(instance, isSSR) {
  // ...
  setCurrentInstance(instance)    // ← 设置全局 currentInstance
  pauseTracking()
  try {
    const setupResult = callWithErrorHandling(
      setup,
      instance,
      ErrorCodes.SETUP_FUNCTION,
      [instance.props, setupContext],
    )
    // ...
  } finally {
    resetTracking()
    unsetCurrentInstance()         // ← 清除全局 currentInstance
  }
}

这证明了为什么在 async setupawait 之后调用 Composable 会失败:await 切出执行上下文时,finally 块还没执行,但当 Promise 恢复时,unsetCurrentInstance() 可能已经被其他组件的 setup 调用覆盖了 currentInstance

onScopeDispose vs onUnmounted

onUnmounted 只在组件的 effectScope 上注册钩子,onScopeDispose 则在当前活跃的任意 effectScope 上注册:

// packages/reactivity/src/effectScope.ts
export function onScopeDispose(fn: () => void, failSilently?: boolean) {
  if (activeEffectScope) {
    activeEffectScope.cleanups.push(fn)
  } else if (!failSilently) {
    warn(`onScopeDispose() is called when there is no active effect scope to be associated with.`)
  }
}

在为库编写 Composable 时,推荐使用 onScopeDispose 而不是 onUnmounted——当 Composable 在独立的 effectScope(如 Pinia store)中使用时,仍然能正确清理:

// 库代码,使用 onScopeDispose 更通用
export function useTimer(fn: () => void, interval: number) {
  const timer = setInterval(fn, interval)
  // onUnmounted 在 Pinia store 中无效,onScopeDispose 有效
  onScopeDispose(() => clearInterval(timer))
}

watch 的 onCleanup 参数:竞态条件的标准解法

import { watch, ref } from 'vue'

const id = ref(1)

watch(id, async (newId, oldId, onCleanup) => {
  let cancelled = false
  
  // 注册清理函数:当 watch 被再次触发(或 stop)时调用
  onCleanup(() => {
    cancelled = true
  })
  
  const response = await fetch(`/api/user/${newId}`)
  
  // 检查:如果在请求进行中 id 又变了,这次的结果就不需要了
  if (!cancelled) {
    user.value = await response.json()
  }
})

onCleanup 的调用时机:

  1. 下一次 watch 触发时(旧 effect 的清理)
  2. watch 被 stop() 时
  3. 组件卸载时

这是处理异步 watch 中竞态条件的官方标准方案。

VueUse 的 Composable 设计模式

VueUse 是 Vue 3 Composable 的最佳实践集合,其设计有几个值得学习的模式:

MaybeRef / MaybeRefOrGetter:接受 ref、getter 函数或普通值,提高 API 灵活性

// toValue() 统一处理:ref → .value,getter → 调用,普通值 → 直接返回
import { toValue, type MaybeRefOrGetter } from 'vue'

export function useFoo(value: MaybeRefOrGetter<string>) {
  const normalized = computed(() => toValue(value))
  // ...
}

// 调用方可以任意传:
useFoo('hello')              // 普通值
useFoo(ref('hello'))         // ref
useFoo(() => someComputed.value) // getter

ConfigurableWindow:支持自定义 window 对象(SSR / iframe / Worker 场景)

interface ConfigurableWindow {
  window?: Window
}

export function useMouse(options: ConfigurableWindow = {}) {
  const { window = defaultWindow } = options
  // 使用 window 而非全局 window,可在 SSR 中 pass undefined
}

Level 4 · 边界与陷阱(全体适用)

陷阱 1:在非 setup 上下文调用 Composable

// ❌ 在 setTimeout 中调用
export default {
  setup() {
    setTimeout(() => {
      const { count } = useCounter() // 警告:missing lifecycle context
      // 生命周期钩子无法注册,onUnmounted 不会执行
    }, 1000)
  }
}

// ❌ 在 Promise 链中调用
async function loadData() {
  await fetch('/api/data')
  const result = useQuery('/api/data') // 错误:在异步回调中
}

// ✅ 只在 setup() 的同步部分调用
export default {
  setup() {
    const { count } = useCounter() // 正确
    
    onMounted(async () => {
      // 在生命周期钩子内部可以有异步操作
      // 但不能在这里调用 useCounter()
      await loadData()
    })
    
    return { count }
  }
}

诊断方式:控制台警告 [Vue warn]: getCurrentInstance() returned null. onMounted() was called when there was no active component instance to be associated with.

陷阱 2:条件调用 Composable(违反规则)

// ❌ 条件调用:会导致组件实例上的副作用注册不稳定
setup() {
  if (isAdmin) {
    const { data } = useAdminData() // 某些渲染不注册,某些渲染注册
  }
  // ...
}

// ✅ 将条件放在 Composable 内部
setup() {
  const { data } = useAdminData({ enabled: isAdmin }) // Composable 内部处理条件
}

// useAdminData 内部:
export function useAdminData({ enabled = true } = {}) {
  const data = ref(null)
  
  if (enabled) {
    onMounted(() => fetchAdminData())
  }
  
  return { data }
}

Vue 的 Composable 不像 React Hooks 有严格的调用顺序要求,但将条件逻辑放在 Composable 内部仍是最佳实践,因为它更清晰地表达意图。

陷阱 3:单例 Composable 的状态跨组件污染

// ❌ 模块级状态在组件卸载后仍然存在
const cache = ref<Map<string, any>>(new Map())

export function useCache() {
  // 所有组件共享同一个 cache
  return { cache }
}

// 测试中的问题:
// ComponentA 写入 cache
// ComponentA 卸载
// ComponentB 挂载,cache 仍然有 ComponentA 的数据
// → 状态泄漏,测试之间相互污染

解决方案:提供 reset 方法,或在 app 级别管理状态(app.provide + effectScope):

const createCache = () => {
  const cache = ref<Map<string, any>>(new Map())
  return {
    cache,
    reset: () => cache.value.clear()
  }
}

// 在应用初始化时创建,不使用模块级状态
const cacheStore = createCache()
app.provide(cacheKey, cacheStore)

陷阱 4:Composable 返回响应式对象被解构后丢失响应性

export function useUser() {
  const user = reactive({
    name: 'Alice',
    role: 'admin'
  })
  return user // 返回 reactive 对象
}

// 在组件中:
const { name, role } = useUser()
// ❌ name 和 role 是普通字符串!reactive 对象解构后失去响应性
// 修改 user.name 不会更新模板中的 name

解决方案一:返回 reactive 对象,不解构

const user = useUser()
// 使用 user.name, user.role

解决方案二:返回 ref 而非 reactive(推荐)

export function useUser() {
  const name = ref('Alice')
  const role = ref('admin')
  return { name, role } // ref 解构后仍有响应性
}

const { name, role } = useUser() // ✅ name 和 role 是 ref,响应性保留

解决方案三:使用 toRefs 转换

export function useUser() {
  const user = reactive({ name: 'Alice', role: 'admin' })
  return toRefs(user) // 将每个属性转为 ref
}

const { name, role } = useUser() // ✅ 可安全解构

陷阱 5:忘记清理 watch 导致的内存泄漏

// ❌ 在 Composable 中创建了 watch,但没有停止
export function useDebounceSearch(query: Ref<string>) {
  const results = ref([])
  
  // 这个 watch 会在组件卸载后仍然运行!
  // 因为没有调用 onUnmounted,watch 不会自动停止
  const stop = watch(query, async (q) => {
    results.value = await searchAPI(q)
  })
  
  // ✅ 应该在 setup 上下文中调用 watch,它会自动与组件绑定
  // 或者手动清理:
  onUnmounted(stop)
  
  return { results }
}

注意:在 setup() 上下文中(currentInstance 有效时)调用的 watch 会自动注册到组件的 effectScope,组件卸载时自动停止。但在 setup() 之外创建的 watch(如模块顶层、setTimeout 中)需要手动停止。

陷阱 6:Composable 嵌套调用时的生命周期注册顺序

export function useA() {
  onMounted(() => console.log('A mounted'))
  const { data } = useB() // useB 内部也有 onMounted
  onUnmounted(() => console.log('A unmounted'))
  return { data }
}

export function useB() {
  onMounted(() => console.log('B mounted'))
  onUnmounted(() => console.log('B unmounted'))
  return { data: ref(null) }
}

// 执行顺序:
// mounted: A → B(注册顺序)
// unmounted: A → B(注册顺序,不是反转)
// 注意:mounted 和 unmounted 的顺序都是注册顺序,不像 React 的 cleanup 是反序

章节小结

  1. Composable 是有状态逻辑的封装单元:以 use 开头、在同步 setup 上下文调用、封装响应式状态与副作用,是 Vue 3 代码复用的核心范式,取代了 Vue 2 的 mixin。

  2. currentInstance 传递机制使钩子可跨函数边界使用:生命周期钩子(onMountedonUnmounted)在 Composable 内部能正常工作,因为 Vue 在 setup 执行期间维护全局的 currentInstance 指针,任何在这段时间内同步调用的函数都能读取到它。

  3. 副作用必须配对清理:每个 addEventListener 对应一个 removeEventListener,每个 setInterval 对应一个 clearInterval。优先使用 onScopeDispose(比 onUnmounted 更通用,在 effectScope 环境中也有效)。

  4. 状态所有权决定 Composable 的架构:组件级 Composable 每次调用创建独立状态(适合局部逻辑),单例 Composable 跨组件共享状态(适合全局状态,但需要应用级生命周期管理)。

  5. 解构是响应性的最大杀手:从 reactive 对象解构的属性是普通值,失去响应性。Composable 应返回 ref 对象或使用 toRefs 转换,确保调用方可以安全解构而不丢失响应性。

本章评分
4.7  / 5  (8 评分)

💬 留言讨论