Vue3 Composable设计模式:从基础封装到高阶复用的工程实践

Vue3组合式函数Composable的核心设计原则

Vue3的Composition API让逻辑复用从Mixin混入模式进化为Composable函数模式。一个合格的Composable必须遵循三个设计原则:显式依赖注入(通过参数传入ref和props)、单一职责(一个Composable只做一件事)、可测试性(不依赖组件实例上下文)。违反这三个原则的Composable最终会变成另一种形式的Mixin——隐式依赖、职责混杂、测试困难。

Composable的标准命名约定是use前缀,如useCounter、useFetch、useIntersectionObserver。这个约定不只是风格偏好,而是Vue生态的工具链(VueDevTools、TypeScript类型推导)都依赖这个前缀来识别组合式函数。

基础Composable:状态与逻辑封装

以一个带防抖的搜索输入为例:

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

export function useDebouncedSearch(delay: number = 300) {
  const searchQuery = ref('')
  const debouncedQuery = ref('')
  let timer: ReturnType<typeof setTimeout> | null = null

  watch(searchQuery, (newVal) => {
    if (timer) clearTimeout(timer)
    timer = setTimeout(() => {
      debouncedQuery.value = newVal
    }, delay)
  })

  return { searchQuery, debouncedQuery }
}

使用方式:

const { searchQuery, debouncedQuery } = useDebouncedSearch(500)

// 模板中绑定searchQuery,watch debouncedQuery发请求
watch(debouncedQuery, async (query) => {
  if (query) {
    const results = await searchApi(query)
    // ...
  }
})

这个Composable的好处是搜索防抖逻辑完全内聚,组件只需消费结果,不需要关心setTimeout的清理逻辑。

进阶模式:异步数据获取Composable

数据获取是前端最常见的需求。封装一个通用的useFetch需要处理加载状态、错误处理、数据缓存和请求取消:

// composables/useFetch.ts
import { ref, shallowRef, watchEffect, type Ref } from 'vue'

interface UseFetchOptions<T> {
  immediate?: boolean
  initialData?: T
}

interface UseFetchReturn<T> {
  data: Ref<T | null>
  error: Ref<Error | null>
  loading: Ref<boolean>
  execute: () => Promise<void>
}

export function useFetch<T>(
  url: Ref<string> | string,
  options: UseFetchOptions<T> = {}
): UseFetchReturn<T> {
  const { immediate = true, initialData = null } = options

  const data = shallowRef<T | null>(initialData)
  const error = ref<Error | null>(null)
  const loading = ref(false)
  let controller: AbortController | null = null

  const execute = async () => {
    const resolvedUrl = typeof url === 'string' ? url : url.value
    if (!resolvedUrl) return

    if (controller) controller.abort()
    controller = new AbortController()

    loading.value = true
    error.value = null

    try {
      const response = await fetch(resolvedUrl, { signal: controller.signal })
      if (!response.ok) throw new Error(`HTTP ${response.status}`)
      data.value = await response.json() as T
    } catch (e: any) {
      if (e.name !== 'AbortError') {
        error.value = e
      }
    } finally {
      loading.value = false
      controller = null
    }
  }

  if (immediate) {
    if (typeof url === 'string') {
      execute()
    } else {
      watchEffect(() => execute())
    }
  }

  return { data, error, loading, execute }
}

关键设计点:使用shallowRef避免深层响应式对大JSON对象的性能损耗;AbortController确保快速切换时取消旧请求;watchEffect自动追踪URL变化重新请求。

高阶模式:Composable的组合与条件渲染

Composable真正的威力在于组合。以一个列表页为例,需要分页、搜索、无限滚动三种能力:

// composables/usePagination.ts
export function usePagination(pageSize: number = 20) {
  const currentPage = ref(1)
  const total = ref(0)
  const totalPages = computed(() => Math.ceil(total.value / pageSize))

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

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

// composables/useInfiniteScroll.ts
export function useInfiniteScroll(
  loadMore: () => Promise<void>, threshold = 100
) {
  const isIntersecting = ref(false)
  const observer = ref<IntersectionObserver | null>(null)

  const observe = (el: HTMLElement) => {
    observer.value = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting && !isIntersecting.value) {
          isIntersecting.value = true
          loadMore().finally(() => { isIntersecting.value = false })
        }
      },
      { rootMargin: `${threshold}px` }
    )
    observer.value.observe(el)
  }

  onUnmounted(() => observer.value?.disconnect())

  return { isIntersecting, observe }
}

// 组件中组合使用
const { currentPage, total, nextPage } = usePagination(20)
const { debouncedQuery } = useDebouncedSearch(500)
const { data, loading, execute } = useFetch(computed(
  () => `/api/items?page=${currentPage.value}&q=${debouncedQuery.value}`
))

const { observe } = useInfiniteScroll(async () => {
  nextPage()
  await execute()
})

三个Composable各管各的职责,通过ref自动联动:搜索输入触发debouncedQuery变化,进而触发useFetch的URL计算属性变化,自动重新请求;滚动到底部触发nextPage改变currentPage,URL变化后自动重新请求。整个数据流无手动事件派发。

Composable的TypeScript类型约束

生产级Composable必须有完整的类型定义。核心原则是输入参数尽量用具体类型,返回值尽量用Ref的泛型:

interface UseListOptions<T> {
  fetchFn: (page: number, query: string) =>
    Promise<{ data: T[]; total: number }>
  pageSize?: number
  immediate?: boolean
}

export function useList<T>(options: UseListOptions<T>) {
  const items = shallowRef<T[]>([]) as Ref<T[]>
  // ...
  return { items } as const
}

使用as const返回类型确保TypeScript将返回值推断为readonly元组,而不是宽松的联合类型。这样解构时每个ref的类型都是精确的,不会丢失泛型信息。

避免Composable反模式

1. 不要在Composable内部直接操作DOM。如果需要DOM引用,让调用者通过ref传入。

2. 不要在Composable中使用全局状态(如Pinia store),通过参数注入依赖。

3. 不要把Composable写成类。Vue的响应式系统基于Proxy,class的getter/setter可能无法正确触发依赖收集,用普通函数加ref替代。

4. 异步Composable必须在setup顶层同步调用。如果需要条件调用,把条件判断放在Composable内部,不要用if包裹useXxx()调用。

原创文章,作者:小编,如若转载,请注明出处:https://www.yunthe.com/vue3composable-she-ji-mo-shi-cong-ji-chu-feng-zhuang-dao/

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

相关推荐