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 = defaultValue
window.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?: boolean
closeOnEsc?: boolean
closeOnOverlay?: boolean
}
export function useModal(options: UseModalOptions = {}) {
const {
initialOpen = false,
closeOnEsc = true,
closeOnOverlay = true
} = options
const isOpen = ref(initialOpen)
const open = () => {
isOpen.value = true
if (closeOnEsc) {
document.addEventListener('keydown', handleEsc)
}
}
const close = () => {
isOpen.value = false
if (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?: boolean
refetchOnUpdate?: boolean
}
export function useFetch<T>(url: string, options: UseFetchOptions = {}) {
const { immediate = true, refetchOnUpdate = true } = options
const 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 = true
error.value = null
isFinished.value = false
try {
const targetUrl = customUrl || url
const 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: number
name: string
email: 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?: string
headers?: Record<string, string>
}
export function useApi(options: ApiOptions = {}) {
const { baseURL = '', headers = {} } = options
const loading = ref(false)
const error = ref<string | null>(null)
const request = async <T>(
endpoint: string,
config: RequestInit = {}
): Promise<T> => {
loading.value = true
error.value = null
try {
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: number
name: string
email: 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 = null
watch(
() => 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,并在团队中建立统一的使用规范。
2234

被折叠的 条评论
为什么被折叠?



