Files
2026-07-13 21:37:07 +08:00

8.3 KiB
Raw Permalink Blame History

Vue Composables

使用组合式 API 封装有状态逻辑的可复用函数。

核心规则

  1. 优先使用 VueUse — 编写自定义函数前先查阅 vueuse.org
  2. 不要使用异步组合式函数 — 在其他组合式函数中被 await 后会丢失生命周期上下文
  3. 仅在顶层调用 — 绝不要在事件处理函数、条件分支或循环中调用
  4. 使用 readonly() 导出 — 保护内部状态不被外部修改
  5. SSR 使用 useState() — 优先使用 Nuxt 的 useState(),而非全局 ref

快速参考

模式 示例
命名 useAuthuseCounteruseDebounce
状态 const count = ref(0)
计算属性 const double = computed(() => count.value * 2)
生命周期 onMounted(() => ...)onUnmounted(() => ...)
返回值 return { count, increment }

结构

// composables/useCounter.ts
import { readonly, 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: readonly(count), // 如果不允许修改,使用 readonly
    increment,
    decrement,
    reset,
  }
}

命名

始终以 use 开头: useAuthuseLocalStorageuseDebounce

文件名即函数名: useAuth.ts 导出 useAuth

最佳实践

应该这样做:

  • 返回包含具名属性的对象(便于解构)
  • 接受 options 配置对象
  • 对不应修改的状态使用 readonly()
  • 处理清理工作(onUnmountedonScopeDispose
  • 为复杂函数添加 JSDoc

生命周期

钩子在组件上下文中执行:

export function useEventListener(target: EventTarget, event: string, handler: Function) {
  onMounted(() => target.addEventListener(event, handler))
  onUnmounted(() => target.removeEventListener(event, handler))
}

监听器清理(Vue 3.5+):

import { watch, onWatcherCleanup } from 'vue'

export function usePolling(url: Ref<string>) {
  watch(url, (newUrl) => {
    const interval = setInterval(() => {
      fetch(newUrl).then(/* ... */)
    }, 1000)

    // 当监听器重新运行或停止时执行清理
    onWatcherCleanup(() => {
      clearInterval(interval)
    })
  })
}

onWatcherCleanup() 的优势:

  • 比返回清理函数更简洁
  • 可与异步监听器配合使用
  • 可在同一个监听器中多次调用

异步模式

export function useAsyncData<T>(fetcher: () => Promise<T>) {
  const data = ref<T | null>(null)
  const error = ref<Error | null>(null)
  const loading = ref(false)

  async function execute() {
    loading.value = true
    error.value = null
    try {
      data.value = await fetcher()
    }
    catch (e) {
      error.value = e as Error
    }
    finally {
      loading.value = false
    }
  }

  execute()
  return { data, error, loading, refetch: execute }
}

数据获取: 优先使用 Pinia Colada 查询,而非自定义组合式函数。

VueUse

关于 VueUse 组合式函数参考,请使用 vueuse 技能。

编写自定义组合式函数前请先查阅 VueUse——大多数常用模式已有现成实现。

关于 Nuxt 专属组合式函数useFetch、useRequestURL):参见 nuxt 技能的 nuxt-composables.md

进阶模式

单例组合式函数

在所有使用同一组合式函数的组件之间共享状态:

import { createSharedComposable } from '@vueuse/core'

function useMapControlsBase() {
  const mapInstance = ref<Map | null>(null)
  const flyTo = (coords: [number, number]) => mapInstance.value?.flyTo(coords)
  return { mapInstance, flyTo }
}

export const useMapControls = createSharedComposable(useMapControlsBase)

可取消的带 AbortController 的 Fetch

export function useSearch() {
  let abortController: AbortController | null = null

  watch(query, async (newQuery) => {
    abortController?.abort()
    abortController = new AbortController()

    try {
      const data = await $fetch('/api/search', {
        query: { q: newQuery },
        signal: abortController.signal,
      })
    }
    catch (e) {
      if (e.name !== 'AbortError')
        throw e
    }
  })
}

基于步骤的状态机

export function useSendFlow() {
  const step = ref<'input' | 'confirm' | 'success'>('input')
  const amount = ref('')

  const next = () => {
    if (step.value === 'input')
      step.value = 'confirm'
    else if (step.value === 'confirm')
      step.value = 'success'
  }

  return { step, amount, next }
}

仅客户端守卫

export function useUserLocation() {
  const location = ref<GeolocationPosition | null>(null)

  if (import.meta.client) {
    navigator.geolocation.getCurrentPosition(pos => location.value = pos)
  }

  return { location }
}

自定义元素组合式函数(Vue 3.5+)

对于自定义元素组件,使用内置辅助函数:

import { useHost, useShadowRoot } from 'vue'

export function useCustomElement() {
  const host = useHost() // 宿主元素引用
  const shadowRoot = useShadowRoot() // Shadow DOM 根节点

  onMounted(() => {
    console.log('Host:', host)
    console.log('Shadow:', shadowRoot)
  })

  return { host, shadowRoot }
}

适用场景:

  • 自定义元素中使用 <script setup> 的组件
  • 通过 Options API 中的 this.$host 访问

带防抖的自动保存

export function useAutoSave(content: Ref<string>) {
  const hasChanges = ref(false)

  const save = useDebounceFn(async () => {
    if (!hasChanges.value)
      return
    await $fetch('/api/save', { method: 'POST', body: { content: content.value } })
    hasChanges.value = false
  }, 1000)

  watch(content, () => {
    hasChanges.value = true
    save()
  })

  return { hasChanges }
}

带标签的日志记录器

import { consola } from 'consola'

export function useSearch() {
  const logger = consola.withTag('search')

  watch(query, (q) => {
    logger.info('Query changed:', q)
  })
}

响应式陷阱

reactive 中的 ref 解包

ref 在 reactive() 对象中会自动解包,但在数组、Map 或 Set 中不会

// ✅ 对象 - 自动解包
const state = reactive({ count: ref(0) })
state.count++ // 无需 .value

// ❌ 数组 - 不解包
const arr = reactive([ref(1)])
arr[0].value // 需要 .value

// ❌ Map/Set - 不解包
const map = reactive(new Map([['key', ref(1)]]))
map.get('key').value // 需要 .value

watchEffect 条件追踪

条件分支内部的依赖在条件为 false 时不会被追踪

// ❌ 错误 - 条件为 false 时不会追踪依赖
watchEffect(() => {
  if (condition.value) {
    console.log(dep.value) // 仅在 condition=true 时追踪
  }
})

// ✅ 正确 - 对条件依赖使用显式 watch
watch([condition, dep], ([cond, d]) => {
  if (cond) console.log(d)
})

清理模式

对于 keep-alive 组件——使用 onDeactivated

export function usePolling() {
  let interval: NodeJS.Timeout

  onMounted(() => { interval = setInterval(poll, 5000) })
  onUnmounted(() => clearInterval(interval))
  onDeactivated(() => clearInterval(interval)) // 停用时暂停
  onActivated(() => { interval = setInterval(poll, 5000) }) // 恢复
}

对于作用域感知的清理——使用 VueUse 的 tryOnScopeDispose

import { tryOnScopeDispose } from '@vueuse/core'

export function useEventSource(url: string) {
  const source = new EventSource(url)

  // 在 effect 作用域销毁时清理(组件卸载、监听器停止)
  tryOnScopeDispose(() => source.close())

  return { source }
}

常见错误

内部状态未使用 readonly()

// ❌ 错误 - 暴露了可修改的 ref
return { count }

// ✅ 正确 - 防止外部修改
return { count: readonly(count) }

缺少清理:

// ❌ 错误 - 监听器从未移除
onMounted(() => target.addEventListener('click', handler))

// ✅ 正确 - 卸载时清理
onMounted(() => target.addEventListener('click', handler))
onUnmounted(() => target.removeEventListener('click', handler))