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 = 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 中强大的逻辑复用工具,通过合理的组合和设计,可以:

  1. 提高代码复用性 - 相同的逻辑在不同组件中复用

  2. 改善代码组织 - 按功能而不是选项组织代码

  3. 增强类型安全 - 完整的 TypeScript 支持

  4. 便于测试 - 独立的逻辑单元更容易测试

建议根据项目需求创建适当的 Composables,并在团队中建立统一的使用规范。

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

好奇的候选人面向对象

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值