Pixiv - KiraraShss
Vue3 Composable 设计模式:可复用组合式函数的 10 个高级技巧
3661 字
18 分钟
Vue3 Composable 设计模式:可复用组合式函数的 10 个高级技巧
Vue3 Composable 设计模式:可复用组合式函数的 10 个高级技巧
Composition API 是 Vue3 最核心的变革。而 Composable(组合式函数)则是 Composition API 的灵魂——它是 Vue3 实现逻辑复用的标准方式,取代了 Vue2 时代的 Mixins、高阶组件等模式。
一个设计良好的 Composable 应该是独立的、可组合的、类型安全的。本文将通过 10 个高级技巧和大量实战代码,帮你掌握 Composable 的设计精髓。
技巧一:自动清理副作用
一个优秀的 Composable 必须管理好自己的生命周期,在组件卸载时自动清理所有副作用。
useEventListener
import { onMounted, onUnmounted, watch, unref, type Ref } from 'vue'
type MaybeRef<T> = T | Ref<T>
export function useEventListener<K extends keyof WindowEventMap>( target: MaybeRef<Window | null>, event: K, handler: (e: WindowEventMap[K]) => void, options?: AddEventListenerOptions): voidexport function useEventListener<K extends keyof HTMLElementEventMap>( target: MaybeRef<HTMLElement | null>, event: K, handler: (e: HTMLElementEventMap[K]) => void, options?: AddEventListenerOptions): voidexport function useEventListener( target: MaybeRef<EventTarget | null>, event: string, handler: EventListenerOrEventListenerObject, options?: AddEventListenerOptions) { let cleanup: (() => void) | undefined
function bindListener() { // 先清理旧的 cleanup?.()
const el = unref(target) if (!el) return
el.addEventListener(event, handler, options) cleanup = () => { el.removeEventListener(event, handler, options) } }
// 如果 target 是 ref,监听变化 if (isRef(target)) { watch(target, bindListener, { immediate: true }) } else { onMounted(bindListener) }
// 组件卸载时自动清理 onUnmounted(() => { cleanup?.() })}使用:
<script setup>import { ref } from 'vue'import { useEventListener } from '@/composables/useEventListener'
const buttonRef = ref<HTMLElement | null>(null)
// 不需要手动清理,组件卸载时自动移除监听useEventListener(window, 'resize', () => { console.log('Window resized')})
useEventListener(buttonRef, 'click', () => { console.log('Button clicked')})</script>
<template> <button ref="buttonRef">Click me</button></template>useInterval 和 useTimeout
import { ref, onUnmounted, watch, type Ref } from 'vue'
export function useInterval( callback: () => void, interval: Ref<number> | number, options: { immediate?: boolean } = {}) { const isActive = ref(false) let timer: ReturnType<typeof setInterval> | null = null
function start() { stop() isActive.value = true if (options.immediate) callback() timer = setInterval(callback, unref(interval)) }
function stop() { if (timer !== null) { clearInterval(timer) timer = null } isActive.value = false }
// 如果 interval 是 ref,监听变化自动重启 if (isRef(interval)) { watch(interval, () => { if (isActive.value) start() }) }
start()
onUnmounted(stop)
return { isActive, start, stop }}技巧二:接受 Ref 和普通值作为参数(MaybeRef 模式)
一个灵活的 Composable 应该同时接受响应式引用和普通值:
import { computed, unref, type Ref } from 'vue'
type MaybeRef<T> = T | Ref<T>type MaybeRefOrGetter<T> = MaybeRef<T> | (() => T)
// 统一解包工具function toValue<T>(source: MaybeRefOrGetter<T>): T { if (typeof source === 'function') { return (source as () => T)() } return unref(source)}useTitle
import { watch, ref, type MaybeRefOrGetter } from 'vue'
export function useTitle( newTitle?: MaybeRefOrGetter<string | null | undefined>, options: { restoreOnUnmount?: boolean } = {}) { const title = ref(document.title) const originalTitle = document.title
if (newTitle !== undefined) { watch( () => toValue(newTitle), (val) => { if (val != null) { document.title = val title.value = val } }, { immediate: true } ) }
if (options.restoreOnUnmount) { onUnmounted(() => { document.title = originalTitle }) }
return title}使用:
<script setup>import { ref, computed } from 'vue'import { useTitle } from '@/composables/useTitle'
const count = ref(0)
// 传入 ref —— 标题会自动更新useTitle(computed(() => `(${count.value}) 我的应用`))
// 传入普通字符串useTitle('静态标题')
// 传入 getteruseTitle(() => `当前计数: ${count.value}`)</script>技巧三:返回值设计——对象 vs 数组
对象解构(推荐大多数场景)
export function useMouse() { const x = ref(0) const y = ref(0) const sourceType = ref<'mouse' | 'touch'>('mouse')
// 返回对象,支持按需解构 return { x, y, sourceType }}
// 使用时可以只取需要的const { x, y } = useMouse()数组解构(适合简单的状态 + 操作模式)
export function useToggle(initialValue = false) { const state = ref(initialValue) const toggle = (value?: boolean) => { state.value = value !== undefined ? value : !state.value }
// 返回数组,允许自定义变量名 return [state, toggle] as const}
// 使用时可以自由命名const [isOpen, toggleOpen] = useToggle()const [isDark, toggleDark] = useToggle(true)混合模式
export function useFetch<T>(url: MaybeRefOrGetter<string>) { const data = ref<T | null>(null) const error = ref<Error | null>(null) const isLoading = ref(false)
async function execute() { isLoading.value = true error.value = null try { const response = await fetch(toValue(url)) if (!response.ok) throw new Error(`HTTP ${response.status}`) data.value = await response.json() } catch (e) { error.value = e instanceof Error ? e : new Error(String(e)) } finally { isLoading.value = false } }
// 返回对象 + 暴露方法 return { data: readonly(data), error: readonly(error), isLoading: readonly(isLoading), execute, // 支持 await then(resolve: Function) { return execute().then(() => resolve({ data, error, isLoading })) } }}技巧四:useFetch——完整的数据请求 Composable
import { ref, readonly, watch, shallowRef, computed, onUnmounted, type MaybeRefOrGetter} from 'vue'
interface UseFetchOptions<T> { immediate?: boolean initialData?: T refetch?: boolean // URL 变化时是否自动重新请求 timeout?: number beforeFetch?: (ctx: { url: string; options: RequestInit }) => void afterFetch?: (ctx: { data: T; response: Response }) => T onFetchError?: (ctx: { error: Error; data: T | null }) => void}
export function useFetch<T = any>( url: MaybeRefOrGetter<string>, fetchOptions: RequestInit = {}, options: UseFetchOptions<T> = {}) { const { immediate = true, initialData = null as T, refetch = true, timeout = 30000 } = options
const data = shallowRef<T | null>(initialData) const error = ref<Error | null>(null) const isLoading = ref(false) const isFinished = ref(false) const abortController = ref<AbortController | null>(null) const statusCode = ref<number | null>(null) const response = shallowRef<Response | null>(null)
// 计算属性 const canAbort = computed(() => isLoading.value)
function abort() { abortController.value?.abort() abortController.value = null isLoading.value = false }
async function execute(throwOnFailed = false): Promise<any> { // 取消之前的请求 abort()
const controller = new AbortController() abortController.value = controller
isLoading.value = true isFinished.value = false error.value = null
const resolvedUrl = toValue(url) const mergedOptions: RequestInit = { ...fetchOptions, signal: controller.signal }
// beforeFetch 钩子 options.beforeFetch?.({ url: resolvedUrl, options: mergedOptions })
// 超时处理 const timeoutId = setTimeout(() => controller.abort(), timeout)
try { const res = await fetch(resolvedUrl, mergedOptions) response.value = res statusCode.value = res.status
if (!res.ok) { throw new Error(`HTTP Error: ${res.status} ${res.statusText}`) }
let result = await res.json() as T
// afterFetch 钩子 if (options.afterFetch) { result = options.afterFetch({ data: result, response: res }) }
data.value = result } catch (e) { if ((e as Error).name === 'AbortError') return
const fetchError = e instanceof Error ? e : new Error(String(e)) error.value = fetchError options.onFetchError?.({ error: fetchError, data: data.value })
if (throwOnFailed) throw fetchError } finally { clearTimeout(timeoutId) isLoading.value = false isFinished.value = true abortController.value = null }
return { data, error, response } }
// URL 变化时自动重新请求 if (refetch) { watch(() => toValue(url), (newUrl) => { if (newUrl) execute() }) }
// 立即执行 if (immediate) { execute() }
// 组件卸载时取消请求 onUnmounted(abort)
return { data: readonly(data), error: readonly(error), isLoading: readonly(isLoading), isFinished: readonly(isFinished), statusCode: readonly(statusCode), response: readonly(response), canAbort, execute, abort }}使用示例:
<script setup>import { ref, computed } from 'vue'import { useFetch } from '@/composables/useFetch'
const page = ref(1)const apiUrl = computed(() => `/api/posts?page=${page.value}&limit=10`)
const { data: posts, isLoading, error, execute: refresh } = useFetch(apiUrl, {}, { afterFetch({ data }) { // 转换数据 return data.map(post => ({ ...post, createdAt: new Date(post.createdAt) })) }, onFetchError({ error }) { console.error('请求失败:', error.message) }})
function nextPage() { page.value++ // URL 自动变化,自动重新请求}</script>
<template> <div> <div v-if="isLoading">加载中...</div> <div v-else-if="error">错误: {{ error.message }}</div> <div v-else> <article v-for="post in posts" :key="post.id"> <h2>{{ post.title }}</h2> </article> <button @click="nextPage">下一页</button> <button @click="refresh()">刷新</button> </div> </div></template>技巧五:useLocalStorage——持久化状态
import { ref, watch, type Ref } from 'vue'
interface UseStorageOptions<T> { serializer?: { read: (raw: string) => T write: (value: T) => string } listenToStorageChanges?: boolean deep?: boolean onError?: (error: Error) => void}
export function useLocalStorage<T>( key: string, initialValue: T, options: UseStorageOptions<T> = {}): Ref<T> { const { serializer = { read: JSON.parse, write: JSON.stringify }, listenToStorageChanges = true, deep = true, onError = console.error } = options
// 从 localStorage 读取初始值 function readFromStorage(): T { try { const raw = localStorage.getItem(key) if (raw !== null) { return serializer.read(raw) } } catch (e) { onError(e as Error) } return initialValue }
// 写入 localStorage function writeToStorage(value: T) { try { if (value === null || value === undefined) { localStorage.removeItem(key) } else { localStorage.setItem(key, serializer.write(value)) } } catch (e) { onError(e as Error) } }
const data = ref(readFromStorage()) as Ref<T>
// 监听变化,自动写入 watch( data, (newValue) => writeToStorage(newValue), { deep } )
// 监听其他标签页的 storage 变化 if (listenToStorageChanges) { useEventListener(window, 'storage', (e: StorageEvent) => { if (e.key === key && e.newValue !== null) { try { data.value = serializer.read(e.newValue) } catch (err) { onError(err as Error) } } }) }
return data}使用:
<script setup>import { useLocalStorage } from '@/composables/useLocalStorage'
// 自动持久化到 localStorageconst theme = useLocalStorage('app-theme', 'light')const userPrefs = useLocalStorage('user-prefs', { fontSize: 14, language: 'zh-CN', sidebarCollapsed: false})
function toggleTheme() { theme.value = theme.value === 'light' ? 'dark' : 'light' // 自动保存到 localStorage}</script>技巧六:组合 Composable(Composable 的组合)
Composable 最强大的地方在于它们可以互相组合:
useDark
import { computed } from 'vue'import { useLocalStorage } from './useLocalStorage'import { useMediaQuery } from './useMediaQuery'
export function useDark() { // 组合 useLocalStorage const userPreference = useLocalStorage<'dark' | 'light' | 'auto'>( 'color-scheme', 'auto' )
// 组合 useMediaQuery const prefersDark = useMediaQuery('(prefers-color-scheme: dark)')
const isDark = computed({ get() { if (userPreference.value === 'auto') { return prefersDark.value } return userPreference.value === 'dark' }, set(value: boolean) { userPreference.value = value ? 'dark' : 'light' } })
// 自动更新 document class watch(isDark, (dark) => { document.documentElement.classList.toggle('dark', dark) }, { immediate: true })
return { isDark, preference: userPreference, toggle: () => { isDark.value = !isDark.value } }}import { ref, onUnmounted } from 'vue'
export function useMediaQuery(query: string) { const matches = ref(false)
let mediaQuery: MediaQueryList | undefined
function update() { mediaQuery = window.matchMedia(query) matches.value = mediaQuery.matches }
function handler(e: MediaQueryListEvent) { matches.value = e.matches }
update() mediaQuery?.addEventListener('change', handler)
onUnmounted(() => { mediaQuery?.removeEventListener('change', handler) })
return matches}技巧七:依赖注入与 Composable
利用 provide/inject 让 Composable 可以在组件树中共享状态:
useSharedState
import { ref, provide, inject, type InjectionKey } from 'vue'
interface Notification { id: number type: 'success' | 'error' | 'warning' | 'info' message: string duration?: number}
interface NotificationContext { notifications: Ref<Notification[]> notify: (notification: Omit<Notification, 'id'>) => void remove: (id: number) => void clear: () => void}
const NotificationKey: InjectionKey<NotificationContext> = Symbol('notification')
let nextId = 0
// Provider Composable —— 在根组件调用export function provideNotification() { const notifications = ref<Notification[]>([])
function notify(notification: Omit<Notification, 'id'>) { const id = nextId++ const item: Notification = { ...notification, id } notifications.value.push(item)
// 自动移除 const duration = notification.duration ?? 3000 if (duration > 0) { setTimeout(() => remove(id), duration) } }
function remove(id: number) { const index = notifications.value.findIndex(n => n.id === id) if (index !== -1) { notifications.value.splice(index, 1) } }
function clear() { notifications.value = [] }
const context: NotificationContext = { notifications, notify, remove, clear }
provide(NotificationKey, context)
return context}
// Consumer Composable —— 在任何子组件调用export function useNotification(): NotificationContext { const context = inject(NotificationKey) if (!context) { throw new Error('useNotification() must be used inside a component tree with provideNotification()') } return context}使用:
<script setup>import { provideNotification } from '@/composables/useNotification'import NotificationContainer from './NotificationContainer.vue'
const { notifications } = provideNotification()</script>
<template> <div id="app"> <router-view /> <NotificationContainer :notifications="notifications" /> </div></template><!-- 任何子组件 --><script setup>import { useNotification } from '@/composables/useNotification'
const { notify } = useNotification()
async function handleSave() { try { await saveData() notify({ type: 'success', message: '保存成功!' }) } catch (e) { notify({ type: 'error', message: '保存失败: ' + e.message }) }}</script>技巧八:useVModel——双向绑定的优雅封装
import { computed, getCurrentInstance } from 'vue'
export function useVModel< P extends Record<string, any>, K extends keyof P>( props: P, key: K, emit?: (event: string, ...args: any[]) => void) { const vm = getCurrentInstance() const _emit = emit || vm?.emit
if (!_emit) { throw new Error('useVModel: emit function is required') }
return computed({ get() { return props[key] }, set(value) { _emit(`update:${String(key)}`, value) } })}使用:
<script setup>import { useVModel } from '@/composables/useVModel'
const props = defineProps<{ modelValue: boolean title: string}>()
const emit = defineEmits<{ 'update:modelValue': [value: boolean] 'update:title': [value: string]}>()
// 直接当 ref 用,修改时自动 emitconst isOpen = useVModel(props, 'modelValue', emit)const dialogTitle = useVModel(props, 'title', emit)
function close() { isOpen.value = false // 自动触发 emit('update:modelValue', false)}</script>
<template> <Teleport to="body"> <div v-if="isOpen" class="modal-overlay" @click.self="close"> <div class="modal-dialog"> <input v-model="dialogTitle" /> <slot /> <button @click="close">关闭</button> </div> </div> </Teleport></template>技巧九:useAsyncState——异步状态管理
import { ref, shallowRef, readonly, type Ref } from 'vue'
interface UseAsyncStateOptions<T> { immediate?: boolean resetOnExecute?: boolean onError?: (e: Error) => void onSuccess?: (data: T) => void delay?: number}
export function useAsyncState<T, Args extends any[] = []>( promise: (...args: Args) => Promise<T>, initialState: T, options: UseAsyncStateOptions<T> = {}) { const { immediate = true, resetOnExecute = false, onError, onSuccess, delay = 0 } = options
const state = shallowRef<T>(initialState) const isReady = ref(false) const isLoading = ref(false) const error = ref<Error | null>(null)
async function execute(...args: Args) { if (resetOnExecute) { state.value = initialState } error.value = null isReady.value = false isLoading.value = true
if (delay > 0) { await new Promise(r => setTimeout(r, delay)) }
try { const result = await promise(...args) state.value = result isReady.value = true onSuccess?.(result) } catch (e) { const err = e instanceof Error ? e : new Error(String(e)) error.value = err onError?.(err) } finally { isLoading.value = false }
return state.value }
if (immediate) { execute(...([] as unknown as Args)) }
return { state: readonly(state) as Readonly<Ref<T>>, isReady: readonly(isReady), isLoading: readonly(isLoading), error: readonly(error), execute }}使用:
<script setup>import { useAsyncState } from '@/composables/useAsyncState'
interface User { id: number name: string email: string}
const { state: users, isLoading, error, execute: fetchUsers } = useAsyncState<User[]>( async () => { const res = await fetch('/api/users') return res.json() }, [], // 初始值 { onError(e) { console.error('获取用户失败:', e) }, onSuccess(data) { console.log(`获取到 ${data.length} 个用户`) } })</script>
<template> <div> <button @click="fetchUsers()" :disabled="isLoading"> {{ isLoading ? '加载中...' : '刷新' }} </button> <p v-if="error" class="error">{{ error.message }}</p> <ul> <li v-for="user in users" :key="user.id"> {{ user.name }} - {{ user.email }} </li> </ul> </div></template>技巧十:useInfiniteScroll——无限滚动
这个 Composable 组合了多个底层 Composable,展示了复杂 Composable 的设计模式:
import { ref, computed, watch, type Ref } from 'vue'import { useEventListener } from './useEventListener'import { useThrottle } from './useThrottle'
interface UseInfiniteScrollOptions { distance?: number // 距离底部多少像素触发加载 direction?: 'top' | 'bottom' preserveScrollPosition?: boolean canLoadMore?: Ref<boolean> | (() => boolean)}
export function useInfiniteScroll( container: Ref<HTMLElement | null>, loadMore: () => Promise<void>, options: UseInfiniteScrollOptions = {}) { const { distance = 100, direction = 'bottom', preserveScrollPosition = false, } = options
const isLoading = ref(false) const isComplete = ref(false) // 没有更多数据了
const canLoadMore = computed(() => { if (isComplete.value) return false if (isLoading.value) return false if (options.canLoadMore) { return typeof options.canLoadMore === 'function' ? options.canLoadMore() : options.canLoadMore.value } return true })
async function handleLoad() { if (!canLoadMore.value) return
isLoading.value = true const el = container.value const previousScrollHeight = el?.scrollHeight || 0
try { await loadMore() } catch (e) { console.error('加载更多失败:', e) } finally { isLoading.value = false
// 向上加载时保持滚动位置 if (preserveScrollPosition && direction === 'top' && el) { const newScrollHeight = el.scrollHeight el.scrollTop += newScrollHeight - previousScrollHeight } } }
function checkShouldLoad() { const el = container.value if (!el || !canLoadMore.value) return
if (direction === 'bottom') { const { scrollTop, scrollHeight, clientHeight } = el if (scrollTop + clientHeight >= scrollHeight - distance) { handleLoad() } } else { if (el.scrollTop <= distance) { handleLoad() } } }
// 节流滚动检查 const throttledCheck = useThrottle(checkShouldLoad, 200)
// 监听滚动事件 useEventListener(container, 'scroll', throttledCheck)
// 容器变化时检查一次 watch(container, () => { if (container.value) checkShouldLoad() })
function done() { isComplete.value = true }
function reset() { isComplete.value = false isLoading.value = false }
return { isLoading: readonly(isLoading), isComplete: readonly(isComplete), done, reset, check: checkShouldLoad }}export function useThrottle(fn: Function, delay: number) { let lastCall = 0 let timer: ReturnType<typeof setTimeout> | null = null
return function throttled(...args: any[]) { const now = Date.now() const remaining = delay - (now - lastCall)
if (remaining <= 0) { if (timer) { clearTimeout(timer) timer = null } lastCall = now fn(...args) } else if (!timer) { timer = setTimeout(() => { lastCall = Date.now() timer = null fn(...args) }, remaining) } }}使用:
<script setup>import { ref } from 'vue'import { useInfiniteScroll } from '@/composables/useInfiniteScroll'
const listContainer = ref<HTMLElement | null>(null)const items = ref<any[]>([])const page = ref(1)
const { isLoading, isComplete, done } = useInfiniteScroll( listContainer, async () => { const res = await fetch(`/api/items?page=${page.value}`) const data = await res.json()
if (data.length === 0) { done() // 没有更多数据 return }
items.value.push(...data) page.value++ })</script>
<template> <div ref="listContainer" class="scroll-container" style="height: 500px; overflow-y: auto;"> <div v-for="item in items" :key="item.id" class="item"> {{ item.name }} </div> <div v-if="isLoading" class="loading">加载中...</div> <div v-if="isComplete" class="complete">没有更多了</div> </div></template>Composable 设计原则总结
经过以上 10 个技巧的实践,我们可以总结出几条 Composable 设计原则:
1. 命名规范
// ✅ 以 use 开头export function useCounter() {}export function useFetch() {}
// ✅ provide 系列以 provide/create 开头export function provideTheme() {}export function createSharedComposable() {}
// ❌ 不要省略 use 前缀export function counter() {}export function fetchData() {}2. 输入灵活性
// ✅ 接受 MaybeRefOrGetterfunction useFetch(url: MaybeRefOrGetter<string>) { const resolvedUrl = computed(() => toValue(url))}
// 这样调用方可以传入任何形式useFetch('/api/users') // 字符串useFetch(urlRef) // RefuseFetch(() => `/api/users/${id}`) // Getter3. 输出只读性
// ✅ 返回 readonly refreturn { data: readonly(data), isLoading: readonly(isLoading), execute // 方法不需要 readonly}
// ❌ 直接暴露可写 refreturn { data, // 外部可以直接修改,破坏数据一致性 isLoading}4. 副作用自清理
export function useXxx() { const timer = setInterval(/* ... */) const handler = () => { /* ... */ } window.addEventListener('resize', handler)
// ✅ 必须在 onUnmounted 中清理 onUnmounted(() => { clearInterval(timer) window.removeEventListener('resize', handler) })}5. SSR 兼容性
export function useLocalStorage(key: string, initialValue: any) { // ✅ 检查环境 if (typeof window === 'undefined') { return ref(initialValue) // SSR 环境返回默认值 } // 浏览器环境的逻辑...}掌握这些设计模式和技巧,你就能写出真正可复用、可组合、可测试的 Vue3 Composable,构建出更优雅的前端架构。
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!
Vue3 Composable 设计模式:可复用组合式函数的 10 个高级技巧
https://boke.hackerdream.xyz/posts/vue3-composable-patterns/ 相关文章 智能推荐
1
Vue3 组合式 AI:用 Composables 封装大模型能力的工程实践
前端架构 深入探索如何用 Vue3 Composables 优雅封装 LLM 流式调用、Token 管理和多模型切换,附完整代码与性能对比,打造可复用的前端 AI 能力层。
2
Vue3 依赖注入深入:provide/inject 的架构设计与最佳实践
Vue 深入 2026-01-21
3
Vue3 编译器优化:静态提升、补丁标记与 Block Tree 的实现原理
Vue 深入 2026-03-04
4
Vue3 自定义渲染器:从零实现一个 Canvas 渲染器
Vue 深入 2026-03-07
5
Vue3 Suspense 与异步组件:优雅处理加载状态的完整方案
Vue 深入 2026-01-24
随机文章 随机推荐