Vue3的Composition API打破了Options API在逻辑组织上的局限,Composable(组合式函数)作为Composition API的核心复用机制,将状态逻辑封装为可独立使用和组合的函数。本文围绕Composable的设计原则、常见模式与实战案例,讲解如何构建高质量的可复用逻辑单元。
Composable的设计原则
Composable本质是一个利用Composition API的函数,约定命名以use开头,返回响应式状态和方法。良好的Composable应具备以下特征:
– 单一职责:每个Composable只处理一类逻辑,如useFetch管理数据请求,useMouseTrack管理鼠标位置
– 无副作用隔离:不修改外部状态,通过返回值与调用方通信
– 自动清理:在onUnmounted中清理定时器、事件监听等资源
– 可组合:Composable内部可调用其他Composable,形成逻辑组合链
基础Composable:useMousePosition
import { ref, onMounted, onUnmounted } from 'vue'
export function useMousePosition() {
const x = ref(0)
const y = ref(0)
function update(event: MouseEvent) {
x.value = event.pageX
y.value = event.pageY
}
onMounted(() => {
window.addEventListener('mousemove', update)
})
onUnmounted(() => {
window.removeEventListener('mousemove', update)
})
return { x, y }
}
在组件中使用:
<script setup lang="ts">
import { useMousePosition } from './composables/useMousePosition'
const { x, y } = useMousePosition()
</script>
<template>
<p>Mouse position: {{ x }}, {{ y }}</p>
</template>
多个组件可共享同一逻辑,各自维护独立的响应式状态,互不干扰。
异步数据获取:useFetch封装
import { ref, watchEffect, isRef, unref } from 'vue'
interface UseFetchOptions<T> {
immediate?: boolean
initialValue?: T
refetch?: boolean
}
export function useFetch<T>(
url: string | Ref<string>,
options: UseFetchOptions<T> = {}
) {
const data = ref<T | null>(options.initialValue ?? null)
const error = ref<Error | null>(null)
const loading = ref(false)
async function execute() {
loading.value = true
error.value = null
try {
const response = await fetch(unref(url))
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
data.value = await response.json()
} catch (e) {
error.value = e as Error
} finally {
loading.value = false
}
}
if (isRef(url) && options.refetch) {
watchEffect(() => {
execute()
})
} else if (options.immediate !== false) {
execute()
}
return { data, error, loading, execute }
}
支持响应式URL参数:当URL为Ref且启用refetch时,URL变化自动重新请求。immediate控制是否立即执行首次请求。
// 基本用法
const { data, loading, error } = useFetch('/api/users')
// 响应式URL,路由变化时自动重新请求
const userId = ref(1)
const { data: user } = useFetch(
computed(() => `/api/users/${userId.value}`),
{ refetch: true }
)
// 手动触发
const { execute } = useFetch('/api/refresh', { immediate: false })
// 点击按钮时调用 execute()
状态共享:全局Store替代方案
对于需要在多个组件间共享的状态,可利用Vue的响应式系统构建轻量级Store,无需引入Pinia等外部库。
// composables/useCounter.ts
import { ref, computed } from 'vue'
const count = ref(0)
const double = computed(() => count.value * 2)
export function useCounter() {
function increment() {
count.value++
}
function decrement() {
count.value--
}
function reset() {
count.value = 0
}
return { count, double, increment, decrement, reset }
}
关键技巧:将响应式变量定义在函数外部,所有调用useCounter的组件共享同一份count状态。这与Pinia的Store概念一致,但实现更轻量,适合简单场景。
防抖与节流:useDebounce和useThrottle
import { ref, watch, onUnmounted } from 'vue'
export function useDebounce<T>(value: Ref<T>, delay = 300) {
const debounced = ref(value.value) as Ref<T>
let timer: ReturnType<typeof setTimeout>;
watch(value, (newVal) => {
clearTimeout(timer)
timer = setTimeout(() => {
debounced.value = newVal
}, delay)
})
onUnmounted(() => {
clearTimeout(timer)
})
return debounced
}
export function useThrottle<T>(value: Ref<T>, delay = 300) {
const throttled = ref(value.value) as Ref<T>
let lastTime = 0
watch(value, (newVal) => {
const now = Date.now()
if (now - lastTime >= delay) {
throttled.value = newVal
lastTime = now
}
})
return throttled
}
使用场景:
const keyword = ref('')
const debouncedKeyword = useDebounce(keyword, 500)
// 监听防抖后的值触发搜索
watch(debouncedKeyword, (val) => {
if (val) {
searchAPI(val)
}
})
事件总线:useEventBus
import { ref, onUnmounted } from 'vue'
type Handler<T> = (payload: T) => void
export function useEventBus<T = unknown>() {
const listeners = ref<Map<string, Set<Handler<T>>>>(new Map())
function on(event: string, handler: Handler<T>) {
if (!listeners.value.has(event)) {
listeners.value.set(event, new Set())
}
listeners.value.get(event)!.add(handler)
}
function off(event: string, handler: Handler<T>) {
listeners.value.get(event)?.delete(handler)
}
function emit(event: string, payload: T) {
listeners.value.get(event)?.forEach(handler => handler(payload))
}
return { on, off, emit }
}
Composable组合模式
Composable的核心优势在于可自由组合。以下示例组合useFetch、useDebounce和useLocalStorage构建一个带缓存的搜索功能:
import { ref, watch, computed } from 'vue'
import { useFetch } from './useFetch'
import { useDebounce } from './useDebounce'
export function useSearch(apiEndpoint: string) {
const keyword = ref('')
const debouncedKeyword = useDebounce(keyword, 500)
const url = computed(() =>
debouncedKeyword.value
? `${apiEndpoint}?q=${encodeURIComponent(debouncedKeyword.value)}`
: apiEndpoint
)
const { data, loading, error } = useFetch(url, {
refetch: true,
initialValue: []
})
const results = computed(() => data.value || [])
return {
keyword,
results,
loading,
error
}
}
组件中使用一行代码即可获得完整的搜索功能:输入防抖、自动请求、响应式结果。
TypeScript类型安全实践
为Composable定义清晰的返回类型,提升开发体验和代码可维护性:
interface UseMousePositionReturn {
x: Ref<number>
y: Ref<number>
}
export function useMousePosition(): UseMousePositionReturn {
// ...实现
return { x, y }
}
// 泛型Composable
export function useLocalStorage<T>(
key: string,
defaultValue: T
): { value: Ref<T>; remove: () => void } {
const stored = localStorage.getItem(key)
const value = ref<T>(
stored ? JSON.parse(stored) : defaultValue
) as Ref<T>
watch(value, (newVal) => {
localStorage.setItem(key, JSON.stringify(newVal))
});
function remove() {
localStorage.removeItem(key)
}
return { value, remove }
}
测试Composable
使用Vue Test Utils和Vitest可对Composable进行单元测试,验证逻辑正确性:
import { useCounter } from './composables/useCounter'
import { useMousePosition } from './composables/useMousePosition'
import { withSetup } from './test-utils'
test('useCounter increments and decrements', () => {
const { count, increment, decrement, reset } = useCounter()
expect(count.value).toBe(0)
increment()
expect(count.value).toBe(1)
decrement()
expect(count.value).toBe(0)
reset()
expect(count.value).toBe(0)
})
test('useMousePosition tracks mouse', async () => {
const { x, y } = withSetup(() => useMousePosition())
// 模拟鼠标移动
window.dispatchEvent(new MouseEvent('mousemove', { pageX: 100, pageY: 200 }))
await nextTick()
expect(x.value).toBe(100)
expect(y.value).toBe(200)
})
涉及生命周期钩子的Composable需要在组件上下文中测试,使用withSetup辅助函数包裹可解决此问题。
原创文章,作者:小编,如若转载,请注明出处:https://www.yunthe.com/vue3composable-zu-he-shi-han-shu-shi-zhan-luo-ji-fu-yong-yu/