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#

composables/useEventListener.ts
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
): void
export function useEventListener<K extends keyof HTMLElementEventMap>(
target: MaybeRef<HTMLElement | null>,
event: K,
handler: (e: HTMLElementEventMap[K]) => void,
options?: AddEventListenerOptions
): void
export 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#

composables/useInterval.ts
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#

composables/useTitle.ts
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('静态标题')
// 传入 getter
useTitle(() => `当前计数: ${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#

composables/useFetch.ts
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——持久化状态#

composables/useLocalStorage.ts
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'
// 自动持久化到 localStorage
const 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#

composables/useDark.ts
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 }
}
}
composables/useMediaQuery.ts
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#

composables/useNotification.ts
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
}

使用:

App.vue
<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——双向绑定的优雅封装#

composables/useVModel.ts
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)
}
})
}

使用:

ModalDialog.vue
<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 用,修改时自动 emit
const 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——异步状态管理#

composables/useAsyncState.ts
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 的设计模式:

composables/useInfiniteScroll.ts
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
}
}
composables/useThrottle.ts
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. 输入灵活性#

// ✅ 接受 MaybeRefOrGetter
function useFetch(url: MaybeRefOrGetter<string>) {
const resolvedUrl = computed(() => toValue(url))
}
// 这样调用方可以传入任何形式
useFetch('/api/users') // 字符串
useFetch(urlRef) // Ref
useFetch(() => `/api/users/${id}`) // Getter

3. 输出只读性#

// ✅ 返回 readonly ref
return {
data: readonly(data),
isLoading: readonly(isLoading),
execute // 方法不需要 readonly
}
// ❌ 直接暴露可写 ref
return {
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/
作者
晴天
发布于
2026-01-18
许可协议
CC BY-NC-SA 4.0
Profile Image of the Author
晴天
Hello, I'm 晴天.
公告
欢迎来到我的博客!这是一则示例公告。
音乐
封面

音乐

暂未播放

0:00 0:00
暂无歌词
分类
标签
站点统计
文章
125
分类
17
标签
287
总字数
257,955
运行时长
0
最后活动
0 天前

目录