Vue Composables

Composables(可組合函式)是 Vue 3 中重用有狀態邏輯的方式。它利用 Composition API 將相關的響應式狀態和邏輯封裝成可重用的函式。

什麼是 Composable?

Composable 是一個函式,它利用 Vue 的 Composition API 來封裝和重用有狀態的邏輯:

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

export function useMouse() {
  const x = ref(0);
  const y = ref(0);

  function update(event) {
    x.value = event.pageX;
    y.value = event.pageY;
  }

  onMounted(() => window.addEventListener('mousemove', update));
  onUnmounted(() => window.removeEventListener('mousemove', update));

  return { x, y };
}

使用:

<script setup>
import { useMouse } from './useMouse';

const { x, y } = useMouse();
</script>

<template>
  <p>滑鼠位置:{{ x }}, {{ y }}</p>
</template>

命名慣例

Composable 函式以 use 開頭,如:

  • useMouse
  • useFetch
  • useLocalStorage
  • useCounter

常見的 Composables

useCounter - 計數器

// useCounter.js
import { ref, computed } from 'vue';

export function useCounter(initialValue = 0, step = 1) {
  const count = ref(initialValue);

  const doubled = computed(() => count.value * 2);

  function increment() {
    count.value += step;
  }

  function decrement() {
    count.value -= step;
  }

  function reset() {
    count.value = initialValue;
  }

  return {
    count,
    doubled,
    increment,
    decrement,
    reset,
  };
}

useFetch - 資料獲取

// useFetch.js
import { ref, watchEffect, toValue } from 'vue';

export function useFetch(url) {
  const data = ref(null);
  const error = ref(null);
  const isLoading = ref(false);

  async function fetchData() {
    isLoading.value = true;
    data.value = null;
    error.value = null;

    try {
      const response = await fetch(toValue(url));
      if (!response.ok) {
        throw new Error(`HTTP error! status: ${response.status}`);
      }
      data.value = await response.json();
    } catch (e) {
      error.value = e.message;
    } finally {
      isLoading.value = false;
    }
  }

  // 如果 url 是響應式的,監聽變化
  watchEffect(() => {
    fetchData();
  });

  return { data, error, isLoading, refetch: fetchData };
}

使用:

<script setup>
import { ref } from 'vue';
import { useFetch } from './useFetch';

const userId = ref(1);
const {
  data: user,
  error,
  isLoading,
} = useFetch(() => `https://api.example.com/users/${userId.value}`);
</script>

<template>
  <div v-if="isLoading">載入中...</div>
  <div v-else-if="error">錯誤:{{ error }}</div>
  <div v-else>{{ user?.name }}</div>
  <button @click="userId++">下一位使用者</button>
</template>

useLocalStorage - 本地儲存

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

export function useLocalStorage(key, defaultValue) {
  // 讀取初始值
  const stored = localStorage.getItem(key);
  const data = ref(stored ? JSON.parse(stored) : defaultValue);

  // 監聽變化並儲存
  watch(
    data,
    (newValue) => {
      if (newValue === null || newValue === undefined) {
        localStorage.removeItem(key);
      } else {
        localStorage.setItem(key, JSON.stringify(newValue));
      }
    },
    { deep: true }
  );

  return data;
}

使用:

<script setup>
import { useLocalStorage } from './useLocalStorage';

const theme = useLocalStorage('theme', 'light');
const settings = useLocalStorage('settings', { notifications: true });
</script>

<template>
  <select v-model="theme">
    <option value="light">淺色</option>
    <option value="dark">深色</option>
  </select>
</template>

useDebounce - 防抖

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

export function useDebounce(value, delay = 300) {
  const debouncedValue = ref(value.value);
  let timeout;

  watch(value, (newValue) => {
    clearTimeout(timeout);
    timeout = setTimeout(() => {
      debouncedValue.value = newValue;
    }, delay);
  });

  return debouncedValue;
}

// 或者直接使用函式
export function useDebounceFn(fn, delay = 300) {
  let timeout;

  return (...args) => {
    clearTimeout(timeout);
    timeout = setTimeout(() => {
      fn(...args);
    }, delay);
  };
}

使用:

<script setup>
import { ref, watch } from 'vue';
import { useDebounce } from './useDebounce';

const searchQuery = ref('');
const debouncedQuery = useDebounce(searchQuery, 500);

watch(debouncedQuery, (query) => {
  // 只有停止輸入 500ms 後才會執行搜尋
  console.log('搜尋:', query);
});
</script>

<template>
  <input v-model="searchQuery" placeholder="搜尋..." />
</template>

useEventListener - 事件監聽

// useEventListener.js
import { onMounted, onUnmounted, toValue } from 'vue';

export function useEventListener(target, event, handler, options) {
  onMounted(() => {
    const el = toValue(target);
    el?.addEventListener(event, handler, options);
  });

  onUnmounted(() => {
    const el = toValue(target);
    el?.removeEventListener(event, handler, options);
  });
}

使用:

<script setup>
import { ref } from 'vue';
import { useEventListener } from './useEventListener';

const buttonRef = ref(null);

useEventListener(window, 'resize', () => {
  console.log('視窗大小改變');
});

useEventListener(buttonRef, 'click', () => {
  console.log('按鈕被點擊');
});
</script>

<template>
  <button ref="buttonRef">點我</button>
</template>

useToggle - 切換

// useToggle.js
import { ref } from 'vue';

export function useToggle(initialValue = false) {
  const state = ref(initialValue);

  function toggle() {
    state.value = !state.value;
  }

  function setTrue() {
    state.value = true;
  }

  function setFalse() {
    state.value = false;
  }

  return { state, toggle, setTrue, setFalse };
}

useWindowSize - 視窗大小

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

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

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

  onMounted(() => {
    window.addEventListener('resize', update);
  });

  onUnmounted(() => {
    window.removeEventListener('resize', update);
  });

  return { width, height };
}

useClipboard - 剪貼簿

// useClipboard.js
import { ref } from 'vue';

export function useClipboard() {
  const copied = ref(false);
  const text = ref('');

  async function copy(value) {
    try {
      await navigator.clipboard.writeText(value);
      text.value = value;
      copied.value = true;

      setTimeout(() => {
        copied.value = false;
      }, 2000);
    } catch (e) {
      console.error('複製失敗:', e);
    }
  }

  return { copy, copied, text };
}

使用:

<script setup>
import { useClipboard } from './useClipboard';

const { copy, copied } = useClipboard();

const code = 'npm install vue';
</script>

<template>
  <code>{{ code }}</code>
  <button @click="copy(code)">
    {{ copied ? '已複製!' : '複製' }}
  </button>
</template>

進階模式

接受 Ref 參數

Composable 可以接受 ref 或普通值作為參數:

import { toValue, watchEffect } from 'vue';

export function useFetch(url) {
  const data = ref(null);

  watchEffect(async () => {
    // toValue 會解包 ref 或呼叫 getter
    const response = await fetch(toValue(url));
    data.value = await response.json();
  });

  return data;
}

// 使用時可以傳入 ref 或 getter
useFetch(ref('https://api.example.com/data'));
useFetch(() => `https://api.example.com/users/${userId.value}`);

組合多個 Composables

// useUserData.js
import { computed } from 'vue';
import { useFetch } from './useFetch';
import { useLocalStorage } from './useLocalStorage';

export function useUserData(userId) {
  // 組合 useFetch
  const { data: user, isLoading, error } = useFetch(() => `/api/users/${userId.value}`);

  // 組合 useLocalStorage
  const favorites = useLocalStorage(`favorites-${userId.value}`, []);

  // 新增邏輯
  const isFavorite = computed(() => favorites.value.includes(userId.value));

  function toggleFavorite() {
    if (isFavorite.value) {
      favorites.value = favorites.value.filter((id) => id !== userId.value);
    } else {
      favorites.value.push(userId.value);
    }
  }

  return {
    user,
    isLoading,
    error,
    isFavorite,
    toggleFavorite,
  };
}

返回單一 Ref vs 物件

// 返回單一值
export function useTimestamp() {
  const timestamp = ref(Date.now());

  setInterval(() => {
    timestamp.value = Date.now();
  }, 1000);

  return timestamp; // 直接返回 ref
}

// 返回物件(多個值)
export function useUser() {
  const user = ref(null);
  const isLoading = ref(false);

  return { user, isLoading }; // 返回物件
}

非同步 Composables

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

export function useAsyncData(asyncFn) {
  const data = ref(null);
  const error = ref(null);
  const isLoading = ref(true);

  async function execute() {
    isLoading.value = true;
    error.value = null;

    try {
      data.value = await asyncFn();
    } catch (e) {
      error.value = e;
    } finally {
      isLoading.value = false;
    }
  }

  onMounted(execute);

  return {
    data,
    error,
    isLoading,
    refresh: execute,
  };
}

與 Mixins 比較

特性ComposablesMixins
來源清晰✅ 明確的 import❌ 不清楚屬性來源
命名衝突✅ 可以重新命名❌ 容易衝突
參數化✅ 可以傳參數❌ 困難
TypeScript✅ 完整支援❌ 支援有限
// ❌ Mixin 的問題
const myMixin = {
  data() {
    return { count: 0 }; // 不知道 count 從哪來
  },
};

// ✅ Composable 清晰明確
import { useCounter } from './useCounter';
const { count } = useCounter(); // 清楚知道 count 來自 useCounter

最佳實踐

  1. use 開頭命名
  2. 單一職責:每個 composable 專注一個功能
  3. 返回響應式資料:使用 ref 或 reactive
  4. 處理清理邏輯:在 onUnmounted 中清理
  5. 支援響應式參數:使用 toValue() 處理參數
  6. 提供 TypeScript 型別

推薦函式庫

VueUse 是一個非常流行的 Composables 函式庫,提供了大量現成的 composables:

npm install @vueuse/core
<script setup>
import { useMouse, useLocalStorage, useDark } from '@vueuse/core';

const { x, y } = useMouse();
const theme = useLocalStorage('theme', 'light');
const isDark = useDark();
</script>