跳至主要內容

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 設計原則

  1. 單一責任:每個 Composable 只處理一個關注點
  2. 返回響應式值:回傳 refreactive,讓呼叫端可以解構
  3. 自動清理:在 onUnmounted 中清理訂閱、事件監聽、計時器
  4. 接受 MaybeRefOrGetter:讓參數可以是靜態值或響應式值,增加靈活性
  5. 明確的命名use 前綴加上描述性名稱

總結

Composables 是 Vue 3 中取代 Mixin 的更好解法,解決了命名衝突和來源不清的問題。通過本文的五種模式(資料獲取、本地儲存、事件管理、無限滾動、表單驗證),你已經掌握了 Composables 的核心設計思維,可以根據實際需求靈活組合。

分享這篇文章