本文 AI 產出,尚未審核

Vue3 Composition API(核心)— 組合函式(Composables)

簡介

在 Vue 3 中,Composition API 為我們提供了一種全新的組織程式碼的方式。相較於 Options API,Composition API 讓邏輯更易於抽取、重用與測試,而 組合函式(Composables) 正是這個概念的核心。

透過 composable,我們可以把「相關的 state、computed、lifecycle 與 side‑effect」封裝成一個獨立的函式,然後在多個元件中直接引用。這不僅讓程式碼更乾淨,也提升了團隊協作的效率,因為每個功能模組都能像 npm 套件一樣被管理與維護。

本文將從概念說明、實作範例、常見陷阱到最佳實踐,完整闡述如何在 Vue 3 中打造高品質的 composable,協助 初學者到中級開發者 快速上手並在專案中落地。


核心概念

1. 什麼是 Composable?

  • Composable 本質上是一個 普通的 JavaScript 函式,它在內部使用 Vue 的 reactiverefcomputedwatchonMounted 等 API,然後把需要對外提供的資料或功能返回。
  • 它不依賴於任何特定的元件實例,因而可以在 任意組件、甚至 純 JavaScript 模組 中使用。

重點:Composable 只是一段「可組合」的邏輯,與元件的模板(template)是分離的。

2. 為什麼要使用 Composable?

傳統 Options API 使用 Composable
datamethodscomputedwatch 混雜在同一個物件裡 邏輯依功能分離,易於重用
同類型的功能散落在多個生命週期鉤子中 相關的 state、side‑effect 集中在同一函式
測試困難,必須掛載整個元件 只測試純函式,無需 Vue 實例
大型元件會變成「巨型」的 Options 物件 小而專注的模組,提升可讀性

3. 建立 Composable 的基本步驟

  1. 建立檔案(慣例以 use 開頭,例如 useFetch.js)。
  2. 在函式內部使用 Vue APIrefreactivecomputedwatchonMounted
  3. 返回想要外部存取的值(可以是單一值、物件或函式)。
  4. 在元件中引入並呼叫,把返回的內容解構使用。

下面將透過多個實務範例說明每個步驟的細節。


程式碼範例

範例 1️⃣:useCounter — 最簡單的計數器

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

/**
 * 回傳一個計數器的 state 與操作函式
 * @param {number} [initial=0] 初始值
 */
export function useCounter(initial = 0) {
  const count = ref(initial)

  const increment = () => count.value++
  const decrement = () => count.value--
  const reset = () => (count.value = initial)

  // 只回傳需要的部分,保持 API 簡潔
  return { count, increment, decrement, reset }
}

使用方式

<template>
  <div>
    <p>目前計數:{{ count }}</p>
    <button @click="increment">+</button>
    <button @click="decrement">-</button>
    <button @click="reset">重設</button>
  </div>
</template>

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

const { count, increment, decrement, reset } = useCounter(10)
</script>

說明useCounter 完全不依賴於任何元件,僅返回 ref 與操作函式,讓多個元件可以共享相同的計數邏輯。


範例 2️⃣:useFetch — 簡易的資料抓取 (含錯誤處理 & loading)

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

/**
 * 依據傳入的 URL 取得資料
 * @param {string|Ref<string>} url
 */
export function useFetch(url) {
  const data = ref(null)
  const error = ref(null)
  const loading = ref(false)

  const fetchData = async (target) => {
    loading.value = true
    error.value = null
    try {
      const res = await fetch(target)
      if (!res.ok) throw new Error(`HTTP ${res.status}`)
      data.value = await res.json()
    } catch (e) {
      error.value = e
    } finally {
      loading.value = false
    }
  }

  // 支援 url 為 Ref 時自動重新抓取
  watchEffect(() => {
    const target = typeof url === 'string' ? url : url.value
    if (target) fetchData(target)
  })

  // 允許手動重新抓取
  const refetch = () => fetchData(typeof url === 'string' ? url : url.value)

  return { data, error, loading, refetch }
}

使用方式

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

<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)
</script>

說明watchEffecturlref 時自動重新發送請求;refetch 提供手動觸發的彈性。此 composable 可在任何需要資料抓取的元件中直接使用。


範例 3️⃣:useWindowSize — 監聽視窗尺寸(含防抖)

// src/composables/useWindowSize.js
import { ref, onMounted, onUnmounted } from 'vue'

/**
 * 取得視窗寬高,支援防抖
 * @param {number} [delay=200] 防抖毫秒
 */
export function useWindowSize(delay = 200) {
  const width = ref(window.innerWidth)
  const height = ref(window.innerHeight)

  let timer = null
  const onResize = () => {
    clearTimeout(timer)
    timer = setTimeout(() => {
      width.value = window.innerWidth
      height.value = window.innerHeight
    }, delay)
  }

  onMounted(() => window.addEventListener('resize', onResize))
  onUnmounted(() => {
    window.removeEventListener('resize', onResize)
    clearTimeout(timer)
  })

  return { width, height }
}

使用方式

<template>
  <p>寬度:{{ width }}px, 高度:{{ height }}px</p>
</template>

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

const { width, height } = useWindowSize(100) // 100ms 防抖
</script>

說明:此 composable 把 addEventListenerremoveEventListener 的清理工作交給 onMounted / onUnmounted,確保不會造成記憶體泄漏。


範例 4️⃣:useLocalStorage — 同步 Ref 到 localStorage

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

/**
 * 把一個 ref 同步到 localStorage
 * @param {string} key localStorage 的鍵名
 * @param {*} initial 初始值(若 localStorage 沒有則使用此值)
 */
export function useLocalStorage(key, initial) {
  const stored = localStorage.getItem(key)
  const state = ref(stored ? JSON.parse(stored) : initial)

  // 任何時候 state 改變,都寫回 localStorage
  watch(
    state,
    (val) => {
      if (val === undefined) {
        localStorage.removeItem(key)
      } else {
        localStorage.setItem(key, JSON.stringify(val))
      }
    },
    { deep: true }
  )

  return state
}

使用方式

<template>
  <input v-model="name" placeholder="輸入名字" />
  <p>持久化的名字:{{ name }}</p>
</template>

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

const name = useLocalStorage('user-name', '')
</script>

說明:只要 name 被修改,就會自動同步到 localStorage,頁面重新載入時仍能恢復先前的值。


範例 5️⃣:useFormValidator — 表單驗證的通用邏輯

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

/**
 * 建立表單驗證器
 * @param {Object} rules 欄位驗證規則,形如 { field: (value) => errorMessage|null }
 * @param {Object} initialValues 初始表單值
 */
export function useFormValidator(rules, initialValues = {}) {
  const values = ref({ ...initialValues })
  const errors = ref({})

  const validateField = (field) => {
    const validator = rules[field]
    if (!validator) return null
    const error = validator(values.value[field])
    errors.value[field] = error
    return error
  }

  const validateAll = () => {
    let isValid = true
    Object.keys(rules).forEach((field) => {
      const err = validateField(field)
      if (err) isValid = false
    })
    return isValid
  }

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

  const onSubmit = (callback) => {
    if (validateAll()) callback({ ...values.value })
  }

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

使用方式

<template>
  <form @submit.prevent="handleSubmit">
    <input v-model="values.email" @blur="validateField('email')" placeholder="Email" />
    <p v-if="errors.email" class="error">{{ errors.email }}</p>

    <input type="password" v-model="values.password" @blur="validateField('password')" placeholder="Password" />
    <p v-if="errors.password" class="error">{{ errors.password }}</p>

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

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

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

const {
  values,
  errors,
  isValid,
  validateField,
  onSubmit,
} = useFormValidator(rules, { email: '', password: '' })

const handleSubmit = () => {
  onSubmit((formData) => {
    // 送出 API
    console.log('送出資料', formData)
  })
}
</script>

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

說明:此 composable 把表單狀態、驗證規則與提交流程抽離,讓不同表單只需要提供自己的 rulesinitialValues 即可。


常見陷阱與最佳實踐

陷阱 說明 解決方案 / 最佳實踐
在 composable 中直接使用 this this 只在 Options API 中指向 Vue 實例,Composition API 裡沒有。 只使用 refreactivecomputed 等返回的值;若需要存取全域屬性,使用 provide/injectapp.config.globalProperties
返回非 reactive 的資料 若返回的物件屬性不是 ref/reactive,元件不會自動更新。 確保所有需要響應的值都包在 refreactivecomputed 中;或在返回前使用 toRefs 轉換。
在 composable 中直接呼叫元件生命週期 (mountedcreated 等) 只能在 setupsetup 內部的函式中使用 onMountedonUnmounted 等。 把生命週期掛鉤寫在 composable 本身(如 useWindowSize),或在元件中自行調用。
忘記清理副作用 (事件監聽、訂閱、定時器) 會導致記憶體泄漏或重複觸發。 在 composable 裡使用 onMounted / onUnmountedtry…finally 來釋放資源。
過度抽象 把太多不相關的邏輯塞進同一個 composable,導致難以維護。 單一職責原則:每個 composable 應該只解決一類問題,例如「資料抓取」或「視窗尺寸」。
在 composable 內部直接存取 localStorage/sessionStorage SSR(Server‑Side Rendering)環境下會報錯,因為 window 不存在。 在使用前先檢查 typeof window !== 'undefined',或把存取封裝在 try…catch 中。

最佳實踐小結

  1. 檔案命名:以 use 為前綴(useXxx),保持一致性。
  2. 返回值:盡量返回 最小化的 API,避免把不必要的內部變數暴露。
  3. 類型支援:若使用 TypeScript,為每個 composable 撰寫明確的型別定義,提升開發體驗。
  4. 文件化:為每個 composable 撰寫 JSDoc,說明參數、回傳值與使用範例。
  5. 測試:使用 Jest / Vitest 撰寫單元測試,只測試函式本身的行為,無需掛載 Vue 元件。

實際應用場景

場景 可使用的 Composable 為什麼適合
多頁面共用的使用者認證狀態 useAuth(封裝 token、login、logout、auto‑refresh) 認證資訊在多個元件間共享,且需要在路由守衛中使用。
即時資料流(WebSocket) useWebSocket(連線、重連、訊息分派) WebSocket 需要在多個畫面中保持單例,composable 能把連線抽離並自動管理生命週期。
表單驗證與提交 useFormValidatoruseSubmit 不同表單只要提供規則即可,驗證邏輯不必重寫。
多語系切換 useI18n(切換語系、載入語言檔) 語系狀態是全局的,且在任意元件都需要即時反應。
動畫與過渡控制 useTransition(返回狀態、觸發函式) 把 CSS/JS 動畫的觸發與狀態抽離,讓 UI 元件更乾淨。
SEO 友好的 SSR 資料預取 useAsyncData(在 setup 中返回 Promise) 在 Nuxt 或 Vite‑SSR 中,composable 能與框架的資料預取機制無縫結合。

案例說明:假設我們開發一個 Dashboard,裡面有多個圖表需要即時更新。可以建立 useChartData,內部透過 fetchWebSocket 取得資料,並返回 ref 給圖表元件。所有圖表只要引用同一個 composable,就能共享同一筆資料,避免重複請求。


總結

  • Composable 是 Vue 3 Composition API 的核心抽象,讓我們能把 statecomputedside‑effect 等邏輯封裝成可重用的函式。
  • 透過 簡潔的命名規則、單一職責、適時的生命週期清理,我們可以寫出 可測試、易維護、可擴充 的程式碼。
  • 本文提供了 五個實用範例(計數器、資料抓取、視窗尺寸、localStorage 同步、表單驗證),示範從 基本概念進階應用 的全流程。
  • 識別常見陷阱、遵守最佳實踐,能讓 composable 在大型專案中發揮最大效益,提升開發速度與團隊協作品質。

掌握了 composable 後,你將能在 Vue 3 中 像組裝 LEGO 一樣,把功能模組化、組合化,打造出更加彈性且可維護的前端應用。祝你寫程式愉快,持續探索 Vue 3 的無限可能! 🚀