Vue3 Composables 全面使用指南 - 现代化逻辑复用方案
1. Composables 基本概念
1.1 什么是 Composables?
Composables 是 Vue3 Composition API 中利用 Vue 响应式系统来封装和复用有状态逻辑的函数。
1.2 Composables vs Hooks
Composables:Vue 生态中的官方术语
Hooks:React 中的概念,在 Vue 中常指代相同模式
本质上都是可复用的逻辑函数
1.3 设计原则
以
use前缀命名返回响应式数据和方法
支持配置参数
良好的 TypeScript 支持
2. 状态管理 Composables
2.1 基础状态管理
// composables/useCounter.ts
import { ref, computed } from 'vue'export function useCounter(initialValue: number = 0) {const count = ref(initialValue)const double = computed(() => count.value * 2)const isEven = computed(() => count.value % 2 === 0)function increment(step: number = 1) {count.value += step}function decrement(step: number = 1) {count.value -= step}function reset() {count.value = initialValue}function set(value: number) {count.value = value}return {// 状态count,// 计算属性double,isEven,// 方法increment,decrement,reset,set}
}使用示例:
<template><div class="counter"><p>当前计数: {{ count }}</p><p>双倍: {{ double }}</p><p>是否偶数: {{ isEven ? '是' : '否' }}</p><div class="controls"><button @click="increment()">+1</button><button @click="increment(5)">+5</button><button @click="decrement()">-1</button><button @click="reset">重置</button><button @click="set(100)">设为100</button></div></div>
</template><script setup lang="ts">
const { count, double, isEven, increment, decrement, reset, set } = useCounter(10)
</script>2.2 本地存储 Composable
// composables/useLocalStorage.ts
import { ref, watch } from 'vue'export function useLocalStorage<T>(key: string, defaultValue: T) {const data = ref<T>(defaultValue)// 读取初始值try {const item = window.localStorage.getItem(key)if (item) {data.value = JSON.parse(item)}} catch (error) {console.warn(`Error reading localStorage key "${key}":`, error)}// 监听变化并保存watch(data,(newValue) => {try {if (newValue === null || newValue === undefined) {window.localStorage.removeItem(key)} else {window.localStorage.setItem(key, JSON.stringify(newValue))}} catch (error) {console.warn(`Error setting localStorage key "${key}":`, error)}},{ deep: true })// 手动更新函数function update(value: T) {data.value = value}// 清除函数function clear() {data.value = defaultValuewindow.localStorage.removeItem(key)}return {data,update,clear}
}使用示例:
<template><div><h2>用户设置</h2><input v-model="username" placeholder="用户名"><select v-model="theme"><option value="light">浅色</option><option value="dark">深色</option></select><button @click="clearSettings">清除设置</button><p>当前设置: {{ username }}, {{ theme }}</p></div>
</template><script setup lang="ts">
const { data: username, update: setUsername } = useLocalStorage('username', '')
const { data: theme, clear: clearTheme } = useLocalStorage('theme', 'light')const clearSettings = () => {setUsername('')clearTheme()
}
</script>3. UI 交互 Composables
3.1 模态框管理
// composables/useModal.ts
import { ref, computed } from 'vue'interface UseModalOptions {initialOpen?: booleancloseOnEsc?: booleancloseOnOverlay?: boolean
}export function useModal(options: UseModalOptions = {}) {const {initialOpen = false,closeOnEsc = true,closeOnOverlay = true} = optionsconst isOpen = ref(initialOpen)const open = () => {isOpen.value = trueif (closeOnEsc) {document.addEventListener('keydown', handleEsc)}}const close = () => {isOpen.value = falseif (closeOnEsc) {document.removeEventListener('keydown', handleEsc)}}const toggle = () => {isOpen.value ? close() : open()}const handleEsc = (event: KeyboardEvent) => {if (event.key === 'Escape') {close()}}const handleOverlayClick = (event: MouseEvent) => {if (closeOnOverlay && (event.target as HTMLElement).classList.contains('modal-overlay')) {close()}}return {isOpen,open,close,toggle,handleOverlayClick}
}使用示例:
<template><div><button @click="open">打开设置</button><div v-if="isOpen" class="modal-overlay" @click="handleOverlayClick"><div class="modal-content"><div class="modal-header"><h3>设置</h3><button @click="close" class="close-btn">×</button></div><div class="modal-body"><!-- 模态框内容 --><p>这是模态框内容</p></div></div></div></div>
</template><script setup lang="ts">
const { isOpen, open, close, handleOverlayClick } = useModal({closeOnEsc: true,closeOnOverlay: true
})
</script><style scoped>
.modal-overlay {position: fixed;top: 0;left: 0;right: 0;bottom: 0;background: rgba(0, 0, 0, 0.5);display: flex;align-items: center;justify-content: center;
}.modal-content {background: white;padding: 20px;border-radius: 8px;min-width: 400px;
}.close-btn {background: none;border: none;font-size: 24px;cursor: pointer;
}
</style>3.2 下拉菜单 Composable
// composables/useDropdown.ts
import { ref, onMounted, onUnmounted } from 'vue'export function useDropdown() {const isOpen = ref(false)const dropdownRef = ref<HTMLElement | null>(null)const open = () => {isOpen.value = true}const close = () => {isOpen.value = false}const toggle = () => {isOpen.value = !isOpen.value}const handleClickOutside = (event: MouseEvent) => {if (dropdownRef.value && !dropdownRef.value.contains(event.target as Node)) {close()}}onMounted(() => {document.addEventListener('mousedown', handleClickOutside)})onUnmounted(() => {document.removeEventListener('mousedown', handleClickOutside)})return {isOpen,dropdownRef,open,close,toggle}
}使用示例:
<template><div class="dropdown" ref="dropdownRef"><button @click="toggle" class="dropdown-toggle">菜单 {{ isOpen ? '▲' : '▼' }}</button><div v-if="isOpen" class="dropdown-menu"><a href="#" @click.prevent="handleClick('item1')" class="dropdown-item">选项1</a><a href="#" @click.prevent="handleClick('item2')" class="dropdown-item">选项2</a><a href="#" @click.prevent="handleClick('item3')" class="dropdown-item">选项3</a></div></div>
</template><script setup lang="ts">
const { isOpen, dropdownRef, toggle, close } = useDropdown()const handleClick = (item: string) => {console.log('选择了:', item)close()
}
</script><style scoped>
.dropdown {position: relative;display: inline-block;
}.dropdown-menu {position: absolute;top: 100%;left: 0;background: white;border: 1px solid #ccc;border-radius: 4px;min-width: 120px;box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}.dropdown-item {display: block;padding: 8px 12px;text-decoration: none;color: #333;
}.dropdown-item:hover {background: #f5f5f5;
}
</style>4. 数据获取 Composables
4.1 通用数据获取
// composables/useFetch.ts
import { ref, watch } from 'vue'interface UseFetchOptions {immediate?: booleanrefetchOnUpdate?: boolean
}export function useFetch<T>(url: string, options: UseFetchOptions = {}) {const { immediate = true, refetchOnUpdate = true } = optionsconst data = ref<T | null>(null)const error = ref<string | null>(null)const isLoading = ref(false)const isFinished = ref(false)const execute = async (customUrl?: string) => {isLoading.value = trueerror.value = nullisFinished.value = falsetry {const targetUrl = customUrl || urlconst response = await fetch(targetUrl)if (!response.ok) {throw new Error(`HTTP error! status: ${response.status}`)}data.value = await response.json()isFinished.value = true} catch (err) {error.value = err instanceof Error ? err.message : '未知错误'} finally {isLoading.value = false}}// 自动执行if (immediate) {execute()}// 监听 URL 变化if (refetchOnUpdate) {watch(() => url, execute)}// 重新获取const refetch = () => execute()return {data,error,isLoading,isFinished,execute,refetch}
}使用示例:
<template><div><div v-if="isLoading">加载中...</div><div v-else-if="error">错误: {{ error }}</div><div v-else-if="data"><h3>用户信息</h3><pre>{{ data }}</pre></div><button @click="refetch" :disabled="isLoading">{{ isLoading ? '加载中...' : '重新加载' }}</button></div>
</template><script setup lang="ts">
interface User {id: numbername: stringemail: string
}const userId = ref(1)
const url = computed(() => `https://jsonplaceholder.typicode.com/users/${userId.value}`)const { data, error, isLoading, refetch } = useFetch<User>(url.value, {immediate: true,refetchOnUpdate: true
})// 切换用户
const nextUser = () => {userId.value++
}
</script>4.2 增强版 API Composable
// composables/useApi.ts
import { ref } from 'vue'interface ApiOptions {baseURL?: stringheaders?: Record<string, string>
}export function useApi(options: ApiOptions = {}) {const { baseURL = '', headers = {} } = optionsconst loading = ref(false)const error = ref<string | null>(null)const request = async <T>(endpoint: string,config: RequestInit = {}): Promise<T> => {loading.value = trueerror.value = nulltry {const url = `${baseURL}${endpoint}`const response = await fetch(url, {headers: {'Content-Type': 'application/json',...headers,...config.headers},...config})if (!response.ok) {throw new Error(`HTTP error! status: ${response.status}`)}return await response.json()} catch (err) {error.value = err instanceof Error ? err.message : '请求失败'throw err} finally {loading.value = false}}const get = <T>(endpoint: string) => request<T>(endpoint, { method: 'GET' })const post = <T>(endpoint: string, data?: any) =>request<T>(endpoint, {method: 'POST',body: data ? JSON.stringify(data) : undefined})const put = <T>(endpoint: string, data?: any) =>request<T>(endpoint, {method: 'PUT',body: data ? JSON.stringify(data) : undefined})const del = <T>(endpoint: string) =>request<T>(endpoint, { method: 'DELETE' })return {loading,error,request,get,post,put,delete: del}
}使用示例:
<template><div><button @click="fetchUsers" :disabled="loading">{{ loading ? '加载中...' : '获取用户' }}</button><div v-if="error" class="error">{{ error }}</div><div v-if="users"><div v-for="user in users" :key="user.id" class="user">{{ user.name }} - {{ user.email }}</div></div></div>
</template><script setup lang="ts">
import { ref } from 'vue'interface User {id: numbername: stringemail: string
}const users = ref<User[]>([])const { get, loading, error } = useApi({baseURL: 'https://jsonplaceholder.typicode.com'
})const fetchUsers = async () => {try {users.value = await get<User[]>('/users')} catch (err) {console.error('获取用户失败:', err)}
}
</script>5. 工具类 Composables
5.1 防抖 Composable
// composables/useDebounce.ts
import { ref, watch } from 'vue'export function useDebounce<T>(value: T, delay: number = 300) {const debouncedValue = ref<T>(value) as { value: T }let timeoutId: number | null = nullwatch(() => value,(newValue) => {if (timeoutId) {clearTimeout(timeoutId)}timeoutId = window.setTimeout(() => {debouncedValue.value = newValue}, delay)},{ immediate: true })// 手动取消const cancel = () => {if (timeoutId) {clearTimeout(timeoutId)timeoutId = null}}return {debouncedValue,cancel}
}使用示例:
<template><div><input v-model="searchTerm" placeholder="搜索..."><p>实时输入: {{ searchTerm }}</p><p>防抖后: {{ debouncedValue }}</p></div>
</template><script setup lang="ts">
import { ref } from 'vue'const searchTerm = ref('')
const { debouncedValue } = useDebounce(searchTerm, 500)// 监听防抖后的值
watch(debouncedValue, (newValue) => {console.log('执行搜索:', newValue)// 这里可以执行搜索逻辑
})
</script>5.2 屏幕断点 Composable
// composables/useBreakpoints.ts
import { ref, onMounted, onUnmounted } from 'vue'const breakpoints = {sm: 640,md: 768,lg: 1024,xl: 1280,'2xl': 1536
}export function useBreakpoints() {const width = ref(window.innerWidth)const updateWidth = () => {width.value = window.innerWidth}onMounted(() => {window.addEventListener('resize', updateWidth)})onUnmounted(() => {window.removeEventListener('resize', updateWidth)})const isMobile = computed(() => width.value < breakpoints.md)const isTablet = computed(() => width.value >= breakpoints.md && width.value < breakpoints.lg)const isDesktop = computed(() => width.value >= breakpoints.lg)const currentBreakpoint = computed(() => {if (width.value < breakpoints.sm) return 'xs'if (width.value < breakpoints.md) return 'sm'if (width.value < breakpoints.lg) return 'md'if (width.value < breakpoints.xl) return 'lg'if (width.value < breakpoints['2xl']) return 'xl'return '2xl'})return {width,isMobile,isTablet,isDesktop,currentBreakpoint}
}使用示例:
<template><div><p>屏幕宽度: {{ width }}px</p><p>当前断点: {{ currentBreakpoint }}</p><div v-if="isMobile" class="mobile-layout">移动端布局</div><div v-else-if="isTablet" class="tablet-layout">平板布局</div><div v-else class="desktop-layout">桌面端布局</div></div>
</template><script setup lang="ts">
const { width, isMobile, isTablet, isDesktop, currentBreakpoint } = useBreakpoints()
</script>6. 组合使用 Composables
6.1 用户认证 Composable(组合多个)
// composables/useAuth.ts
import { computed } from 'vue'export function useAuth() {const { data: token, update: setToken } = useLocalStorage('auth_token', '')const { data: user, execute: fetchUser } = useFetch<User>('/api/user', {immediate: false})const isAuthenticated = computed(() => !!token.value)const login = async (credentials: LoginCredentials) => {const { post } = useApi()try {const response = await post<LoginResponse>('/api/login', credentials)setToken(response.token)await fetchUser()return response} catch (error) {throw error}}const logout = () => {setToken('')user.value = null}// 自动获取用户信息if (isAuthenticated.value) {fetchUser()}return {user,isAuthenticated,login,logout}
}7. 最佳实践
7.1 命名规范
// 好的命名
useCounter
useLocalStorage
useFetch
useBreakpoints// 避免的命名
getCounter() // 不是函数式
counterHook() // 冗余7.2 返回值设计
// 返回响应式状态和方法
return {// 状态data,loading,error,// 方法execute,reset,// 计算属性isEmpty: computed(() => !data.value)
}7.3 错误处理
// 提供清晰的错误信息
try {// 业务逻辑
} catch (error) {error.value = error instanceof Error ? error.message : '操作失败'throw error // 重新抛出以便外部处理
}7.4 TypeScript 支持
// 完整的类型定义
interface UseFetchReturn<T> {data: Ref<T | null>error: Ref<string | null>loading: Ref<boolean>execute: () => Promise<void>
}export function useFetch<T>(url: string): UseFetchReturn<T> {// 实现
}总结
Composables 是 Vue3 中强大的逻辑复用工具,通过合理的组合和设计,可以:
提高代码复用性 - 相同的逻辑在不同组件中复用
改善代码组织 - 按功能而不是选项组织代码
增强类型安全 - 完整的 TypeScript 支持
便于测试 - 独立的逻辑单元更容易测试
建议根据项目需求创建适当的 Composables,并在团队中建立统一的使用规范。
