Vue3组合式函数封装实战:从可复用逻辑到TypeScript泛型进阶

Composables设计模式与单例陷阱

Vue3的组合式API最大的价值在于逻辑复用。但很多开发者在封装Composable时,会把状态和逻辑混在一起,结果复用时出现状态污染。正确的做法是区分有状态和无状态两种Composable,并根据场景选择。

无状态Composable只封装逻辑,不持有响应式数据,每次调用返回新的实例:

import { ref, onMounted, onUnmounted } from 'vue'

export function useEventListener(target, event, handler) {
  onMounted(() => target.addEventListener(event, handler))
  onUnmounted(() => target.removeEventListener(event, handler))
}

有状态Composable持有响应式数据,需要确保每次调用创建独立的响应式引用,而不是在模块顶层定义共享状态:

// 错误写法:模块级共享状态
const count = ref(0) // 所有组件共享同一个ref!
export function useCounter() {
  return { count }
}

// 正确写法:每次调用创建新实例
export function useCounter(initial = 0) {
  const count = ref(initial)
  const increment = () => count.value++
  const decrement = () => count.value--
  return { count, increment, decrement }
}

模块顶层的ref(0)是一个单例,所有引入该模块的组件会共享同一个响应式变量。这在某些全局状态场景下是有意的(类似Pinia的store),但在多数Composable中是Bug来源。

异步数据获取Composable封装

前端开发中最常见的可复用逻辑是异步数据获取。一个健壮的useFetch需要处理加载状态、错误处理、请求取消和竞态条件:

import { ref, shallowRef } from 'vue'

export function useFetch(url) {
  const data = shallowRef(null)
  const error = shallowRef(null)
  const loading = ref(false)
  let controller = null

  const execute = async () => {
    if (controller) controller.abort()
    controller = new AbortController()
    loading.value = true
    error.value = null

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

  execute()
  return { data, error, loading, refetch: execute }
}

这里有几个设计要点:使用shallowRef而非ref存储data,避免深层响应式代理对大型JSON对象的性能开销;AbortController确保快速切换时旧请求被取消,不会出现竞态问题;错误处理中排除AbortError,避免取消请求被当作真实错误上报。

TypeScript泛型增强类型推断

上面的useFetch返回的data类型是any,在实际使用中缺乏类型安全。通过泛型约束可以提供精确的类型推断:

import { ref, shallowRef } from 'vue'

export function useFetch<T>(url: string) {
  const data = shallowRef<T | null>(null)
  const error = shallowRef<Error | null>(null)
  const loading = ref(false)
  let controller: AbortController | null = null

  const execute = async () => {
    if (controller) controller.abort()
    controller = new AbortController()
    loading.value = true
    error.value = null

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

  execute()
  return { data, error, loading, refetch: execute }
}

// 使用时指定返回类型
interface User {
  id: number
  name: string
  email: string
}

const { data, loading } = useFetch<User>('/api/user/1')
// data.value 类型为 User | null

泛型参数T让调用方指定响应数据的类型结构。配合Vue3.3+的泛型组件语法,Composable的泛型参数还可以传递到组件props上,实现端到端的类型安全。

防抖与节流Composable实战

搜索输入、窗口resize等高频事件需要防抖/节流处理。封装为Composable后可以在组件中声明式使用:

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

export function useDebounce<T>(value: Ref<T>, delay: number) {
  const debouncedValue = ref(value.value) as Ref<T>
  let timer: ReturnType<typeof setTimeout>

  watch(value, (newVal) => {
    clearTimeout(timer)
    timer = setTimeout(() => {
      debouncedValue.value = newVal
    }, delay)
  })

  onUnmounted(() => clearTimeout(timer))
  return debouncedValue
}

// 组件中使用
const keyword = ref('')
const debouncedKeyword = useDebounce(keyword, 300)

watch(debouncedKeyword, (val) => {
  // 300ms防抖后触发搜索
  fetchSearchResults(val)
})

onUnmounted中清除定时器是必要的,否则组件销毁后回调仍会执行,可能导致对已销毁组件状态的非法访问。这个Composable同样支持泛型,Ref<T>确保输入输出的类型一致。

Composable测试策略

Composable的单元测试比组件测试简单得多,因为它是纯逻辑函数。使用Vue的withSetup工具函数可以快速测试:

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

test('useCounter increments and decrements', () => {
  const [result] = withSetup(() => useCounter(5))
  expect(result.count.value).toBe(5)
  result.increment()
  expect(result.count.value).toBe(6)
  result.decrement()
  expect(result.count.value).toBe(5)
})

withSetup封装了创建和销毁组件上下文的逻辑,让Composable测试像普通函数测试一样简洁。对于依赖浏览器API的Composable(如useEventListener),可以在测试环境中mock对应API。Composable封装的价值在这里充分体现——逻辑从组件中抽离后,测试不再需要挂载组件、模拟DOM事件,直接验证输入输出即可。

原创文章,作者:小编,如若转载,请注明出处:https://www.yunthe.com/vue3-zu-he-shi-han-shu-feng-zhuang-shi-zhan-cong-ke-fu-yong/

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

相关推荐