本文 AI 產出,尚未審核

Vue3 組合式函式(Composables)── 何謂 Composable?


簡介

在 Vue 3 推出 Composition API 後,開發者可以把「屬性、方法、生命週期」等邏輯抽離成可重用的函式,這類函式被稱為 Composable
Composable 不只是程式碼的「工具箱」,更是一種思考與組織的方式:將功能相近的邏輯聚合在一起,讓組件保持簡潔、易讀、易測。

對於從 Options API 轉換到 Composition API 的開發者,或是剛踏入 Vue 生態系的新手,掌握 Composable 的概念與寫作技巧,等同於掌握了 Vue 應用的模組化基礎。本文將從概念說明、實作範例、常見陷阱與最佳實踐,逐步帶你建立對 Composable 的完整認知,並提供實務上可直接套用的範例。


核心概念

1. Composable 是什麼?

  • Composable 本質上是一個 純函式(pure function),它接受參數,返回一組 reactive(響應式)狀態或方法。
  • 它遵循 「以功能為單位」 的設計原則,類似於 React 的 Hook。
  • 命名慣例上,必須以 use 為前綴(例如 useFetchuseMouse),以便在程式碼中一眼辨識。

重點:Composable 不會直接改變組件的 setup 內部結構,而是把需要的邏輯抽出,讓 setup 只負責「組合」這些函式。

2. 為什麼要使用 Composable?

目的 好處
重用邏輯 多個組件可以共用同一套資料取得、表單驗證或動畫控制的程式碼。
分離關注點 把「狀態管理」與「UI 表現」分開,程式碼更易維護。
提升測試性 純函式易於單元測試,無需掛載整個 Vue 實例。
支援 TypeScript 以函式簽名為基礎,TypeScript 推斷更精確。

3. Composable 的基本結構

import { ref, computed, onMounted, onUnmounted } from 'vue'

export function useExample(initialValue) {
  // 1. 定義 reactive 狀態
  const count = ref(initialValue)

  // 2. 定義計算屬性
  const double = computed(() => count.value * 2)

  // 3. 定義行為(方法)
  function increment(step = 1) {
    count.value += step
  }

  // 4. 使用生命週期(如有需要)
  onMounted(() => {
    console.log('useExample 已掛載')
  })

  // 5. 回傳需要在組件中使用的項目
  return {
    count,
    double,
    increment
  }
}

說明:以上範例展示了 refcomputedonMounted 的基本使用,並將它們封裝在 useExample 函式中,供外部 setup 直接解構取得。


程式碼範例

以下提供 5 個實務常見的 Composable,每個範例均附上註解與使用方式。

1️⃣ useFetch – 簡易資料請求

// useFetch.js
import { ref, onMounted } from 'vue'
import axios from 'axios'

/**
 * @param {string} url - 要請求的 API 位址
 * @param {object} [options] - axios 設定 (optional)
 * @returns {object} { data, error, loading, refetch }
 */
export function useFetch(url, options = {}) {
  const data = ref(null)
  const error = ref(null)
  const loading = ref(false)

  async function fetchData() {
    loading.value = true
    error.value = null
    try {
      const response = await axios.get(url, options)
      data.value = response.data
    } catch (err) {
      error.value = err
    } finally {
      loading.value = false
    }
  }

  // 初始載入
  onMounted(fetchData)

  // 允許外部重新呼叫
  function refetch(newUrl) {
    if (newUrl) url = newUrl
    fetchData()
  }

  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 { useFetch } from '@/composables/useFetch'

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

技巧useFetch 內部使用 ref 包裝回傳值,使得組件在資料變化時自動重新渲染。


2️⃣ useWindowSize – 監聽視窗尺寸

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

export function useWindowSize() {
  const width = ref(window.innerWidth)
  const height = ref(window.innerHeight)

  function onResize() {
    width.value = window.innerWidth
    height.value = window.innerHeight
  }

  onMounted(() => window.addEventListener('resize', onResize))
  onUnmounted(() => window.removeEventListener('resize', onResize))

  return { width, height }
}

使用方式:

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

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

const { width, height } = useWindowSize()
</script>

重點:透過 onMounted / onUnmounted 自動註冊與移除事件,避免記憶體泄漏。


3️⃣ useLocalStorage – 同步到 LocalStorage

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

/**
 * 讓一個 reactive 變數自動同步到 localStorage
 * @param {string} key - localStorage 的鍵名
 * @param {*} defaultValue - 初始值
 */
export function useLocalStorage(key, defaultValue) {
  const stored = localStorage.getItem(key)
  const data = ref(stored ? JSON.parse(stored) : defaultValue)

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

  return data
}

使用方式:

<template>
  <input v-model="name" placeholder="輸入姓名" />
  <p>儲存的姓名:{{ name }}</p>
</template>

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

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

說明watch 內使用 { deep: true },確保物件或陣列的深層變更也會同步。


4️⃣ useDebounce – 防抖函式

// useDebounce.js
import { ref, watch } from 'vue'

/**
 * 防抖一個值,只有在指定延遲時間內沒有變化時才更新
 * @param {*} source - 要防抖的原始值(ref、reactive、或普通值)
 * @param {number} delay - 延遲毫秒數
 * @returns {ref} - 防抖後的值
 */
export function useDebounce(source, delay = 300) {
  const debounced = ref(source.value ?? source)

  let timeout
  watch(
    () => (source.value !== undefined ? source.value : source),
    (newVal) => {
      clearTimeout(timeout)
      timeout = setTimeout(() => {
        debounced.value = newVal
      }, delay)
    }
  )

  return debounced
}

使用方式:

<template>
  <input v-model="keyword" placeholder="搜尋關鍵字" />
  <p>防抖後的關鍵字:{{ debouncedKeyword }}</p>
</template>

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

const keyword = ref('')
const debouncedKeyword = useDebounce(keyword, 500) // 0.5 秒防抖
</script>

應用:在搜尋框、表單驗證等需要減少過度觸發的情境非常實用。


5️⃣ usePermission – 瀏覽器權限檢測

// usePermission.js
import { ref, onMounted } from 'vue'

/**
 * 監聽瀏覽器權限(如 geolocation、notifications)
 * @param {PermissionName} name - 權限名稱
 */
export function usePermission(name) {
  const status = ref('prompt') // default

  async function queryPermission() {
    if (!navigator.permissions) return
    const result = await navigator.permissions.query({ name })
    status.value = result.state
    result.onchange = () => {
      status.value = result.state
    }
  }

  onMounted(queryPermission)

  return { status }
}

使用方式:

<template>
  <p>定位權限狀態:{{ status }}</p>
  <button @click="requestLocation">取得位置</button>
</template>

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

const { status } = usePermission('geolocation')

function requestLocation() {
  navigator.geolocation.getCurrentPosition(
    (pos) => console.log('位置', pos),
    (err) => console.error(err)
  )
}
</script>

提示navigator.permissions 不是所有瀏覽器都支援,使用前可先檢查 if (navigator.permissions)


常見陷阱與最佳實踐

陷阱 說明 最佳實踐
忘記返回 reactive 物件 直接回傳普通變數,組件不會自動更新。 始終使用 refreactivecomputed,並在 return 時暴露它們。
在 Composable 中直接操作 DOM 會破壞 SSR(Server‑Side Rendering)或測試環境。 使用 onMounted / onBeforeUnmount 包裹 DOM 操作,或改用 Vue 的指令。
依賴外部全域變數 使 Composable 難以測試且耦合度高。 將依賴作為參數注入(DI),如 useFetch(url, { client: axios })
過度抽象 把太小的功能拆成單獨的 Composable,導致檔案過多、閱讀成本提升。 遵循「功能完整」的粒度:一個 Composable 應該解決同一類型的需求(如所有表單驗證)。
忘記清理副作用 事件監聽、計時器等若未在 onUnmounted 清除,會造成記憶體泄漏。 在 Composable 中使用 onUnmounted 釋放資源,或使用 watchEffect 的返回值作清理。

其他最佳實踐

  1. 命名規則:所有 Composable 必須以 use 開頭,且檔案名稱同樣使用 useXxx.js,方便 IDE 自動補全與搜尋。
  2. 類型安全:若使用 TypeScript,為每個返回值加上明確的型別,並在函式參數加上 interfacetype
  3. 文件說明:在每個 Composable 頂部加入 JSDoc 註解,說明用途、參數與回傳值,提升團隊協作效率。
  4. 測試:以 單元測試 為主,利用 @vue/test-utilsvitest 測試 refcomputed 的變化與副作用。
  5. 避免循環依賴:如果兩個 Composable 互相引用,請抽出共用邏輯到第三個更底層的 Composable,保持單向依賴樹。

實際應用場景

場景 可能使用的 Composable 為何適合
表單驗證 useForm, useValidator 集中管理欄位狀態、錯誤訊息與提交流程。
即時搜尋 useDebounce, useFetch 防抖減少請求次數,統一處理資料取得與錯誤。
響應式佈局 useWindowSize, useMediaQuery 依螢幕尺寸切換 UI,避免在每個組件重複寫監聽程式。
使用者偏好設定 useLocalStorage, useCookie 把設定自動同步到瀏覽器儲存,保持跨頁面一致性。
權限與安全 usePermission, useAuth 集中管理瀏覽器權限或登入狀態,方便在全局守衛使用。
動畫與過渡 useTransition, useMotion 把動畫參數與生命週期抽離,讓組件只關注 UI 結構。

案例:一個「商品列表」頁面

  • 使用 useFetch 取得商品資料。
  • 使用 useWindowSize 調整每列顯示的商品數量。
  • 使用 useLocalStorage 把使用者的排序偏好存起來。
  • 使用 useDebounce 為搜尋框加入防抖,避免每次鍵入都發送請求。

這樣的組合讓每段功能都能獨立測試、重用,且程式碼結構清晰。


總結

  • Composable 是 Vue 3 中 以函式為單位 的可重用邏輯封裝,遵守 useXxx 命名慣例。
  • 透過 refreactivecomputed、生命週期鉤子等 API,Composable 能夠提供 完整的響應式狀態與行為,而不會與組件的模板耦合。
  • 正確的 抽象粒度、資源清理、依賴注入 以及 類型安全,是寫好 Composable 的關鍵。
  • 在實務開發中,從表單驗證、資料抓取、視窗尺寸偵測、到權限管理,都能透過 Composable 讓程式碼更 模組化、可測、易維護

掌握了 Composable,等於掌握了 Vue 3 中最核心的組件組合方式。未來在大型專案或團隊協作時,你將能夠快速建立 可共享、可擴充 的功能模組,讓開發效率與程式品質同步提升。祝你在 Vue 的世界裡玩得開心、寫得更好! 🚀