本文 AI 產出,尚未審核

Vue3 組合式函式(Composables)

主題:可重用邏輯封裝


簡介

在 Vue 2 時代,我們常透過 mixinsextend 或是 $emit / $on 來共享程式碼。這些方式雖然能達到「重用」的目的,但往往會產生 命名衝突隱式依賴,以及 難以追蹤的副作用

Vue 3 引入的 Composition API,讓開發者可以把「一段功能」抽離成 Composable(組合式函式),以 純函式 的方式封裝可重用邏輯。Composable 不僅解決了舊有技巧的缺點,還能讓程式碼更具可讀性、可測試性與可維護性,成為中大型專案的最佳實踐。

本篇文章將從 概念說明實作範例常見陷阱最佳實踐,一步步帶你掌握如何在 Vue3 中建立與使用可重用的 Composable,讓你的專案從此更乾淨、更有彈性。


核心概念

1️⃣ 為什麼需要 Composable

  • 邏輯分離:把 UI(template)與行為(logic)分離,讓同一段邏輯可以在多個元件間共享。
  • 避免命名衝突:每個 composable 都是獨立的函式,返回的 refreactivecomputed 等變數都在自己的作用域內,不會與其他 composable 產生衝突。
  • 提升可測試性:因為 composable 本質上是純函式,僅依賴參數與返回值,撰寫單元測試變得非常簡單。

Tip:如果你發現同一段程式碼在兩個以上的元件中出現,這通常就是抽成 composable 的好時機。


2️⃣ 基本寫法

一個最簡單的 composable 只需要一個普通的 JavaScript ,內部使用 Vue3 提供的 reactive API,最後把需要對外暴露的資料或方法回傳:

// src/composables/useCounter.js
import { ref } from 'vue'

export function useCounter(initial = 0) {
  const count = ref(initial)

  function inc(step = 1) {
    count.value += step
  }

  function dec(step = 1) {
    count.value -= step
  }

  // 回傳的物件會在使用的元件中解構
  return { count, inc, dec }
}

在元件中使用方式:

<script setup>
import { useCounter } from '@/composables/useCounter'

const { count, inc, dec } = useCounter(10)
</script>

<template>
  <div>
    <p>目前值:{{ count }}</p>
    <button @click="inc()">+1</button>
    <button @click="dec()">-1</button>
  </div>
</template>

重點useCounter 只是一個普通函式,不會與 Vue 元件的生命週期產生耦合,因此可以在任何地方(包括 Pinia store、測試檔)直接呼叫。


3️⃣ 常見範例

下面提供 3~5 個實務中常見的 composable,每個範例都帶有完整註解,說明其設計思路與使用方式。

3.1 useFetch – 抽象化的資料取得

// src/composables/useFetch.js
import { ref, onMounted, watch } from 'vue'

/**
 * @param {string|Ref<string>} url - 要抓取的 API 位址,支援 Ref 動態變化
 * @param {object} [options] - fetch 的設定
 * @returns {object} { data, error, loading, refetch }
 */
export function useFetch(url, options = {}) {
  const data = ref(null)
  const error = ref(null)
  const loading = ref(false)

  // 真正執行 fetch 的函式,允許外部手動呼叫
  async function fetchData() {
    loading.value = true
    error.value = null
    try {
      const response = await fetch(unref(url), options)
      if (!response.ok) throw new Error(`HTTP ${response.status}`)
      data.value = await response.json()
    } catch (e) {
      error.value = e
    } finally {
      loading.value = false
    }
  }

  // 初始載入
  onMounted(fetchData)

  // 若 url 為 Ref,監聽變化自動重新抓取
  if (isRef(url)) {
    watch(url, () => {
      fetchData()
    })
  }

  return { data, error, loading, refetch: fetchData }
}

使用範例

<script setup>
import { ref } from 'vue'
import { useFetch } from '@/composables/useFetch'

const api = ref('https://api.example.com/posts')
const { data, error, loading, refetch } = useFetch(api)

function changeApi() {
  api.value = 'https://api.example.com/comments'
}
</script>

<template>
  <div v-if="loading">載入中…</div>
  <div v-else-if="error">錯誤:{{ error.message }}</div>
  <pre v-else>{{ data }}</pre>

  <button @click="refetch">重新載入</button>
  <button @click="changeApi">切換 API</button>
</template>

說明:此 composable 把 API 請求、狀態管理、錯誤處理 完全封裝,元件只要關注 UI,保持單一職責。


3.2 useEventListener – 簡化全域或元素事件

// src/composables/useEventListener.js
import { onMounted, onBeforeUnmount } from 'vue'

/**
 * 為指定目標 (window / element) 加上事件監聽
 * @param {EventTarget|Ref<EventTarget>} target - 監聽對象
 * @param {string} type - 事件名稱
 * @param {Function} handler - 事件處理函式
 * @param {Object} [options] - addEventListener 的選項
 */
export function useEventListener(target, type, handler, options) {
  let cleanup = null

  onMounted(() => {
    const el = typeof target === 'function' ? target() : target
    if (!el) return
    el.addEventListener(type, handler, options)
    cleanup = () => el.removeEventListener(type, handler, options)
  })

  onBeforeUnmount(() => {
    if (cleanup) cleanup()
  })
}

使用範例(監聽視窗捲動):

<script setup>
import { ref } from 'vue'
import { useEventListener } from '@/composables/useEventListener'

const scrollY = ref(0)

function onScroll() {
  scrollY.value = window.scrollY
}

// 直接在 setup 裡呼叫即可
useEventListener(window, 'scroll', onScroll)
</script>

<template>
  <p>目前捲動高度:{{ scrollY }} px</p>
</template>

技巧useEventListener 能接受 Ref 作為目標,讓你在元素還未掛載時也能安全使用。


3.3 useLocalStorage – 同步狀態至瀏覽器儲存

// src/composables/useLocalStorage.js
import { ref, watch } from 'vue'

/**
 * 把一個 ref 同步至 localStorage
 * @param {string} key - localStorage 的鍵名
 * @param {any} defaultValue - 若無資料時的預設值
 * @returns {Ref<any>}
 */
export function useLocalStorage(key, defaultValue) {
  const stored = localStorage.getItem(key)
  const data = ref(stored ? JSON.parse(stored) : defaultValue)

  // 當 data 改變時自動寫回 localStorage
  watch(
    data,
    (val) => {
      localStorage.setItem(key, JSON.stringify(val))
    },
    { deep: true }
  )

  return data
}

使用範例(保存暗黑模式設定):

<script setup>
import { computed } from 'vue'
import { useLocalStorage } from '@/composables/useLocalStorage'

const isDark = useLocalStorage('dark-mode', false)

const themeClass = computed(() => (isDark.value ? 'dark' : 'light'))

function toggleTheme() {
  isDark.value = !isDark.value
}
</script>

<template>
  <div :class="themeClass">
    <button @click="toggleTheme">
      切換為 {{ isDark ? '亮色' : '暗色' }} 模式
    </button>
  </div>
</template>

<style scoped>
.dark { background:#222; color:#fff; }
.light { background:#fff; color:#222; }
</style>

說明:只要在任何元件內 import 並呼叫 useLocalStorage,即能在多個元件間共享同一筆持久化資料。


3.4 useFormValidator – 表單驗證的通用解決方案

// src/composables/useFormValidator.js
import { reactive, computed } from 'vue'

/**
 * 建立一個簡易的表單驗證器
 * @param {object} rules - { fieldName: (value) => string | null }
 * @returns {object} { values, errors, isValid, validateField, validateAll }
 */
export function useFormValidator(rules) {
  const values = reactive({})
  const errors = reactive({})

  // 為每個欄位建立 getter / setter
  Object.keys(rules).forEach((field) => {
    values[field] = ''
    errors[field] = null
  })

  function validateField(field) {
    const validator = rules[field]
    const errorMsg = validator(values[field])
    errors[field] = errorMsg
    return !errorMsg
  }

  function validateAll() {
    let ok = true
    Object.keys(rules).forEach((field) => {
      if (!validateField(field)) ok = false
    })
    return ok
  }

  const isValid = computed(() => {
    return Object.values(errors).every((e) => e === null)
  })

  return { values, errors, isValid, validateField, validateAll }
}

使用範例(簡易註冊表單):

<script setup>
import { useFormValidator } from '@/composables/useFormValidator'

const { values, errors, isValid, validateAll } = useFormValidator({
  email: (v) => (!v ? '必填' : !/^\S+@\S+\.\S+$/.test(v) ? '格式錯誤' : null),
  password: (v) => (v.length < 6 ? '至少 6 個字元' : null),
})

function submit() {
  if (validateAll()) {
    alert('表單驗證通過!')
  }
}
</script>

<template>
  <form @submit.prevent="submit">
    <div>
      <label>Email:</label>
      <input v-model="values.email" @blur="validateField('email')" />
      <span class="err">{{ errors.email }}</span>
    </div>

    <div>
      <label>Password:</label>
      <input type="password" v-model="values.password" @blur="validateField('password')" />
      <span class="err">{{ errors.password }}</span>
    </div>

    <button :disabled="!isValid">送出</button>
  </form>
</template>

<style scoped>
.err { color: red; font-size: 0.9em; }
</style>

重點useFormValidator欄位狀態、驗證規則、錯誤訊息 全部封裝,讓表單元件只需要關心 UI,驗證邏輯可在多個表單間共用。


常見陷阱與最佳實踐

陷阱 說明 解決方式 / 最佳實踐
在 composable 中直接使用 this this 只在 Options API 中有意義,Composition API 的函式沒有 this 綁定。 直接使用 refreactivecomputed,或把需要的參數以 參數 形式傳入。
返回的 reactive 物件被解構 解構會失去響應式(例如 const { count } = useCounter() 會變成普通值)。 保留整個物件,或使用 toRefsstoreToRefs,或在解構前使用 const { count } = toRefs(counter)
在 composable 中直接使用 onMountedonUnmounted 等生命週期 若 composable 被呼叫在 非元件上下文(如 Pinia store)會拋錯。 把生命週期掛鉤放在 使用端(元件)或使用 tryOnMounted(VueUse)等安全封裝。
過度抽象 把太小的功能抽成 composable 會導致檔案過多、維護成本上升。 只對 可重用且具體的 邏輯抽象;若僅在單一元件使用,保持在元件內即可。
共享狀態意外變更 多個元件同時使用同一個 composable 回傳的 reactive 物件,會產生全局共享的副作用。 若需要 獨立實例,在 composable 內部建立新的 ref/reactive(如 useCounter() 每次呼叫都返回新物件)。若需要 全局共享,可考慮使用 Pinia 或 provide/inject

最佳實踐小結

  1. 命名慣例:所有 composable 函式統一以 use 開頭(useXxx),方便辨識。
  2. 保持純函式:除非真的需要生命週期掛鉤,否則 composable 內部應避免副作用。
  3. 型別支援:使用 TypeScript 時,為每個返回值寫明確的型別,提升 IDE 補全與錯誤檢查。
  4. 文件化:每個 composable 建議在檔案頂部寫上 JSDoc,說明參數、回傳值與使用情境。
  5. 測試:把 composable 抽成 純函式 後,就能用 Jest / Vitest 輕鬆寫單元測試,確保邏輯正確。

實際應用場景

場景 為何使用 composable 典型範例
多頁面共用的 API 請求 統一錯誤處理、loading 狀態、快取策略 useFetchuseAxios
全局 UI 互動(如彈窗、Toast) 讓任何元件都能呼叫 showToast('訊息'),不必透過事件總線 useToast
跨元件的表單驗證 把驗證規則與錯誤訊息抽離,保持表單 UI 輕量 useFormValidator
螢幕尺寸偵測 & 響應式斷點 在不同元件內部直接取得當前斷點,避免重複寫監聽 useBreakpoints
本地化 & 多語系切換 把 i18n 初始化與語言切換封裝,提供全局 t() 函式 useI18n(Vue I18n 官方提供)
權限控制 依據使用者角色返回可執行的操作列表,元件只負責顯示 useAuthusePermission

案例說明:假設你在一個電商平台,需要在商品列表與商品詳情頁都使用「加入購物車」的功能。透過 useCart composable,你可以在兩個頁面分別呼叫 addItem(product),而所有的狀態(購物車內容、總金額)都集中管理,且只需要在一處修改即時更新 UI,避免重複寫 localStoragewatch 等邏輯。


總結

  • Composable 是 Vue3 中最核心的可重用邏輯封裝機制,讓我們能把「資料取得、事件監聽、狀態同步、表單驗證」等功能抽離成獨立、可測試的函式。
  • 正確的 命名、純函式設計、適度抽象,可以大幅提升專案的可維護性與開發效率。
  • 透過本文的 實作範例useCounteruseFetchuseEventListeneruseLocalStorageuseFormValidator),你已掌握從 簡單計數器完整表單驗證 的全流程。
  • 在實務開發中,務必留意 共享狀態與生命週期 的細節,遵守最佳實踐,才能避免常見陷阱,讓 composable 真正發揮「一次編寫、處處使用」的威力。

最後的建議:在新專案或既有專案的重構階段,先挑選 兩個以上元件共用的功能,試著寫成 composable,感受它帶來的程式碼乾淨度與可測試性提升,你會發現這是一條通往更高品質 Vue 應用的捷徑。祝開發順利! 🚀