Vue3 Composable组合式函数实战:逻辑复用与响应式模式设计

Vue3的Composition API打破了Options API在逻辑组织上的局限,Composable(组合式函数)作为Composition API的核心复用机制,将状态逻辑封装为可独立使用和组合的函数。本文围绕Composable的设计原则、常见模式与实战案例,讲解如何构建高质量的可复用逻辑单元。

Composable的设计原则

Composable本质是一个利用Composition API的函数,约定命名以use开头,返回响应式状态和方法。良好的Composable应具备以下特征:

– 单一职责:每个Composable只处理一类逻辑,如useFetch管理数据请求,useMouseTrack管理鼠标位置

– 无副作用隔离:不修改外部状态,通过返回值与调用方通信

– 自动清理:在onUnmounted中清理定时器、事件监听等资源

– 可组合:Composable内部可调用其他Composable,形成逻辑组合链

基础Composable:useMousePosition

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

export function useMousePosition() {
  const x = ref(0)
  const y = ref(0)

  function update(event: MouseEvent) {
    x.value = event.pageX
    y.value = event.pageY
  }

  onMounted(() => {
    window.addEventListener('mousemove', update)
  })

  onUnmounted(() => {
    window.removeEventListener('mousemove', update)
  })

  return { x, y }
}

在组件中使用:

<script setup lang="ts">
import { useMousePosition } from './composables/useMousePosition'

const { x, y } = useMousePosition()
</script>

<template>
  <p>Mouse position: {{ x }}, {{ y }}</p>
</template>

多个组件可共享同一逻辑,各自维护独立的响应式状态,互不干扰。

异步数据获取:useFetch封装

import { ref, watchEffect, isRef, unref } from 'vue'

interface UseFetchOptions<T> {
  immediate?: boolean
  initialValue?: T
  refetch?: boolean
}

export function useFetch<T>(
  url: string | Ref<string>,
  options: UseFetchOptions<T> = {}
) {
  const data = ref<T | null>(options.initialValue ?? null)
  const error = ref<Error | null>(null)
  const loading = ref(false)

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

    try {
      const response = await fetch(unref(url))
      if (!response.ok) {
        throw new Error(`HTTP ${response.status}: ${response.statusText}`)
      }
      data.value = await response.json()
    } catch (e) {
      error.value = e as Error
    } finally {
      loading.value = false
    }
  }

  if (isRef(url) && options.refetch) {
    watchEffect(() => {
      execute()
    })
  } else if (options.immediate !== false) {
    execute()
  }

  return { data, error, loading, execute }
}

支持响应式URL参数:当URL为Ref且启用refetch时,URL变化自动重新请求。immediate控制是否立即执行首次请求。

// 基本用法
const { data, loading, error } = useFetch('/api/users')

// 响应式URL,路由变化时自动重新请求
const userId = ref(1)
const { data: user } = useFetch(
  computed(() => `/api/users/${userId.value}`),
  { refetch: true }
)

// 手动触发
const { execute } = useFetch('/api/refresh', { immediate: false })
// 点击按钮时调用 execute()

状态共享:全局Store替代方案

对于需要在多个组件间共享的状态,可利用Vue的响应式系统构建轻量级Store,无需引入Pinia等外部库。

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

const count = ref(0)
const double = computed(() => count.value * 2)

export function useCounter() {
  function increment() {
    count.value++
  }

  function decrement() {
    count.value--
  }

  function reset() {
    count.value = 0
  }

  return { count, double, increment, decrement, reset }
}

关键技巧:将响应式变量定义在函数外部,所有调用useCounter的组件共享同一份count状态。这与Pinia的Store概念一致,但实现更轻量,适合简单场景。

防抖与节流:useDebounce和useThrottle

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

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

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

  onUnmounted(() => {
    clearTimeout(timer)
  })

  return debounced
}

export function useThrottle<T>(value: Ref<T>, delay = 300) {
  const throttled = ref(value.value) as Ref<T>
  let lastTime = 0

  watch(value, (newVal) => {
    const now = Date.now()
    if (now - lastTime >= delay) {
      throttled.value = newVal
      lastTime = now
    }
  })

  return throttled
}

使用场景:

const keyword = ref('')
const debouncedKeyword = useDebounce(keyword, 500)

// 监听防抖后的值触发搜索
watch(debouncedKeyword, (val) => {
  if (val) {
    searchAPI(val)
  }
})

事件总线:useEventBus

import { ref, onUnmounted } from 'vue'

type Handler<T> = (payload: T) => void

export function useEventBus<T = unknown>() {
  const listeners = ref<Map<string, Set<Handler<T>>>>(new Map())

  function on(event: string, handler: Handler<T>) {
    if (!listeners.value.has(event)) {
      listeners.value.set(event, new Set())
    }
    listeners.value.get(event)!.add(handler)
  }

  function off(event: string, handler: Handler<T>) {
    listeners.value.get(event)?.delete(handler)
  }

  function emit(event: string, payload: T) {
    listeners.value.get(event)?.forEach(handler => handler(payload))
  }

  return { on, off, emit }
}

Composable组合模式

Composable的核心优势在于可自由组合。以下示例组合useFetch、useDebounce和useLocalStorage构建一个带缓存的搜索功能:

import { ref, watch, computed } from 'vue'
import { useFetch } from './useFetch'
import { useDebounce } from './useDebounce'

export function useSearch(apiEndpoint: string) {
  const keyword = ref('')
  const debouncedKeyword = useDebounce(keyword, 500)

  const url = computed(() =>
    debouncedKeyword.value
      ? `${apiEndpoint}?q=${encodeURIComponent(debouncedKeyword.value)}`
      : apiEndpoint
  )

  const { data, loading, error } = useFetch(url, {
    refetch: true,
    initialValue: []
  })

  const results = computed(() => data.value || [])

  return {
    keyword,
    results,
    loading,
    error
  }
}

组件中使用一行代码即可获得完整的搜索功能:输入防抖、自动请求、响应式结果。

TypeScript类型安全实践

为Composable定义清晰的返回类型,提升开发体验和代码可维护性:

interface UseMousePositionReturn {
  x: Ref<number>
  y: Ref<number>
}

export function useMousePosition(): UseMousePositionReturn {
  // ...实现
  return { x, y }
}

// 泛型Composable
export function useLocalStorage<T>(
  key: string,
  defaultValue: T
): { value: Ref<T>; remove: () => void } {
  const stored = localStorage.getItem(key)
  const value = ref<T>(
    stored ? JSON.parse(stored) : defaultValue
  ) as Ref<T>

  watch(value, (newVal) => {
    localStorage.setItem(key, JSON.stringify(newVal))
  });

  function remove() {
    localStorage.removeItem(key)
  }

  return { value, remove }
}

测试Composable

使用Vue Test Utils和Vitest可对Composable进行单元测试,验证逻辑正确性:

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

test('useCounter increments and decrements', () => {
  const { count, increment, decrement, reset } = useCounter()

  expect(count.value).toBe(0)
  increment()
  expect(count.value).toBe(1)
  decrement()
  expect(count.value).toBe(0)
  reset()
  expect(count.value).toBe(0)
})

test('useMousePosition tracks mouse', async () => {
  const { x, y } = withSetup(() => useMousePosition())

  // 模拟鼠标移动
  window.dispatchEvent(new MouseEvent('mousemove', { pageX: 100, pageY: 200 }))
  await nextTick()

  expect(x.value).toBe(100)
  expect(y.value).toBe(200)
})

涉及生命周期钩子的Composable需要在组件上下文中测试,使用withSetup辅助函数包裹可解决此问题。

原创文章,作者:小编,如若转载,请注明出处:https://www.yunthe.com/vue3composable-zu-he-shi-han-shu-shi-zhan-luo-ji-fu-yong-yu/

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

相关推荐