Vue3——第 12 篇:组合式 API(下)——Composables 与逻辑3

本篇定位 :组合式 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 覆盖大部分日常场景
高级模式 函数组合、条件生命周期、异步函数 提升复用能力
设计原则 单一职责、开闭原则、接口隔离 保持函数可维护和可测试

九、思考与练习

  1. 基础练习 :创建一个 useToggle 组合式函数,用于管理布尔值状态,提供 toggle、setTrue、setFalse、reset 方法。

  2. 实践任务 :创建一个 useForm 组合式函数,封装表单状态管理,支持字段注册、验证、提交、重置等功能。

  3. 陷阱排查:分析以下组合式函数有什么问题:

    typescript 复制代码
    export function useSharedCounter() {
      const count = ref(0)
      function increment() { count.value++ }
      return { count, increment }
    }
  4. 深度思考:组合式函数和 React Hooks 在设计上有何异同?Vue 的依赖追踪机制如何让组合式函数可以条件性地调用生命周期钩子?

  5. 扩展任务 :为 useFetch 添加缓存功能,相同 URL 的请求在 60 秒内返回缓存结果,避免重复请求。


下篇预告

第 13 篇我们将学习计算属性与侦听器,深入理解:

  • computed 的 getter/setter 与缓存机制
  • watch 的深度监听、即时执行、多个来源
  • watchEffect 自动收集依赖
  • 何时使用 watch vs watchEffect
  • 性能优化与常见陷阱

准备好掌握 Vue 中处理数据变化的核心工具!🔍

相关推荐
LuHang16 小时前
从天地图切换到高德地图:一次真实的底图迁移实战
前端·vue.js·cesium
ruanno_top20 小时前
Vue 3 后台权限路由:我漏掉了按钮级裁剪,还把 403 写成了 404
vue.js
栉甜20 小时前
Vue2学习内容(上)
前端·javascript·vue.js·学习
小郎君6581 天前
(六)el-cascader 的展示值为什么不能直接 join:emitPath、路径还原与多选去重
vue.js·算法
用户093671755411 天前
用 4500 行 TypeScript 复刻了童年方格纸上的海战棋
前端·vue.js
行走的陀螺仪1 天前
Claude Code 从入门到实战(一):安装、登录与第一个Vue项目
前端·javascript·vue.js
jktantan1 天前
Vue keep-alive 切标签后滚动位置回顶?因为 onDeactivated 里读到的 scrollTop 是 0
前端·javascript·vue.js
小郎君6582 天前
(一)为什么表单需要第三种状态:disabled、readonly 与纯文本展示
前端·vue.js
jktantan2 天前
(二)Vue 3 的 attrs 不是响应式:el-form 扩展属性同步的三种方案
前端·javascript·vue.js·element-plus