Composables设计模式与单例陷阱
Vue3的组合式API最大的价值在于逻辑复用。但很多开发者在封装Composable时,会把状态和逻辑混在一起,结果复用时出现状态污染。正确的做法是区分有状态和无状态两种Composable,并根据场景选择。
无状态Composable只封装逻辑,不持有响应式数据,每次调用返回新的实例:
import { ref, onMounted, onUnmounted } from 'vue'
export function useEventListener(target, event, handler) {
onMounted(() => target.addEventListener(event, handler))
onUnmounted(() => target.removeEventListener(event, handler))
}
有状态Composable持有响应式数据,需要确保每次调用创建独立的响应式引用,而不是在模块顶层定义共享状态:
// 错误写法:模块级共享状态
const count = ref(0) // 所有组件共享同一个ref!
export function useCounter() {
return { count }
}
// 正确写法:每次调用创建新实例
export function useCounter(initial = 0) {
const count = ref(initial)
const increment = () => count.value++
const decrement = () => count.value--
return { count, increment, decrement }
}
模块顶层的ref(0)是一个单例,所有引入该模块的组件会共享同一个响应式变量。这在某些全局状态场景下是有意的(类似Pinia的store),但在多数Composable中是Bug来源。
异步数据获取Composable封装
前端开发中最常见的可复用逻辑是异步数据获取。一个健壮的useFetch需要处理加载状态、错误处理、请求取消和竞态条件:
import { ref, shallowRef } from 'vue'
export function useFetch(url) {
const data = shallowRef(null)
const error = shallowRef(null)
const loading = ref(false)
let controller = null
const execute = async () => {
if (controller) controller.abort()
controller = new AbortController()
loading.value = true
error.value = null
try {
const res = await fetch(url, { signal: controller.signal })
if (!res.ok) throw new Error(`HTTP ${res.status}`)
data.value = await res.json()
} catch (e) {
if (e.name !== 'AbortError') {
error.value = e
}
} finally {
loading.value = false
}
}
execute()
return { data, error, loading, refetch: execute }
}
这里有几个设计要点:使用shallowRef而非ref存储data,避免深层响应式代理对大型JSON对象的性能开销;AbortController确保快速切换时旧请求被取消,不会出现竞态问题;错误处理中排除AbortError,避免取消请求被当作真实错误上报。
TypeScript泛型增强类型推断
上面的useFetch返回的data类型是any,在实际使用中缺乏类型安全。通过泛型约束可以提供精确的类型推断:
import { ref, shallowRef } from 'vue'
export function useFetch<T>(url: string) {
const data = shallowRef<T | null>(null)
const error = shallowRef<Error | null>(null)
const loading = ref(false)
let controller: AbortController | null = null
const execute = async () => {
if (controller) controller.abort()
controller = new AbortController()
loading.value = true
error.value = null
try {
const res = await fetch(url, { signal: controller.signal })
if (!res.ok) throw new Error(`HTTP ${res.status}`)
data.value = await res.json() as T
} catch (e) {
if (e instanceof Error && e.name !== 'AbortError') {
error.value = e
}
} finally {
loading.value = false
}
}
execute()
return { data, error, loading, refetch: execute }
}
// 使用时指定返回类型
interface User {
id: number
name: string
email: string
}
const { data, loading } = useFetch<User>('/api/user/1')
// data.value 类型为 User | null
泛型参数T让调用方指定响应数据的类型结构。配合Vue3.3+的泛型组件语法,Composable的泛型参数还可以传递到组件props上,实现端到端的类型安全。
防抖与节流Composable实战
搜索输入、窗口resize等高频事件需要防抖/节流处理。封装为Composable后可以在组件中声明式使用:
import { ref, watch, onUnmounted } from 'vue'
export function useDebounce<T>(value: Ref<T>, delay: number) {
const debouncedValue = ref(value.value) as Ref<T>
let timer: ReturnType<typeof setTimeout>
watch(value, (newVal) => {
clearTimeout(timer)
timer = setTimeout(() => {
debouncedValue.value = newVal
}, delay)
})
onUnmounted(() => clearTimeout(timer))
return debouncedValue
}
// 组件中使用
const keyword = ref('')
const debouncedKeyword = useDebounce(keyword, 300)
watch(debouncedKeyword, (val) => {
// 300ms防抖后触发搜索
fetchSearchResults(val)
})
onUnmounted中清除定时器是必要的,否则组件销毁后回调仍会执行,可能导致对已销毁组件状态的非法访问。这个Composable同样支持泛型,Ref<T>确保输入输出的类型一致。
Composable测试策略
Composable的单元测试比组件测试简单得多,因为它是纯逻辑函数。使用Vue的withSetup工具函数可以快速测试:
import { withSetup } from './test-utils'
import { useCounter } from './useCounter'
test('useCounter increments and decrements', () => {
const [result] = withSetup(() => useCounter(5))
expect(result.count.value).toBe(5)
result.increment()
expect(result.count.value).toBe(6)
result.decrement()
expect(result.count.value).toBe(5)
})
withSetup封装了创建和销毁组件上下文的逻辑,让Composable测试像普通函数测试一样简洁。对于依赖浏览器API的Composable(如useEventListener),可以在测试环境中mock对应API。Composable封装的价值在这里充分体现——逻辑从组件中抽离后,测试不再需要挂载组件、模拟DOM事件,直接验证输入输出即可。
原创文章,作者:小编,如若转载,请注明出处:https://www.yunthe.com/vue3-zu-he-shi-han-shu-feng-zhuang-shi-zhan-cong-ke-fu-yong/