8.3 KiB
8.3 KiB
Vue Composables
使用组合式 API 封装有状态逻辑的可复用函数。
核心规则
- 优先使用 VueUse — 编写自定义函数前先查阅 vueuse.org
- 不要使用异步组合式函数 — 在其他组合式函数中被
await后会丢失生命周期上下文 - 仅在顶层调用 — 绝不要在事件处理函数、条件分支或循环中调用
- 使用
readonly()导出 — 保护内部状态不被外部修改 - SSR 使用
useState()— 优先使用 Nuxt 的useState(),而非全局 ref
快速参考
| 模式 | 示例 |
|---|---|
| 命名 | useAuth、useCounter、useDebounce |
| 状态 | 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 开头: useAuth、useLocalStorage、useDebounce
文件名即函数名: useAuth.ts 导出 useAuth
最佳实践
应该这样做:
- 返回包含具名属性的对象(便于解构)
- 接受 options 配置对象
- 对不应修改的状态使用
readonly() - 处理清理工作(
onUnmounted、onScopeDispose) - 为复杂函数添加 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))