Vue 3 Composables 設計模式:打造可複用的邏輯層
11 分鐘閱讀 1,200 字
Vue 3 Composables 設計模式:打造可複用的邏輯層
Composables 是 Vue 3 Composition API 最重要的概念之一,用來封裝和複用有狀態的邏輯。良好的 Composables 設計能讓元件瘦身、邏輯集中、測試方便。本文介紹幾種實用的設計模式。
Composables 基本結構
一個 Composable 是以 use 開頭的函式,內部使用 Composition API:
// composables/useCounter.ts
import { ref, computed } from 'vue'
export function useCounter(initialValue = 0) {
const count = ref(initialValue)
const doubled = computed(() => count.value * 2)
function increment() { count.value++ }
function decrement() { count.value-- }
function reset() { count.value = initialValue }
return { count, doubled, increment, decrement, reset }
}
// 在元件中使用
// <script setup>
// const { count, increment, reset } = useCounter(10)模式一:資料獲取 Composable
最常見的應用場景是封裝非同步資料請求:
// composables/useFetch.ts
import { ref, shallowRef, watchEffect, toValue, type MaybeRefOrGetter } from 'vue'
export function useFetch<T>(url: MaybeRefOrGetter<string>) {
const data = shallowRef<T | null>(null)
const error = ref<Error | null>(null)
const loading = ref(false)
async function fetchData() {
const resolvedUrl = toValue(url)
if (!resolvedUrl) return
loading.value = true
error.value = null
try {
const response = await fetch(resolvedUrl)
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
data.value = await response.json()
} catch (e) {
error.value = e instanceof Error ? e : new Error(String(e))
} finally {
loading.value = false
}
}
// watchEffect 自動追蹤 url 的變化
watchEffect(() => {
fetchData()
})
return { data, error, loading, refresh: fetchData }
}
// 使用:url 可以是響應式的
const userId = ref(1)
const { data: user, loading } = useFetch<User>(
() => `/api/users/${userId.value}`
)
// userId.value = 2 → 自動重新請求模式二:本地儲存同步
// composables/useLocalStorage.ts
import { ref, watch } from 'vue'
export function useLocalStorage<T>(key: string, defaultValue: T) {
const stored = localStorage.getItem(key)
const value = ref<T>(stored ? JSON.parse(stored) : defaultValue)
watch(
value,
(newValue) => {
if (newValue === null || newValue === undefined) {
localStorage.removeItem(key)
} else {
localStorage.setItem(key, JSON.stringify(newValue))
}
},
{ deep: true }
)
return value
}
// 使用
const theme = useLocalStorage('theme', 'light')
theme.value = 'dark' // 自動儲存到 localStorage模式三:事件監聽管理
自動在元件卸載時移除事件監聽:
// composables/useEventListener.ts
import { onMounted, onUnmounted, type Ref } from 'vue'
export function useEventListener<K extends keyof WindowEventMap>(
target: Window | Document | Ref<HTMLElement | null>,
event: K,
handler: (event: WindowEventMap[K]) => void,
options?: AddEventListenerOptions
) {
onMounted(() => {
const el = 'value' in target ? target.value : target
el?.addEventListener(event, handler as EventListener, options)
})
onUnmounted(() => {
const el = 'value' in target ? target.value : target
el?.removeEventListener(event, handler as EventListener, options)
})
}
// 使用:自動管理生命週期
const handleResize = () => console.log('resized')
useEventListener(window, 'resize', handleResize)模式四:無限滾動
// composables/useInfiniteScroll.ts
import { ref, onMounted, onUnmounted } from 'vue'
export function useInfiniteScroll(
containerRef: Ref<HTMLElement | null>,
fetchMore: () => Promise<boolean> // 返回 false 表示沒有更多資料
) {
const isLoading = ref(false)
const hasMore = ref(true)
const observer = new IntersectionObserver(
async ([entry]) => {
if (entry.isIntersecting && !isLoading.value && hasMore.value) {
isLoading.value = true
hasMore.value = await fetchMore()
isLoading.value = false
}
},
{ threshold: 0.1 }
)
const sentinel = ref<HTMLElement | null>(null)
onMounted(() => {
if (sentinel.value) {
observer.observe(sentinel.value)
}
})
onUnmounted(() => observer.disconnect())
return { isLoading, hasMore, sentinel }
}模式五:表單驗證
// composables/useForm.ts
import { reactive, computed } from 'vue'
type ValidationRule<T> = (value: T) => string | true
interface FieldConfig<T> {
initialValue: T
rules?: ValidationRule<T>[]
}
export function useForm<T extends Record<string, any>>(
config: { [K in keyof T]: FieldConfig<T[K]> }
) {
const values = reactive(
Object.fromEntries(
Object.entries(config).map(([key, { initialValue }]) => [key, initialValue])
)
) as T
const errors = reactive(
Object.fromEntries(Object.keys(config).map(key => [key, '']))
) as Record<keyof T, string>
function validate(): boolean {
let isValid = true
for (const [field, { rules }] of Object.entries(config)) {
if (!rules) continue
for (const rule of rules) {
const result = rule((values as any)[field])
if (result !== true) {
(errors as any)[field] = result
isValid = false
break
} else {
(errors as any)[field] = ''
}
}
}
return isValid
}
const isValid = computed(() =>
Object.values(errors).every(e => e === '')
)
function reset() {
for (const [key, { initialValue }] of Object.entries(config)) {
(values as any)[key] = initialValue
(errors as any)[key] = ''
}
}
return { values, errors, validate, isValid, reset }
}
// 使用
const { values, errors, validate } = useForm({
email: {
initialValue: '',
rules: [
v => !!v || '必填',
v => /.+@.+\..+/.test(v) || 'Email 格式不正確'
]
},
password: {
initialValue: '',
rules: [
v => !!v || '必填',
v => v.length >= 8 || '密碼至少 8 個字元'
]
}
})Composables 設計原則
- 單一責任:每個 Composable 只處理一個關注點
- 返回響應式值:回傳
ref或reactive,讓呼叫端可以解構 - 自動清理:在
onUnmounted中清理訂閱、事件監聽、計時器 - 接受 MaybeRefOrGetter:讓參數可以是靜態值或響應式值,增加靈活性
- 明確的命名:
use前綴加上描述性名稱
總結
Composables 是 Vue 3 中取代 Mixin 的更好解法,解決了命名衝突和來源不清的問題。通過本文的五種模式(資料獲取、本地儲存、事件管理、無限滾動、表單驗證),你已經掌握了 Composables 的核心設計思維,可以根據實際需求靈活組合。
分享這篇文章