Vue3组合式API封装企业级业务Hook方法详解

组合式API下业务逻辑复用的正确姿势

Vue3组合式API解决了Options API下逻辑分散在data、methods、computed中难以复用的问题。但实践中发现,不少团队对Hook的封装停留在”把setup代码搬进函数”的层面,缺少对依赖注入、生命周期管理和类型约束的考量。企业级业务Hook需要做到可测试、可配置、可组合,而非简单的代码搬移。

基础Hook封装:useRequest请求管理

数据请求是前端最常见的业务场景。封装一个通用的useRequest Hook,处理加载状态、错误处理和重复请求取消:

import { ref, shallowRef, onUnmounted } from 'vue'
import type { Ref, ShallowRef } from 'vue'

interface UseRequestOptions<T> {
  immediate?: boolean
  initialData?: T
  onError?: (err: Error) => void
}

interface UseRequestReturn<T> {
  data: ShallowRef<T | undefined>
  loading: Ref<boolean>
  error: Ref<Error | null>
  run: (...args: any[]) => Promise<T>
  cancel: () => void
}

export function useRequest<T>(
  fn: (...args: any[]) => Promise<T>,
  options: UseRequestOptions<T> = {}
): UseRequestReturn<T> {
  const { immediate = true, initialData, onError } = options

  const data = shallowRef<T | undefined>(initialData)
  const loading = ref(false)
  const error = ref<Error | null>(null)

  let abortController: AbortController | null = null

  const run = async (...args: any[]): Promise<T> => {
    // 取消上一次未完成请求
    if (abortController) {
      abortController.abort()
    }
    abortController = new AbortController()

    loading.value = true
    error.value = null

    try {
      const result = await fn(...args)
      data.value = result
      return result
    } catch (e) {
      const err = e instanceof Error ? e : new Error(String(e))
      error.value = err
      onError?.(err)
      throw err
    } finally {
      loading.value = false
      abortController = null
    }
  }

  const cancel = () => {
    abortController?.abort()
    loading.value = false
  }

  onUnmounted(cancel)

  if (immediate) {
    run()
  }

  return { data, loading, error, run, cancel }
}

关键设计:shallowRef避免深度响应大对象的性能开销;重复调用run时自动取消上一次请求防止竞态;onUnmounted中清理避免内存泄漏。

带缓存的分页Hook:usePagination

列表分页是中后台系统出现频率最高的场景。封装带缓存的分页Hook,避免切换页码后回退时重复请求:

import { ref, computed, watch } from 'vue'

interface PaginationState<T> {
  list: T[]
  total: number
  page: number
  pageSize: number
}

export function usePagination<T>(
  fetchFn: (page: number, pageSize: number) => Promise<{ list: T[]; total: number }>,
  pageSize = 20
) {
  const page = ref(1)
  const total = ref(0)
  const list = ref<T[]>([])
  const loading = ref(false)

  // 页级缓存,避免回退翻页重复请求
  const cache = new Map<string, { list: T[]; total: number }>()

  const fetch = async (p: number) => {
    const cacheKey = `${p}_${pageSize}`
    if (cache.has(cacheKey)) {
      const cached = cache.get(cacheKey)!
      list.value = cached.list
      total.value = cached.total
      return
    }

    loading.value = true
    try {
      const res = await fetchFn(p, pageSize)
      list.value = res.list
      total.value = res.total
      cache.set(cacheKey, res)
    } finally {
      loading.value = false
    }
  }

  // 缓存上限100页,防止内存泄漏
  const pruneCache = () => {
    if (cache.size > 100) {
      const firstKey = cache.keys().next().value
      cache.delete(firstKey)
    }
  }

  watch(page, (newPage) => {
    pruneCache()
    fetch(newPage)
  }, { immediate: true })

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

  return { list, total, page, pageSize, totalPages, loading, refresh: fetch }
}

页面切换时直接从缓存读取,用户体感上翻页”零延迟”。缓存上限设为100页,超过后LRU淘汰,平衡性能和内存。

跨组件状态共享:useGlobalState

多个组件共享同一份状态时,Pinia是最常见的方案。但轻量场景下,一个简单的Hook即可实现:

import { ref, readonly } from 'vue'

function createGlobalState<T>(initializer: () => T) {
  let state: T | undefined

  return (): T => {
    if (state === undefined) {
      state = initializer()
    }
    return state
  }
}

// 全局用户状态Hook
const useUserState = createGlobalState(() => {
  const userInfo = ref<{ name: string; role: string } | null>(null)
  const permissions = ref<string[]>([])

  const isAdmin = computed(() => userInfo.value?.role === 'admin')

  return { userInfo, permissions, isAdmin }
})

// 任意组件中使用,状态自动共享
// const { userInfo, permissions, isAdmin } = useUserState()

闭包保证state只初始化一次,所有调用者共享同一个引用。返回readonly包装的值可防止外部意外修改。相比Pinia,这种方式零依赖、类型完备,适合3-5个组件共享的简单场景。

Hook的单元测试策略

企业级Hook必须有单元测试。使用@vue/test-utilswithSetup辅助函数模拟组件环境:

import { withSetup } from './test-utils'
import { useRequest } from './useRequest'

describe('useRequest', () => {
  it('should handle loading state', async () => {
    const mockFn = vi.fn().mockResolvedValue({ id: 1, name: 'test' })

    const [result] = withSetup(() =>
      useRequest(mockFn, { immediate: false })
    )

    expect(result.loading.value).toBe(false)

    const promise = result.run()
    expect(result.loading.value).toBe(true)

    await promise
    expect(result.loading.value).toBe(false)
    expect(result.data.value).toEqual({ id: 1, name: 'test' })
  })

  it('should cancel previous request', async () => {
    let callCount = 0
    const mockFn = vi.fn().mockImplementation(async () => {
      callCount++
      await new Promise(r => setTimeout(r, 100))
      return callCount
    })

    const [result] = withSetup(() =>
      useRequest(mockFn, { immediate: false })
    )

    result.run()  // 第一次请求
    const promise = result.run()  // 取消第一次,发起第二次

    await promise
    expect(callCount).toBe(2)
  })
})

Hook封装的质量取决于三个维度:是否把所有副作用(定时器、事件监听、网络请求)都在onUnmounted中清理;是否通过TypeScript泛型约束输入输出类型;是否对边界情况(空值、错误、竞态)做了处理。做到这三点,Hook才具备企业级可维护性。

原创文章,作者:小编,如若转载,请注明出处:https://www.yunthe.com/vue3-zu-he-shi-api-feng-zhuang-qi-ye-ji-ye-wu-hook-fang-fa/

(0)
小编小编
上一篇 14小时前
下一篇 14小时前

相关推荐