Vue3组合式函数封装:从自定义Hook到可复用业务逻辑的最佳实践

组合式API的核心思维:逻辑关注点分离

Vue3的组合式API(Composition API)相比选项式API最大的优势在于逻辑关注点分离。选项式API中,同一个功能的代码被分散在data、methods、computed、watch等选项中,当组件变得复杂时,代码的维护成本急剧上升。组合式API允许将同一关注点的逻辑封装到一个函数中,这就是自定义Hook(Composable)。

一个标准的组合式函数应该遵循以下约定:以 use 开头命名;返回ref或reactive响应式数据;在setup函数或script setup中同步调用(不能在异步回调中使用);接收ref作为参数时,自动解包。

实战封装:useRequest异步请求Hook

异步数据获取是前端开发最高频的场景。下面封装一个生产可用的useRequest Hook,支持自动请求、手动触发、防抖、缓存、错误重试:

// composables/useRequest.ts
import { ref, watch, type Ref, onUnmounted } from 'vue'

interface UseRequestOptions<T> {
  immediate?: boolean          // 是否立即执行,默认true
  debounce?: number            // 防抖延迟ms
  cache?: boolean              // 是否启用缓存
  cacheKey?: string            // 缓存键
  retry?: number               // 重试次数
  retryInterval?: number       // 重试间隔ms
  onSuccess?: (data: T) => void
  onError?: (error: Error) => void
}

export function useRequest<T>(
  fetchFn: () => Promise<T>,
  options: UseRequestOptions<T> = {}
) {
  const {
    immediate = true,
    retry = 0,
    retryInterval = 1000,
  } = options

  const data = ref<T | null>(null) as Ref<T | null>
  const error = ref<Error | null>(null)
  const loading = ref(false)

  let retryCount = 0
  let debounceTimer: ReturnType<typeof setTimeout> | null = null

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

    try {
      data.value = await fetchFn()
      retryCount = 0
      options.onSuccess?.(data.value)
    } catch (e) {
      const err = e instanceof Error ? e : new Error(String(e))
      if (retryCount < retry) {
        retryCount++
        setTimeout(execute, retryInterval)
        return
      }
      error.value = err
      options.onError?.(err)
    } finally {
      loading.value = false
    }
  }

  const debouncedExecute = () => {
    if (options.debounce) {
      if (debounceTimer) clearTimeout(debounceTimer)
      debounceTimer = setTimeout(execute, options.debounce)
    } else {
      execute()
    }
  }

  if (immediate) {
    execute()
  }

  onUnmounted(() => {
    if (debounceTimer) clearTimeout(debounceTimer)
  })

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

在组件中使用:

<script setup lang="ts">
import { useRequest } from '@/composables/useRequest'
import { fetchUserList } from '@/api/user'

const { data: users, loading, error, execute: refresh } = useRequest(
  () => fetchUserList({ page: 1, size: 20 }),
  {
    retry: 2,
    retryInterval: 2000,
    onError: (err) => console.error('获取用户列表失败:', err.message)
  }
)
</script>

响应式参数与计算属性联动

高级场景下,Hook的参数需要响应式变化。例如分页组件中,页码改变时自动重新请求数据。封装一个支持响应式参数的usePagination Hook:

// composables/usePagination.ts
import { ref, watch, computed, type Ref } from 'vue'
import { useRequest } from './useRequest'

interface PaginationParams {
  page: Ref<number>
  pageSize: Ref<number>
}

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

  const { data, loading, execute } = useRequest(
    () => fetchFn(page.value, pageSize.value),
    { immediate: false }
  )

  // 页码或每页条数变化时自动请求
  watch([page, pageSize], () => {
    execute()
  }, { immediate: true })

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

  watch(data, (val) => {
    if (val) total.value = val.total
  })

  return {
    list: computed(() => data.value?.list ?? []),
    total,
    totalPages,
    loading,
    page,
    pageSize,
  }
}

Hook的组合与嵌套模式

组合式函数的核心价值在于可组合性——Hook之间可以相互调用形成更复杂的业务逻辑。以下是一个用户认证Hook组合Token管理和路由跳转的示例:

// composables/useAuth.ts
import { computed } from 'vue'
import { useStorage } from './useStorage'
import { useRouter } from 'vue-router'

export function useAuth() {
  const router = useRouter()
  const { value: token, set: setToken, remove: removeToken } = useStorage('auth_token', '')
  const isLoggedIn = computed(() => !!token.value)

  const logout = () => {
    removeToken()
    router.push('/login')
  }

  return { token, isLoggedIn, setToken, logout }
}

// composables/useStorage.ts
import { ref, watch } from 'vue'

export function useStorage<T>(key: string, defaultValue: T) {
  const value = ref<T>(
    JSON.parse(localStorage.getItem(key) ?? 'null') ?? defaultValue
  )

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

  return {
    value,
    set: (val: T) => { value.value = val },
    remove: () => {
      value.value = defaultValue
      localStorage.removeItem(key)
    }
  }
}

前端工程化中Hook的测试策略

组合式函数的单元测试相对简单,因为它们是纯函数。使用Vue的 @vue/test-utils 配合ViTest进行测试:

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

describe('useRequest', () => {
  it('应在immediate=true时自动请求', async () => {
    const mockFn = vi.fn().mockResolvedValue({ id: 1, name: 'test' })
    const { data, loading } = useRequest(mockFn, { immediate: true })
    
    expect(loading.value).toBe(true)
    await vi.waitFor(() => {
      expect(data.value).toEqual({ id: 1, name: 'test' })
      expect(loading.value).toBe(false)
    })
  })

  it('应在retry次后报告错误', async () => {
    const mockFn = vi.fn().mockRejectedValue(new Error('fail'))
    const { error } = useRequest(mockFn, { retry: 2, retryInterval: 100 })
    
    await vi.waitFor(() => {
      expect(error.value).toBeInstanceOf(Error)
      expect(mockFn).toHaveBeenCalledTimes(3) // 1次初始 + 2次重试
    })
  })
})

测试中需要关注:响应式数据的初始值和变更时机、异步请求的loading状态转换、错误和重试逻辑、组件卸载时的清理操作。保持Hook职责单一、依赖明确(通过参数注入而非直接import全局状态),是提高可测试性的关键。

原创文章,作者:小编,如若转载,请注明出处:https://www.yunthe.com/vue3-zu-he-shi-han-shu-feng-zhuang-cong-zi-ding-yi-hook-dao/

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

相关推荐