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 開頭,如:
useMouseuseFetchuseLocalStorageuseCounter
常見的 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 比較
| 特性 | Composables | Mixins |
|---|---|---|
| 來源清晰 | ✅ 明確的 import | ❌ 不清楚屬性來源 |
| 命名衝突 | ✅ 可以重新命名 | ❌ 容易衝突 |
| 參數化 | ✅ 可以傳參數 | ❌ 困難 |
| TypeScript | ✅ 完整支援 | ❌ 支援有限 |
// ❌ Mixin 的問題
const myMixin = {
data() {
return { count: 0 }; // 不知道 count 從哪來
},
};
// ✅ Composable 清晰明確
import { useCounter } from './useCounter';
const { count } = useCounter(); // 清楚知道 count 來自 useCounter
最佳實踐
- 以
use開頭命名 - 單一職責:每個 composable 專注一個功能
- 返回響應式資料:使用 ref 或 reactive
- 處理清理邏輯:在 onUnmounted 中清理
- 支援響應式參數:使用 toValue() 處理參數
- 提供 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>