Vue 3 自定义 Hooks 设计:可复用逻辑的组织与类型安全实践

Vue 3 自定义 Hooks 设计:可复用逻辑的组织与类型安全实践

Vue 3 的 Composition API 最大的好处是能让逻辑复用变得简单。Mixins、Scoped Slots 这些 Vue 2 时代的方案要么有命名冲突,要么有数据来源不清晰的问题。Hooks 函数(也叫 composables)解决了这些问题。

但 Hooks 写不好也会有问题:数据流不清晰、响应式丢失、类型推断失败、维护困难。我整理一下实际项目里摸索出来的设计原则和最佳实践。

什么是 Hooks(composables)

Hooks 本质上就是一个函数,内部用了 Vue 的响应式 API,返回的数据可以在组件里用:

// composables/useCounter.ts
import { ref } from 'vue'

export function useCounter(initialCount = 0) {
  const count = ref(initialCount)

  function increment() {
    count.value++
  }

  function decrement() {
    count.value--
  }

  return {
    count,
    increment,
    decrement,
  }
}

组件里这样用:

<script setup>
import { useCounter } from '@/composables/useCounter'

const { count, increment, decrement } = useCounter(10)
</script>

常见问题一:响应式丢失

问题代码

// 错误:返回普通对象而非响应式数据
export function useUser() {
  const name = ref('张三')
  const age = ref(25)

  // 问题:解构后响应式丢失
  return {
    name,  // ref 会自动解开,但 age 不会
    age,
  }
}

useUser() 返回后,nameage 在组件里虽然能用,但不再是响应式的了(虽然 ref 会自动解开)。

正确写法

// 方案一:直接返回 ref
export function useUser() {
  const name = ref('张三')
  const age = ref(25)

  return {
    name,  // ref 自动解开,组件里用 name.value
    age,   // ref 自动解开,组件里用 age.value
  }
}

// 方案二:用 toRefs 保持响应式关联
export function useUser() {
  const state = reactive({
    name: '张三',
    age: 25,
  })

  return toRefs(state)  // 解构后依然是响应式的
}

// 方案三:返回整个响应式对象
export function useUser() {
  const state = reactive({
    name: '张三',
    age: 25,
  })

  return {
    state,  // 返回 reactive 对象本身
    // 或者返回只读版本
    readonlyState: readonly(state),
  }
}

常见问题二:生命周期钩子位置不对

问题代码

// 错误:在 composable 里调用了 onMounted,但 composable 可能在 setup 外被调用
export function useOnlineStatus() {
  const isOnline = ref(navigator.onLine)

  onMounted(() => {
    window.addEventListener('online', () => isOnline.value = true)
    window.addEventListener('offline', () => isOnline.value = false)
  })

  return { isOnline }
}

正确写法

// 正确:用 onMounted 返回清理函数
export function useOnlineStatus() {
  const isOnline = ref(navigator.onLine)

  onMounted(() => {
    const handleOnline = () => isOnline.value = true
    const handleOffline = () => isOnline.value = false

    window.addEventListener('online', handleOnline)
    window.addEventListener('offline', handleOffline)

    // 返回清理函数
    onUnmounted(() => {
      window.removeEventListener('online', handleOnline)
      window.removeEventListener('offline', handleOffline)
    })
  })

  return { isOnline }
}

或者更简洁的写法,用 useEventListener(如果你们项目封装了的话):

export function useOnlineStatus() {
  const isOnline = ref(navigator.onLine)

  useEventListener(window, 'online', () => isOnline.value = true)
  useEventListener(window, 'offline', () => isOnline.value = false)

  return { isOnline }
}

常见问题三:类型安全不足

问题代码

// 没有类型定义的 composable
export function usePagination() {
  const page = ref(1)
  const pageSize = ref(20)
  const total = ref(0)

  function nextPage() {
    page.value++
  }

  return { page, pageSize, total, nextPage }
}

调用方不知道 pagepageSize 是什么类型,IDE 补全也不完整。

正确写法

// 定义完整的返回类型
interface UsePaginationReturn {
  page: Ref<number>
  pageSize: Ref<number>
  total: Ref<number>
  pageCount: ComputedRef<number>
  nextPage: () => void
  prevPage: () => void
  setPage: (p: number) => void
}

export function usePagination(
  initialPage = 1,
  initialPageSize = 20
): UsePaginationReturn {
  const page = ref(initialPage)
  const pageSize = ref(initialPageSize)
  const total = ref(0)

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

  function nextPage() {
    if (page.value < pageCount.value) {
      page.value++
    }
  }

  function prevPage() {
    if (page.value > 1) {
      page.value--
    }
  }

  function setPage(p: number) {
    page.value = Math.max(1, Math.min(p, pageCount.value))
  }

  return {
    page,
    pageSize,
    total,
    pageCount,
    nextPage,
    prevPage,
    setPage,
  }
}

这样 TypeScript 能完整推导类型,IDE 补全也正常。

常见问题四:异步 composable 不处理 loading 和 error

问题代码

// 没有 loading 和 error 状态
export function useUser(userId: string) {
  const user = ref(null)

  async function fetchUser() {
    const res = await api.getUser(userId)
    user.value = res
  }

  fetchUser()

  return { user }
}

调用方不知道什么时候在加载、什么时候出错了。

正确写法

interface UseUserOptions {
  immediate?: boolean
  initialData?: User | null
}

interface UseUserReturn {
  user: Ref<User | null>
  loading: Ref<boolean>
  error: Ref<Error | null>
  execute: () => Promise<void>
}

export function useUser(
  userId: MaybeRef<string>,
  options: UseUserOptions = {}
): UseUserReturn {
  const {
    immediate = true,
    initialData = null,
  } = options

  const user = ref<User | null>(initialData)
  const loading = ref(false)
  const error = ref<Error | null>(null)

  async function execute() {
    loading.value = true
    error.value = null

    try {
      const id = isRef(userId) ? userId.value : userId
      const res = await api.getUser(id)
      user.value = res
    } catch (e) {
      error.value = e as Error
    } finally {
      loading.value = false
    }
  }

  if (immediate) {
    execute()
  }

  return {
    user,
    loading,
    error,
    execute,
  }
}

使用方式:

<script setup>
import { useUser } from '@/composables/useUser'

const route = useRoute()
const { user, loading, error, execute } = useUser(
  computed(() => route.params.id as string)
)
</script>

<template>
  <div v-if="loading">加载中...</div>
  <div v-else-if="error">
    加载失败: {{ error.message }}
    <button @click="execute">重试</button>
  </div>
  <div v-else-if="user">
    {{ user.name }}
  </div>
</template>

实际项目中的推荐模式

1. 基础状态型 hooks

// composables/useLocalStorage.ts
export function useLocalStorage<T>(key: string, defaultValue: T) {
  const stored = localStorage.getItem(key)
  const initial = stored ? JSON.parse(stored) : defaultValue

  const data = ref(initial) as Ref<T>

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

  return data
}

// 使用
const theme = useLocalStorage('theme', 'light')

2. 业务数据获取型 hooks

// composables/useOrderList.ts
interface OrderListParams {
  keyword?: string
  status?: string
  page?: number
  pageSize?: number
}

interface UseOrderListReturn {
  list: Ref<Order[]>
  total: Ref<number>
  loading: Ref<boolean>
  error: Ref<Error | null>
  refresh: () => Promise<void>
  setParams: (params: Partial<OrderListParams>) => void
}

export function useOrderList(
  initialParams: OrderListParams = {}
): UseOrderListReturn {
  const params = reactive({ ...initialParams })
  const list = ref<Order[]>([])
  const total = ref(0)
  const loading = ref(false)
  const error = ref<Error | null>(null)

  async function fetch() {
    loading.value = true
    error.value = null

    try {
      const res = await api.getOrders(params)
      list.value = res.list
      total.value = res.total
    } catch (e) {
      error.value = e as Error
    } finally {
      loading.value = false
    }
  }

  function setParams(newParams: Partial<OrderListParams>) {
    Object.assign(params, newParams)
    fetch()
  }

  return {
    list,
    total,
    loading,
    error,
    refresh: fetch,
    setParams,
  }
}

3. 工具型 hooks

// composables/useDebounce.ts
export function useDebounce<T extends (...args: any[]) => any>(
  fn: T,
  delay: number
) {
  let timer: ReturnType<typeof setTimeout> | null = null

  function debounced(...args: Parameters<T>) {
    if (timer) clearTimeout(timer)
    timer = setTimeout(() => {
      fn(...args)
    }, delay)
  }

  function cancel() {
    if (timer) {
      clearTimeout(timer)
      timer = null
    }
  }

  return { debounced, cancel }
}

// 使用
const { debounced } = useDebounce(search, 300)

Hooks 的测试

Hooks 是普通函数,测试起来比组件容易:

// composables/__tests__/useCounter.test.ts
import { describe, it, expect } from 'vitest'
import { useCounter } from '../useCounter'

describe('useCounter', () => {
  it('should increment', () => {
    const { count, increment } = useCounter()
    expect(count.value).toBe(0)
    increment()
    expect(count.value).toBe(1)
  })

  it('should accept initial value', () => {
    const { count } = useCounter(10)
    expect(count.value).toBe(10)
  })
})

总结

设计良好的 Hooks 应该具备:

  1. 类型安全:返回值的类型要完整定义,TypeScript 能完整推导
  2. 响应式完整:返回的数据保持响应式,解构后不会丢失
  3. 生命周期正确:异步清理逻辑放在正确的生命周期钩子里
  4. 状态完整:loading、error 等边界状态要考虑
  5. 单一职责:一个 Hook 只做一件事,不要把所有逻辑塞进一个 Hook
  6. 可测试:Hooks 应该是纯函数或者容易 mock 依赖的

Hooks 是 Composition API 最强大的用法之一。用好了,能把逻辑复用做到极致,同时保持代码的清晰和可维护性。

最后更新 4/20/2026, 12:15:46 AM