Vue 3 常用 Hook 封装

一、基础状态 Hook

1. useToggle - 切换布尔状态

ts 复制代码
import { ref } from 'vue'

export function useToggle(initialValue = false) {
  const state = ref(initialValue)
  
  const toggle = () => state.value = !state.value
  const set = (value: boolean) => state.value = value
  const setTrue = () => state.value = true
  const setFalse = () => state.value = false

  return {
    state,
    toggle,
    set,
    setTrue,
    setFalse
  }
}

使用示例:

vue 复制代码
<script setup>
const { state: isOpen, toggle } = useToggle(false)
</script>

<template>
  <button @click="toggle">{{ isOpen ? '关闭' : '打开' }}</button>
</template>

二、DOM 相关 Hook

2. useClickOutside - 点击外部区域

ts 复制代码
import { onMounted, onUnmounted, ref } from 'vue'

export function useClickOutside(targetRef: Ref<HTMLElement | null>, callback: () => void) {
  const listener = (event: MouseEvent) => {
    if (!targetRef.value || targetRef.value.contains(event.target as Node)) {
      return
    }
    callback()
  }

  onMounted(() => document.addEventListener('click', listener))
  onUnmounted(() => document.removeEventListener('click', listener))
}

使用示例:

vue 复制代码
<script setup>
import { ref } from 'vue'
const dropdownRef = ref(null)
const isOpen = ref(false)

useClickOutside(dropdownRef, () => {
  isOpen.value = false
})
</script>

<template>
  <div ref="dropdownRef">
    <button @click="isOpen = !isOpen">菜单</button>
    <div v-if="isOpen">下拉内容</div>
  </div>
</template>

三、网络请求 Hook

3. useFetch - 数据请求

ts 复制代码
import { ref } from 'vue'

interface UseFetchOptions {
  immediate?: boolean
}

export function useFetch<T>(url: string, options: UseFetchOptions = {}) {
  const data = ref<T | null>(null)
  const error = ref<Error | null>(null)
  const loading = ref(false)

  const execute = async (params?: Record<string, any>) => {
    loading.value = true
    error.value = null
    
    try {
      const response = await fetch(`${url}?${new URLSearchParams(params)}`)
      if (!response.ok) throw new Error(response.statusText)
      data.value = await response.json()
    } catch (err) {
      error.value = err as Error
    } finally {
      loading.value = false
    }
  }

  if (options.immediate) {
    execute()
  }

  return {
    data,
    error,
    loading,
    execute,
    refresh: execute
  }
}

使用示例:

vue 复制代码
<script setup>
const { data: users, loading, error } = useFetch('https://api.example.com/users', {
  immediate: true
})
</script>

四、浏览器 API Hook

4. useLocalStorage - 本地存储

ts 复制代码
import { ref, watch } from 'vue'

export function useLocalStorage<T>(key: string, initialValue: T) {
  const storedValue = localStorage.getItem(key)
  const value = ref<T>(storedValue ? JSON.parse(storedValue) : initialValue)

  watch(value, (newValue) => {
    localStorage.setItem(key, JSON.stringify(newValue))
  }, { deep: true })

  return value
}

使用示例:

vue 复制代码
<script setup>
const theme = useLocalStorage('theme', 'light')
</script>

<template>
  <button @click="theme = theme === 'light' ? 'dark' : 'light'">
    切换主题 (当前: {{ theme }})
  </button>
</template>

五、UI 交互 Hook

5. useDebounce - 防抖

ts 复制代码
import { ref, watch, onUnmounted } from 'vue'

export function useDebounce<T>(value: T, delay = 300) {
  const debouncedValue = ref(value)
  let timeoutId: number | null = null

  const clearTimer = () => {
    if (timeoutId) {
      clearTimeout(timeoutId)
    }
  }

  watch(() => value, (newValue) => {
    clearTimer()
    timeoutId = setTimeout(() => {
      debouncedValue.value = newValue
    }, delay)
  })

  onUnmounted(clearTimer)

  return debouncedValue
}

使用示例:

vue 复制代码
<script setup>
const searchQuery = ref('')
const debouncedQuery = useDebounce(searchQuery, 500)

watch(debouncedQuery, (value) => {
  console.log('搜索:', value)
  // 执行搜索API请求
})
</script>

<template>
  <input v-model="searchQuery" placeholder="搜索...">
</template>

六、时间相关 Hook

6. useInterval - 定时器

ts 复制代码
import { onMounted, onUnmounted, ref } from 'vue'

export function useInterval(callback: () => void, interval: number) {
  const intervalId = ref<number | null>(null)

  const start = () => {
    stop()
    intervalId.value = setInterval(callback, interval)
  }

  const stop = () => {
    if (intervalId.value) {
      clearInterval(intervalId.value)
      intervalId.value = null
    }
  }

  onMounted(start)
  onUnmounted(stop)

  return {
    start,
    stop
  }
}

使用示例:

vue 复制代码
<script setup>
const count = ref(0)

const { stop } = useInterval(() => {
  count.value++
}, 1000)
</script>

<template>
  <div>计数: {{ count }}</div>
  <button @click="stop">停止</button>
</template>

七、表单相关 Hook

7. useForm - 表单管理

ts 复制代码
import { reactive, toRefs } from 'vue'

interface FormOptions<T> {
  initialValues: T
  onSubmit: (values: T) => Promise<void> | void
  validate?: (values: T) => Record<keyof T, string>
}

export function useForm<T extends Record<string, any>>(options: FormOptions<T>) {
  const state = reactive({
    values: { ...options.initialValues },
    errors: {} as Record<keyof T, string>,
    isSubmitting: false
  })

  const handleChange = (field: keyof T, value: any) => {
    state.values[field] = value
    if (state.errors[field]) {
      delete state.errors[field]
    }
  }

  const handleSubmit = async () => {
    if (options.validate) {
      state.errors = options.validate(state.values)
      if (Object.keys(state.errors).length > 0) {
        return
      }
    }

    state.isSubmitting = true
    try {
      await options.onSubmit(state.values)
    } finally {
      state.isSubmitting = false
    }
  }

  const resetForm = () => {
    state.values = { ...options.initialValues }
    state.errors = {} as Record<keyof T, string>
  }

  return {
    ...toRefs(state),
    handleChange,
    handleSubmit,
    resetForm
  }
}

使用示例:

vue 复制代码
<script setup>
const { values, errors, isSubmitting, handleChange, handleSubmit } = useForm({
  initialValues: {
    username: '',
    password: ''
  },
  onSubmit: async (values) => {
    console.log('提交表单:', values)
    // 调用API
  },
  validate: (values) => {
    const errors = {}
    if (!values.username) errors.username = '请输入用户名'
    if (!values.password) errors.password = '请输入密码'
    return errors
  }
})
</script>

<template>
  <form @submit.prevent="handleSubmit">
    <input 
      :value="values.username" 
      @input="e => handleChange('username', e.target.value)"
      placeholder="用户名"
    >
    <div v-if="errors.username">{{ errors.username }}</div>
    
    <input
      type="password"
      :value="values.password"
      @input="e => handleChange('password', e.target.value)"
      placeholder="密码"
    >
    <div v-if="errors.password">{{ errors.password }}</div>
    
    <button type="submit" :disabled="isSubmitting">
      {{ isSubmitting ? '提交中...' : '登录' }}
    </button>
  </form>
</template>

八、响应式工具 Hook

8. useMediaQuery - 媒体查询

ts 复制代码
import { ref, onMounted, onUnmounted } from 'vue'

export function useMediaQuery(query: string) {
  const matches = ref(false)
  
  const mediaQuery = window.matchMedia(query)
  
  const updateMatches = () => {
    matches.value = mediaQuery.matches
  }
  
  onMounted(() => {
    updateMatches()
    mediaQuery.addEventListener('change', updateMatches)
  })
  
  onUnmounted(() => {
    mediaQuery.removeEventListener('change', updateMatches)
  })
  
  return matches
}

使用示例:

vue 复制代码
<script setup>
const isMobile = useMediaQuery('(max-width: 768px)')
</script>

<template>
  <div v-if="isMobile">移动端内容</div>
  <div v-else>桌面端内容</div>
</template>

九、动画 Hook

9. useAnimationFrame - 动画帧

ts 复制代码
import { onMounted, onUnmounted, ref } from 'vue'

export function useAnimationFrame(callback: (deltaTime: number) => void) {
  const animationId = ref<number | null>(null)
  let lastTime = 0

  const animate = (time: number) => {
    const deltaTime = time - lastTime
    lastTime = time
    callback(deltaTime)
    animationId.value = requestAnimationFrame(animate)
  }

  const start = () => {
    if (animationId.value === null) {
      animationId.value = requestAnimationFrame(animate)
    }
  }

  const stop = () => {
    if (animationId.value) {
      cancelAnimationFrame(animationId.value)
      animationId.value = null
    }
  }

  onMounted(start)
  onUnmounted(stop)

  return {
    start,
    stop
  }
}

使用示例:

vue 复制代码
<script setup>
const position = ref(0)

const { start } = useAnimationFrame((deltaTime) => {
  position.value += deltaTime * 0.01
  if (position.value > 200) position.value = 0
})
</script>

<template>
  <div 
    class="box" 
    :style="{ transform: `translateX(${position}px)` }"
  ></div>
</template>

十、组合 Hook

10. usePagination - 分页

ts 复制代码
import { ref, computed } from 'vue'

interface PaginationOptions {
  total: number
  pageSize?: number
  currentPage?: number
}

export function usePagination(options: PaginationOptions) {
  const currentPage = ref(options.currentPage || 1)
  const pageSize = ref(options.pageSize || 10)

  const totalPages = computed(() => Math.ceil(options.total / pageSize.value))

  const nextPage = () => {
    if (currentPage.value < totalPages.value) {
      currentPage.value++
    }
  }

  const prevPage = () => {
    if (currentPage.value > 1) {
      currentPage.value--
    }
  }

  const goToPage = (page: number) => {
    if (page >= 1 && page <= totalPages.value) {
      currentPage.value = page
    }
  }

  return {
    currentPage,
    pageSize,
    totalPages,
    nextPage,
    prevPage,
    goToPage
  }
}

使用示例:

vue 复制代码
<script setup>
const { data, total } = await fetchData()
const pagination = usePagination({
  total,
  pageSize: 20
})

watch(() => pagination.currentPage, () => {
  fetchData(pagination.currentPage)
})
</script>

<template>
  <div>
    <button @click="pagination.prevPage" :disabled="pagination.currentPage === 1">上一页</button>
    <span>第 {{ pagination.currentPage }} 页 / 共 {{ pagination.totalPages }} 页</span>
    <button @click="pagination.nextPage" :disabled="pagination.currentPage === pagination.totalPages">下一页</button>
  </div>
</template>

总结

  1. 基础状态管理:useToggle
  2. DOM 交互:useClickOutside
  3. 数据请求:useFetch
  4. 浏览器 API:useLocalStorage
  5. UI 交互:useDebounce
  6. 定时器:useInterval
  7. 表单处理:useForm
  8. 响应式工具:useMediaQuery
  9. 动画:useAnimationFrame
  10. 业务逻辑:usePagination

保持 Hook 的单一职责原则,每个 Hook 只关注一个特定功能,这样才能最大化其复用价值。