本篇定位 :组合式 API 最强大的能力在于逻辑复用 。本篇将深入讲解如何将组件逻辑封装为可复用的组合式函数(Composables),涵盖设计原则、典型模式、常见场景以及与传统 mixins 的对比。读完本篇,你将能够像搭积木一样自由组合和复用逻辑,大幅提升代码质量和开发效率。
一、什么是组合式函数?
1.1 定义与核心思想
组合式函数(Composables) 是一个利用 Vue 组合式 API 来封装和复用有状态逻辑 的函数。它本质上是一个普通函数 ,但它内部使用了 ref、reactive、computed、watch、onMounted 等组合式 API。
核心思想 :将组件中的逻辑(而非 UI) 提取为独立的函数,以便在多个组件之间共享。
typescript
// 一个简单的组合式函数
import { ref, onMounted } from 'vue'
export function useCounter(initialValue = 0) {
const count = ref(initialValue)
const doubleCount = computed(() => count.value * 2)
function increment() {
count.value++
}
function decrement() {
count.value--
}
function reset() {
count.value = initialValue
}
return {
count,
doubleCount,
increment,
decrement,
reset
}
}
vue
<!-- 在组件中使用 -->
<script setup>
import { useCounter } from './composables/useCounter'
// 每个组件实例都有独立的状态
const { count, doubleCount, increment, reset } = useCounter(10)
</script>
<template>
<p>{{ count }} (双倍: {{ doubleCount }})</p>
<button @click="increment">+</button>
<button @click="reset">重置</button>
</template>
1.2 为什么需要组合式函数?
在 Vue 2 时代,逻辑复用主要依靠 mixins,但它们存在诸多问题:
| 问题 | 说明 |
|---|---|
| 命名冲突 | 多个 mixins 可能定义相同名称的属性/方法 |
| 数据来源不清晰 | 难以追踪某个属性来自哪个 mixin |
| 隐式依赖 | mixin 之间可能互相依赖,耦合度高 |
| 类型支持差 | TypeScript 类型推导困难 |
组合式函数解决了这些问题:
- ✅ 显式的数据来源(通过返回值)
- ✅ 没有命名冲突(解构时可以重命名)
- ✅ 清晰的依赖关系(函数参数)
- ✅ 完美的 TypeScript 支持
二、创建组合式函数
2.1 基本结构
一个标准的组合式函数通常遵循以下模式:
typescript
// composables/useExample.ts
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
// 命名规范:以 use 开头
export function useExample(options?: ExampleOptions) {
// 1. 创建响应式状态
const data = ref(null)
const isLoading = ref(false)
const error = ref<Error | null>(null)
// 2. 创建计算属性
const isReady = computed(() => !isLoading.value && data.value !== null)
// 3. 定义方法
async function fetchData() {
isLoading.value = true
error.value = null
try {
// ... 获取数据
data.value = await someApi()
} catch (e) {
error.value = e as Error
} finally {
isLoading.value = false
}
}
// 4. 生命周期钩子
onMounted(() => {
if (options?.immediate !== false) {
fetchData()
}
})
// 5. 清理(如果必要)
onUnmounted(() => {
// 清理定时器、取消订阅等
})
// 6. 返回暴露的 API
return {
data,
isLoading,
error,
isReady,
fetchData
}
}
2.2 命名规范与约定
| 约定 | 说明 | 示例 |
|---|---|---|
| use 前缀 | 函数名以 use 开头 |
useCounter、useFetch |
| 返回 ref | 返回响应式引用,保留响应性 | return { count, increment } |
| 可选参数对象 | 使用对象参数便于扩展 | useFetch({ url, immediate }) |
| 返回值解构 | 用户通过解构获取所需 API | const { data, isLoading } = useFetch() |
| 生命周期清理 | 在 onUnmounted 中清理资源 |
清理定时器、取消请求 |
2.3 响应式状态的共享
组合式函数在每次调用时都会创建新的状态实例 ,因此是相互独立的:
typescript
// composables/useCounter.ts
export function useCounter(initial = 0) {
const count = ref(initial)
function increment() { count.value++ }
return { count, increment }
}
vue
<!-- 组件中使用 -->
<script setup>
const { count: countA, increment: incrementA } = useCounter(0)
const { count: countB, increment: incrementB } = useCounter(100)
// countA 和 countB 是独立的,互不影响
</script>
如果需要全局共享状态 ,可以使用 provide / inject 或 Pinia:
typescript
// 使用 provide 实现单例
const globalState = ref({ count: 0 })
export function useSharedCounter() {
const state = inject('globalCounter', globalState)
function increment() { state.value.count++ }
return { count: state, increment }
}
三、常见组合式函数模式
3.1 useFetch:数据获取
这是最经典、最常用的组合式函数之一:
typescript
// composables/useFetch.ts
import { ref, readonly, onMounted, watch } from 'vue'
interface FetchOptions {
immediate?: boolean
headers?: Record<string, string>
body?: any
method?: 'GET' | 'POST' | 'PUT' | 'DELETE'
onSuccess?: (data: any) => void
onError?: (error: Error) => void
}
export function useFetch<T = any>(url: string, options: FetchOptions = {}) {
const {
immediate = true,
headers = {},
body = null,
method = 'GET',
onSuccess,
onError
} = options
const data = ref<T | null>(null)
const isLoading = ref(false)
const error = ref<Error | null>(null)
const statusCode = ref<number | null>(null)
async function execute(overrideUrl?: string) {
const targetUrl = overrideUrl || url
isLoading.value = true
error.value = null
statusCode.value = null
try {
const response = await fetch(targetUrl, {
method,
headers: {
'Content-Type': 'application/json',
...headers
},
body: body ? JSON.stringify(body) : undefined
})
statusCode.value = response.status
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
const result = await response.json()
data.value = result
onSuccess?.(result)
return result
} catch (e) {
const err = e as Error
error.value = err
onError?.(err)
throw err
} finally {
isLoading.value = false
}
}
// 支持重新获取
function refresh() {
return execute()
}
// 重置状态
function reset() {
data.value = null
isLoading.value = false
error.value = null
statusCode.value = null
}
if (immediate) {
onMounted(() => execute())
}
return {
data: readonly(data), // 只读保护
isLoading: readonly(isLoading),
error: readonly(error),
statusCode: readonly(statusCode),
execute,
refresh,
reset
}
}
使用示例:
vue
<script setup>
import { useFetch } from './composables/useFetch'
// 获取用户列表
const { data: users, isLoading, error, refresh } = useFetch('/api/users')
// 带参数的 POST 请求
const { data: createResult, execute: createUser } = useFetch('/api/users', {
method: 'POST',
immediate: false,
onSuccess: (data) => console.log('创建成功', data)
})
async function handleCreate() {
await createUser()
}
</script>
<template>
<div>
<div v-if="isLoading">加载中...</div>
<div v-else-if="error">错误:{{ error.message }}</div>
<div v-else>
<ul>
<li v-for="user in users" :key="user.id">{{ user.name }}</li>
</ul>
<button @click="refresh">刷新</button>
</div>
</div>
</template>
3.2 useLocalStorage:持久化状态
将状态同步到 localStorage:
typescript
// composables/useLocalStorage.ts
import { ref, watch, onMounted } from 'vue'
export function useLocalStorage<T>(key: string, defaultValue: T) {
// 从 localStorage 读取初始值
const stored = localStorage.getItem(key)
const initialValue = stored ? JSON.parse(stored) : defaultValue
const data = ref<T>(initialValue)
// 数据变化时自动保存到 localStorage
watch(data, (newValue) => {
if (newValue === null || newValue === undefined) {
localStorage.removeItem(key)
} else {
localStorage.setItem(key, JSON.stringify(newValue))
}
}, { deep: true })
// 手动清除
function clear() {
localStorage.removeItem(key)
data.value = defaultValue
}
return {
data,
clear
}
}
使用示例:
vue
<script setup>
import { useLocalStorage } from './composables/useLocalStorage'
// 主题偏好持久化
const { data: theme } = useLocalStorage('theme', 'light')
const { data: userPreferences } = useLocalStorage('preferences', {
language: 'zh-CN',
notifications: true,
fontSize: 16
})
function toggleTheme() {
theme.value = theme.value === 'light' ? 'dark' : 'light'
}
</script>
3.3 useDebounce:防抖与节流
封装防抖和节流逻辑:
typescript
// composables/useDebounce.ts
import { ref, watch, onUnmounted } from 'vue'
export function useDebounce<T>(value: T, delay: number = 300) {
const debouncedValue = ref<T>(value)
let timer: ReturnType<typeof setTimeout> | null = null
watch(value, (newVal) => {
if (timer) clearTimeout(timer)
timer = setTimeout(() => {
debouncedValue.value = newVal
timer = null
}, delay)
})
onUnmounted(() => {
if (timer) clearTimeout(timer)
})
return {
debouncedValue
}
}
export function useThrottle<T extends (...args: any[]) => any>(
fn: T,
delay: number = 300
) {
let timer: ReturnType<typeof setTimeout> | null = null
let lastArgs: Parameters<T> | null = null
let lastThis: any = null
function throttled(this: any, ...args: Parameters<T>) {
if (timer) {
// 保存最新的参数,等待下一次执行
lastArgs = args
lastThis = this
return
}
fn.apply(this, args)
timer = setTimeout(() => {
timer = null
if (lastArgs) {
throttled.apply(lastThis, lastArgs)
lastArgs = null
lastThis = null
}
}, delay)
}
function cancel() {
if (timer) {
clearTimeout(timer)
timer = null
lastArgs = null
lastThis = null
}
}
return {
throttled,
cancel
}
}
使用示例:
vue
<script setup>
import { ref, watch } from 'vue'
import { useDebounce, useThrottle } from './composables/useDebounce'
const searchQuery = ref('')
const { debouncedValue } = useDebounce(searchQuery, 500)
// 使用节流函数
const { throttled: handleScroll } = useThrottle(() => {
console.log('滚动事件', Date.now())
}, 200)
// 搜索词变化时触发 API 请求
watch(debouncedValue, (newVal) => {
if (newVal) {
console.log('搜索:', newVal)
// 调用 API...
}
})
</script>
<template>
<input v-model="searchQuery" placeholder="搜索..." />
<div @scroll="handleScroll" style="height: 300px; overflow: auto;">
<!-- 内容 -->
</div>
</template>
3.4 useEventListener:事件监听
封装 DOM 事件监听,自动清理:
typescript
// composables/useEventListener.ts
import { onMounted, onUnmounted } from 'vue'
export function useEventListener<K extends keyof DocumentEventMap>(
target: EventTarget | (() => EventTarget) | null,
event: K,
handler: (event: DocumentEventMap[K]) => void,
options?: boolean | AddEventListenerOptions
) {
// 如果 target 是函数,获取返回值
const getTarget = () => {
if (typeof target === 'function') {
return target()
}
return target
}
let cleanup: (() => void) | null = null
function setup() {
cleanup?.()
const el = getTarget()
if (!el) return
el.addEventListener(event, handler as EventListener, options)
cleanup = () => {
el.removeEventListener(event, handler as EventListener, options)
}
}
onMounted(setup)
onUnmounted(() => {
cleanup?.()
})
return {
stop: cleanup
}
}
使用示例:
vue
<script setup>
import { useEventListener } from './composables/useEventListener'
import { ref } from 'vue'
const buttonRef = ref<HTMLButtonElement | null>(null)
// 监听窗口 resize
useEventListener(window, 'resize', () => {
console.log('窗口大小变化', window.innerWidth)
})
// 监听鼠标移动
const mousePos = ref({ x: 0, y: 0 })
useEventListener(document, 'mousemove', (e) => {
mousePos.value = { x: e.clientX, y: e.clientY }
})
// 监听一个模板 ref 元素
useEventListener(
() => buttonRef.value,
'click',
() => console.log('按钮被点击!')
)
</script>
<template>
<p>鼠标位置:{{ mousePos.x }}, {{ mousePos.y }}</p>
<button ref="buttonRef">点击我</button>
</template>
3.5 useMediaQuery:响应式断点
封装媒体查询逻辑:
typescript
// composables/useMediaQuery.ts
import { ref, onMounted, onUnmounted } from 'vue'
export function useMediaQuery(query: string) {
const matches = ref(false)
function updateMatches() {
const mediaQuery = window.matchMedia(query)
matches.value = mediaQuery.matches
}
// 监听变化
function setupListener() {
const mediaQuery = window.matchMedia(query)
// 使用 addEventListener(标准方式)
const listener = (e: MediaQueryListEvent) => {
matches.value = e.matches
}
mediaQuery.addEventListener('change', listener)
return () => {
mediaQuery.removeEventListener('change', listener)
}
}
let cleanup: (() => void) | null = null
onMounted(() => {
updateMatches()
cleanup = setupListener()
})
onUnmounted(() => {
cleanup?.()
})
return { matches }
}
// 预定义的断点
export function useBreakpoints() {
const isMobile = useMediaQuery('(max-width: 640px)')
const isTablet = useMediaQuery('(min-width: 641px) and (max-width: 1024px)')
const isDesktop = useMediaQuery('(min-width: 1025px)')
const isDarkMode = useMediaQuery('(prefers-color-scheme: dark)')
return {
isMobile,
isTablet,
isDesktop,
isDarkMode
}
}
使用示例:
vue
<script setup>
import { useBreakpoints } from './composables/useMediaQuery'
const { isMobile, isTablet, isDesktop, isDarkMode } = useBreakpoints()
</script>
<template>
<div>
<p v-if="isMobile">📱 移动端视图</p>
<p v-else-if="isTablet">📟 平板视图</p>
<p v-else-if="isDesktop">🖥️ 桌面视图</p>
<p>暗色模式:{{ isDarkMode ? '🌙 开启' : '☀️ 关闭' }}</p>
</div>
</template>
3.6 useClipboard:剪贴板操作
typescript
// composables/useClipboard.ts
import { ref, readonly } from 'vue'
export function useClipboard() {
const text = ref('')
const isSupported = ref(!!navigator?.clipboard)
const error = ref<Error | null>(null)
async function copy(value: string) {
if (!isSupported.value) {
error.value = new Error('剪贴板 API 不支持')
return false
}
try {
await navigator.clipboard.writeText(value)
text.value = value
error.value = null
return true
} catch (e) {
error.value = e as Error
return false
}
}
async function paste() {
if (!isSupported.value) {
error.value = new Error('剪贴板 API 不支持')
return ''
}
try {
const value = await navigator.clipboard.readText()
text.value = value
error.value = null
return value
} catch (e) {
error.value = e as Error
return ''
}
}
return {
text: readonly(text),
isSupported: readonly(isSupported),
error: readonly(error),
copy,
paste
}
}
四、组合式函数 vs Mixins
4.1 对比分析
| 维度 | 组合式函数 | Mixins |
|---|---|---|
| 代码组织 | 按功能聚合,清晰明了 | 按选项分散,难以追踪 |
| 命名冲突 | 解构时可以重命名,无冲突 | 容易发生属性/方法名冲突 |
| 数据来源 | 显式返回值,来源清晰 | 隐式注入,来源不明确 |
| 类型支持 | 完美的 TypeScript 支持 | 类型推导困难 |
| 依赖关系 | 通过函数参数显式传递 | 隐式依赖,难以管理 |
| 测试 | 独立函数,易于单元测试 | 难以单独测试 |
| 组合多个 | 函数组合,灵活自由 | 多个 mixins 叠加,复杂度高 |
| Vue 3 支持 | ✅ 原生支持 | ⚠️ 仅作为兼容存在 |
4.2 从 Mixins 迁移示例
Mixins 写法(Vue 2):
javascript
// mixins/counterMixin.js
export const counterMixin = {
data() {
return {
count: 0
}
},
computed: {
doubleCount() {
return this.count * 2
}
},
methods: {
increment() {
this.count++
}
}
}
vue
<!-- 组件中使用 -->
<script>
import { counterMixin } from './mixins/counterMixin'
export default {
mixins: [counterMixin]
}
</script>
组合式函数写法(Vue 3):
typescript
// composables/useCounter.ts
export function useCounter(initialValue = 0) {
const count = ref(initialValue)
const doubleCount = computed(() => count.value * 2)
function increment() {
count.value++
}
return { count, doubleCount, increment }
}
vue
<script setup>
import { useCounter } from './composables/useCounter'
const { count, doubleCount, increment } = useCounter()
</script>
4.3 从 Mixins 迁移的优势
迁移前(Mixins):
- 多个 mixins 的属性混在一起,难以判断来源
- 如果有两个 mixin 都定义了
count,会相互覆盖 - 在模板中看到
count,无法快速定位来源
迁移后(Composables):
vue
<script setup>
const { count: counterCount } = useCounter()
const { count: userCount } = useUserCount()
// 来源清晰,没有冲突
</script>
五、组合式函数的高级模式
5.1 组合多个组合式函数
组合式函数可以互相组合,形成更复杂的逻辑:
typescript
// composables/useUserWithFetch.ts
import { useFetch } from './useFetch'
import { useLocalStorage } from './useLocalStorage'
export function useUserWithFetch(userId: number) {
// 组合 useFetch 获取用户数据
const { data: user, isLoading, error, refresh } = useFetch(
`/api/users/${userId}`
)
// 组合 useLocalStorage 缓存用户偏好
const { data: preferences } = useLocalStorage(
`user_preferences_${userId}`,
{ theme: 'light', language: 'zh' }
)
// 新增业务逻辑
function updatePreference(key: string, value: any) {
preferences.value = { ...preferences.value, [key]: value }
}
return {
user,
isLoading,
error,
refresh,
preferences,
updatePreference
}
}
5.2 条件性生命周期
在组合式函数中可以根据条件决定是否执行生命周期钩子:
typescript
// composables/useAutoRefresh.ts
import { ref, onMounted, onUnmounted, watch } from 'vue'
export function useAutoRefresh(
fetchFn: () => Promise<void>,
interval: number = 30000,
options: { immediate?: boolean; enabled?: boolean } = {}
) {
const { immediate = true, enabled = true } = options
const isEnabled = ref(enabled)
let timer: ReturnType<typeof setInterval> | null = null
async function refresh() {
if (!isEnabled.value) return
await fetchFn()
}
function start() {
if (timer) return
if (immediate) refresh()
timer = setInterval(refresh, interval)
}
function stop() {
if (timer) {
clearInterval(timer)
timer = null
}
}
function toggle(enabled: boolean) {
isEnabled.value = enabled
if (enabled) {
start()
} else {
stop()
}
}
watch(isEnabled, (enabled) => {
if (enabled) {
start()
} else {
stop()
}
})
onMounted(start)
onUnmounted(stop)
return {
isEnabled,
refresh,
start,
stop,
toggle
}
}
5.3 异步组合式函数
组合式函数可以是异步的,但需要注意使用方式:
typescript
// composables/useAsyncData.ts
import { ref } from 'vue'
export async function useAsyncData<T>(fetcher: () => Promise<T>) {
const data = ref<T | null>(null)
const isLoading = ref(true)
const error = ref<Error | null>(null)
try {
data.value = await fetcher()
} catch (e) {
error.value = e as Error
} finally {
isLoading.value = false
}
return { data, isLoading, error }
}
vue
<script setup>
// 注意:在 setup 中可以使用顶层 await
const { data: users, isLoading } = await useAsyncData(() =>
fetch('/api/users').then(r => r.json())
)
</script>
⚠️ 注意 :使用顶层 await 需要配合 Suspense 组件使用(将在第 15 篇详细介绍)。
5.4 组合式函数的状态隔离与共享
typescript
// composables/useToast.ts
import { ref, provide, inject } from 'vue'
// 单例状态(全局共享)
const toastState = ref<{ message: string; type: string; visible: boolean }[]>([])
// 在根组件中 provide
export function provideToast() {
function show(message: string, type: 'success' | 'error' | 'info' = 'info') {
toastState.value.push({
message,
type,
visible: true
})
// 3秒后自动移除
setTimeout(() => {
toastState.value = toastState.value.filter(t => t.message !== message)
}, 3000)
}
function hide(index: number) {
toastState.value.splice(index, 1)
}
const toast = {
state: toastState,
show,
hide
}
provide('toast', toast)
return toast
}
// 在任意组件中注入
export function useToast() {
const toast = inject<ReturnType<typeof provideToast>>('toast')
if (!toast) {
throw new Error('useToast 必须在 provideToast 之后使用')
}
return toast
}
六、综合实战:用户管理 + 持久化 + 实时搜索
让我们综合运用多个组合式函数,构建一个完整的用户管理页面:
typescript
// composables/useUserManagement.ts
import { ref, computed, watch, readonly } from 'vue'
import { useFetch } from './useFetch'
import { useLocalStorage } from './useLocalStorage'
import { useDebounce } from './useDebounce'
export interface User {
id: number
name: string
email: string
role: 'admin' | 'user' | 'guest'
status: 'active' | 'inactive'
createdAt: string
}
export function useUserManagement() {
// -------- 数据获取 --------
const {
data: users,
isLoading,
error,
refresh: refreshUsers,
execute: fetchUsers
} = useFetch<User[]>('/api/users')
// -------- 本地缓存(持久化筛选状态) --------
const { data: filterState, clear: clearFilters } = useLocalStorage('user_filters', {
keyword: '',
role: 'all' as 'all' | 'admin' | 'user' | 'guest',
status: 'all' as 'all' | 'active' | 'inactive',
sortBy: 'createdAt' as 'name' | 'email' | 'createdAt',
sortOrder: 'desc' as 'asc' | 'desc'
})
// -------- 防抖搜索 --------
const { debouncedValue: debouncedKeyword } = useDebounce(
() => filterState.value.keyword,
300
)
// -------- 过滤和排序后的用户列表 --------
const filteredUsers = computed(() => {
if (!users.value) return []
let result = [...users.value]
// 关键字过滤(支持跨字段搜索)
const keyword = filterState.value.keyword.trim().toLowerCase()
if (keyword) {
result = result.filter(user =>
user.name.toLowerCase().includes(keyword) ||
user.email.toLowerCase().includes(keyword)
)
}
// 角色过滤
if (filterState.value.role !== 'all') {
result = result.filter(user => user.role === filterState.value.role)
}
// 状态过滤
if (filterState.value.status !== 'all') {
result = result.filter(user => user.status === filterState.value.status)
}
// 排序
const { sortBy, sortOrder } = filterState.value
result.sort((a, b) => {
let aVal = a[sortBy]
let bVal = b[sortBy]
if (aVal < bVal) return sortOrder === 'asc' ? -1 : 1
if (aVal > bVal) return sortOrder === 'asc' ? 1 : -1
return 0
})
return result
})
// -------- 统计信息 --------
const stats = computed(() => {
const total = users.value?.length || 0
const active = users.value?.filter(u => u.status === 'active').length || 0
const admins = users.value?.filter(u => u.role === 'admin').length || 0
return { total, active, inactive: total - active, admins }
})
// -------- 操作方法 --------
function setFilter(key: keyof typeof filterState.value, value: any) {
filterState.value[key] = value
}
function toggleSort(field: 'name' | 'email' | 'createdAt') {
if (filterState.value.sortBy === field) {
filterState.value.sortOrder = filterState.value.sortOrder === 'asc' ? 'desc' : 'asc'
} else {
filterState.value.sortBy = field
filterState.value.sortOrder = 'asc'
}
}
function resetAllFilters() {
clearFilters()
// 重新设置默认值
filterState.value = {
keyword: '',
role: 'all',
status: 'all',
sortBy: 'createdAt',
sortOrder: 'desc'
}
}
// 当搜索词变化时,自动刷新(防抖已经处理)
watch(debouncedKeyword, (keyword) => {
filterState.value.keyword = keyword
})
// 初始加载
fetchUsers()
return {
// 状态
users: readonly(users),
isLoading: readonly(isLoading),
error: readonly(error),
filteredUsers,
stats,
filterState,
// 方法
refresh: refreshUsers,
setFilter,
toggleSort,
resetAllFilters
}
}
在组件中使用:
vue
<script setup lang="ts">
import { useUserManagement } from './composables/useUserManagement'
const {
users,
isLoading,
filteredUsers,
stats,
filterState,
refresh,
setFilter,
toggleSort,
resetAllFilters
} = useUserManagement()
// 获取角色标签
function getRoleLabel(role: string) {
const map = { admin: '管理员', user: '用户', guest: '访客' }
return map[role as keyof typeof map] || role
}
// 获取状态标签
function getStatusLabel(status: string) {
const map = { active: '✅ 活跃', inactive: '❌ 非活跃' }
return map[status as keyof typeof map] || status
}
</script>
<template>
<div class="user-management">
<h1>👥 用户管理</h1>
<!-- 统计卡片 -->
<div class="stats-grid">
<div class="stat-card">
<span class="stat-label">总用户</span>
<span class="stat-value">{{ stats.total }}</span>
</div>
<div class="stat-card active">
<span class="stat-label">活跃</span>
<span class="stat-value">{{ stats.active }}</span>
</div>
<div class="stat-card inactive">
<span class="stat-label">非活跃</span>
<span class="stat-value">{{ stats.inactive }}</span>
</div>
<div class="stat-card admin">
<span class="stat-label">管理员</span>
<span class="stat-value">{{ stats.admins }}</span>
</div>
</div>
<!-- 筛选栏 -->
<div class="filters">
<input
:value="filterState.keyword"
@input="setFilter('keyword', ($event.target as HTMLInputElement).value)"
placeholder="🔍 搜索用户..."
class="search-input"
/>
<select :value="filterState.role" @change="setFilter('role', ($event.target as HTMLSelectElement).value)">
<option value="all">全部角色</option>
<option value="admin">管理员</option>
<option value="user">用户</option>
<option value="guest">访客</option>
</select>
<select :value="filterState.status" @change="setFilter('status', ($event.target as HTMLSelectElement).value)">
<option value="all">全部状态</option>
<option value="active">活跃</option>
<option value="inactive">非活跃</option>
</select>
<button @click="refresh" :disabled="isLoading" class="btn-refresh">
{{ isLoading ? '⏳' : '🔄' }} 刷新
</button>
<button @click="resetAllFilters" class="btn-reset">重置</button>
</div>
<!-- 加载状态 -->
<div v-if="isLoading" class="loading">
<span class="spinner"></span> 加载中...
</div>
<!-- 用户表格 -->
<div v-else-if="users" class="table-container">
<table class="user-table">
<thead>
<tr>
<th @click="toggleSort('name')" style="cursor: pointer;">
ID
<span v-if="filterState.sortBy === 'name'">
{{ filterState.sortOrder === 'asc' ? '▲' : '▼' }}
</span>
</th>
<th @click="toggleSort('name')" style="cursor: pointer;">
姓名
<span v-if="filterState.sortBy === 'name'">
{{ filterState.sortOrder === 'asc' ? '▲' : '▼' }}
</span>
</th>
<th @click="toggleSort('email')" style="cursor: pointer;">
邮箱
<span v-if="filterState.sortBy === 'email'">
{{ filterState.sortOrder === 'asc' ? '▲' : '▼' }}
</span>
</th>
<th>角色</th>
<th>状态</th>
<th @click="toggleSort('createdAt')" style="cursor: pointer;">
创建时间
<span v-if="filterState.sortBy === 'createdAt'">
{{ filterState.sortOrder === 'asc' ? '▲' : '▼' }}
</span>
</th>
</tr>
</thead>
<tbody>
<tr v-for="user in filteredUsers" :key="user.id">
<td>{{ user.id }}</td>
<td><strong>{{ user.name }}</strong></td>
<td>{{ user.email }}</td>
<td>
<span :class="['role-badge', user.role]">
{{ getRoleLabel(user.role) }}
</span>
</td>
<td>
<span :class="['status-badge', user.status]">
{{ getStatusLabel(user.status) }}
</span>
</td>
<td>{{ new Date(user.createdAt).toLocaleDateString() }}</td>
</tr>
</tbody>
</table>
<div v-if="filteredUsers.length === 0" class="empty">
没有匹配的用户
</div>
<div class="table-footer">
<span>显示 {{ filteredUsers.length }} / {{ users.length }} 个用户</span>
</div>
</div>
</div>
</template>
<style scoped>
.user-management {
max-width: 1200px;
margin: 20px auto;
padding: 24px;
background: #f8fafc;
border-radius: 16px;
}
h1 { font-size: 24px; margin-bottom: 20px; }
.stats-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
margin-bottom: 20px;
}
.stat-card {
background: white;
padding: 16px 20px;
border-radius: 10px;
box-shadow: 0 1px 3px rgba(0,0,0,0.06);
display: flex;
justify-content: space-between;
align-items: center;
}
.stat-label { font-size: 14px; color: #718096; }
.stat-value { font-size: 24px; font-weight: 700; color: #2d3748; }
.stat-card.active .stat-value { color: #48bb78; }
.stat-card.inactive .stat-value { color: #fc8181; }
.stat-card.admin .stat-value { color: #6b46c1; }
.filters {
display: flex;
gap: 12px;
margin-bottom: 20px;
flex-wrap: wrap;
}
.search-input {
flex: 1;
min-width: 200px;
padding: 10px 14px;
border: 1px solid #e2e8f0;
border-radius: 8px;
font-size: 14px;
}
.filters select {
padding: 10px 14px;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: white;
}
.btn-refresh, .btn-reset {
padding: 10px 20px;
border: none;
border-radius: 8px;
cursor: pointer;
font-weight: 500;
}
.btn-refresh { background: #4299e1; color: white; }
.btn-refresh:disabled { opacity: 0.6; cursor: not-allowed; }
.btn-reset { background: #e2e8f0; color: #4a5568; }
.loading {
text-align: center;
padding: 40px;
color: #a0aec0;
}
.spinner {
display: inline-block;
width: 24px;
height: 24px;
border: 3px solid #e2e8f0;
border-top-color: #4299e1;
border-radius: 50%;
animation: spin 0.8s linear infinite;
vertical-align: middle;
margin-right: 12px;
}
@keyframes spin { to { transform: rotate(360deg); } }
.table-container {
background: white;
border-radius: 10px;
overflow: hidden;
box-shadow: 0 1px 3px rgba(0,0,0,0.06);
}
.user-table {
width: 100%;
border-collapse: collapse;
}
.user-table th {
background: #f7fafc;
padding: 12px 16px;
text-align: left;
font-weight: 600;
color: #4a5568;
border-bottom: 2px solid #e2e8f0;
}
.user-table td {
padding: 10px 16px;
border-bottom: 1px solid #edf2f7;
}
.user-table tr:hover td { background: #f7fafc; }
.role-badge {
padding: 2px 12px;
border-radius: 12px;
font-size: 13px;
}
.role-badge.admin { background: #faf5ff; color: #6b46c1; }
.role-badge.user { background: #ebf8ff; color: #2b6cb0; }
.role-badge.guest { background: #f7fafc; color: #718096; }
.status-badge {
padding: 2px 12px;
border-radius: 12px;
font-size: 13px;
}
.status-badge.active { background: #f0fff4; color: #276749; }
.status-badge.inactive { background: #fff5f5; color: #9b2c2c; }
.empty { text-align: center; padding: 40px; color: #a0aec0; }
.table-footer {
padding: 12px 16px;
border-top: 1px solid #e2e8f0;
font-size: 14px;
color: #718096;
text-align: right;
}
@media (max-width: 768px) {
.stats-grid { grid-template-columns: repeat(2, 1fr); }
.filters { flex-direction: column; }
.filters select { width: 100%; }
}
</style>
七、组合式函数的设计原则
7.1 SOLID 原则在组合式函数中的体现
| 原则 | 应用 |
|---|---|
| 单一职责 | 每个组合式函数只做一件事,如 useFetch 只负责数据获取 |
| 开闭原则 | 通过参数和 options 对象扩展功能,无需修改源码 |
| 依赖倒置 | 依赖抽象(如接口、类型),而非具体实现 |
| 接口隔离 | 返回精炼的 API,让用户按需使用 |
7.2 组合式函数的可测试性
组合式函数是普通函数,易于编写单元测试:
typescript
// composables/__tests__/useCounter.test.ts
import { useCounter } from '../useCounter'
describe('useCounter', () => {
it('should initialize with default value', () => {
const { count } = useCounter()
expect(count.value).toBe(0)
})
it('should initialize with given value', () => {
const { count } = useCounter(10)
expect(count.value).toBe(10)
})
it('should increment count', () => {
const { count, increment } = useCounter()
increment()
expect(count.value).toBe(1)
})
it('should reset to initial value', () => {
const { count, increment, reset } = useCounter(5)
increment()
increment()
expect(count.value).toBe(7)
reset()
expect(count.value).toBe(5)
})
})
7.3 组合式函数的最佳实践清单
- 函数名以
use开头 - 返回对象(便于解构),不要返回数组(容易混淆)
- 使用
readonly保护内部状态 - 在
onUnmounted中清理资源(定时器、事件监听、订阅) - 使用
Options对象参数(便于扩展) - 提供合理的默认值
- 充分使用 TypeScript 类型
- 保持函数纯粹(不修改外部状态)
- 编写单元测试
- 添加 JSDoc 注释
八、本篇小结
| 知识点 | 核心内容 | 关键要点 |
|---|---|---|
| 组合式函数定义 | 使用组合式 API 封装有状态逻辑的函数 | 命名以 use 开头 |
| 逻辑复用 | 替代 Vue 2 的 mixins | 更清晰、更安全、更灵活 |
| 状态隔离 | 每次调用创建独立状态 | 组件实例间互不影响 |
| 常见模式 | useFetch、useLocalStorage、useDebounce、useEventListener |
覆盖大部分日常场景 |
| 高级模式 | 函数组合、条件生命周期、异步函数 | 提升复用能力 |
| 设计原则 | 单一职责、开闭原则、接口隔离 | 保持函数可维护和可测试 |
九、思考与练习
-
基础练习 :创建一个
useToggle组合式函数,用于管理布尔值状态,提供toggle、setTrue、setFalse、reset方法。 -
实践任务 :创建一个
useForm组合式函数,封装表单状态管理,支持字段注册、验证、提交、重置等功能。 -
陷阱排查:分析以下组合式函数有什么问题:
typescriptexport function useSharedCounter() { const count = ref(0) function increment() { count.value++ } return { count, increment } } -
深度思考:组合式函数和 React Hooks 在设计上有何异同?Vue 的依赖追踪机制如何让组合式函数可以条件性地调用生命周期钩子?
-
扩展任务 :为
useFetch添加缓存功能,相同 URL 的请求在 60 秒内返回缓存结果,避免重复请求。
下篇预告
第 13 篇我们将学习计算属性与侦听器,深入理解:
computed的 getter/setter 与缓存机制watch的深度监听、即时执行、多个来源watchEffect自动收集依赖- 何时使用
watchvswatchEffect - 性能优化与常见陷阱
准备好掌握 Vue 中处理数据变化的核心工具!🔍