Vue3 组合式函数设计:从逻辑复用到响应式架构的工程实践

cover

一、选项式 API 的复用瓶颈:当 mixins 变成技术债

Vue2 时代的 mixins 是逻辑复用的主流方案,但它有三个难以回避的缺陷:命名冲突(多个 mixin 可能定义同名属性,后者覆盖前者,且无警告)、数据来源不透明(模板中使用的变量无法判断来自哪个 mixin)、类型推导困难(TypeScript 无法推断 mixin 注入的属性类型)。当项目中的 mixin 数量超过 5 个时,维护成本急剧上升。

Vue3 的组合式函数(Composable)从根本上解决了这些问题。它以函数为复用单元,通过闭包隔离状态,通过参数传递依赖,天然支持 TypeScript 类型推导。但 Composable 的设计并非"把选项式 API 的代码搬到 setup() 里"那么简单——响应式数据的生命周期管理、副作用清理、异步错误处理、组合函数之间的依赖关系,都需要系统化的设计思路。

二、Composable 的架构分层与响应式数据流

flowchart TB
    subgraph 视图层
        TEMPLATE[组件模板] --> USE1[useUserList]
        TEMPLATE --> USE2[useUserForm]
        TEMPLATE --> USE3[usePermission]
    end

    subgraph 组合式函数层
        USE1 --> CORE1[useFetch: 数据请求]
        USE1 --> CORE2[usePagination: 分页控制]
        USE2 --> CORE3[useForm: 表单状态]
        USE2 --> CORE4[useValidation: 校验逻辑]
        USE3 --> CORE5[useAuth: 权限判断]
        USE3 --> CORE6[useStorage: 持久化]
    end

    subgraph 基础设施层
        CORE1 --> API[HTTP 客户端]
        CORE4 --> RULES[校验规则引擎]
        CORE5 --> TOKEN[Token 管理]
        CORE6 --> LS[localStorage]
    end

    subgraph 响应式数据流
        API --> |ref/reactive| STATE1[请求状态]
        RULES --> |computed| STATE2[校验结果]
        TOKEN --> |readonly ref| STATE3[权限状态]
    end

    style USE1 fill:#e3f2fd
    style USE2 fill:#e3f2fd
    style USE3 fill:#e3f2fd
    style CORE1 fill:#fff3e0
    style CORE3 fill:#fff3e0
    style CORE5 fill:#fff3e0

Composable 的架构分为三层:业务组合函数(如 useUserList)、基础组合函数(如 useFetch、useForm)和基础设施层(HTTP 客户端、存储等)。业务组合函数编排基础组合函数,基础组合函数封装基础设施。这种分层确保了每一层只关注自己的职责,避免业务逻辑与底层实现耦合。

关键设计原则:输入是 ref,输出也是 ref。Composable 接收 ref 类型的参数,内部通过 watch 响应参数变化,输出 ref 类型的结果供模板或其他 Composable 消费。这种"ref 进、ref 出"的模式确保了数据流的响应式贯穿始终。

三、生产级 Composable 的实现模式

3.1 useFetch:带缓存与去重的数据请求

// composables/useFetch.ts
import { ref, watch, toValue, type MaybeRef } from 'vue'

// 请求缓存:避免重复请求同一 URL
const cache = new Map<string, {
  data: unknown
  timestamp: number
  ttl: number
}>()

interface UseFetchOptions<T> {
  /** 请求方法 */
  method?: 'GET' | 'POST' | 'PUT' | 'DELETE'
  /** 请求体 */
  body?: MaybeRef<unknown>
  /** 缓存时间(毫秒),0 表示不缓存 */
  cacheTTL?: number
  /** 是否立即执行 */
  immediate?: boolean
  /** 请求前的数据转换 */
  transform?: (data: unknown) => T
  /** 错误处理回调 */
  onError?: (error: Error) => void
}

interface UseFetchReturn<T> {
  /** 响应数据 */
  data: Ref<T | null>
  /** 加载状态 */
  isLoading: Ref<boolean>
  /** 错误信息 */
  error: Ref<Error | null>
  /** 手动触发请求 */
  execute: () => Promise<void>
  /** 刷新数据(忽略缓存) */
  refresh: () => Promise<void>
}

export function useFetch<T = unknown>(
  url: MaybeRef<string>,
  options: UseFetchOptions<T> = {}
): UseFetchReturn<T> {
  const {
    method = 'GET',
    body,
    cacheTTL = 0,
    immediate = true,
    transform = (d) => d as T,
    onError,
  } = options

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

  // 请求去重:同一时刻对同一 URL 的请求只发一次
  const pendingRequests = new Map<string, Promise<unknown>>()

  async function execute(useCache = true): Promise<void> {
    const urlValue = toValue(url)
    const bodyValue = toValue(body)

    // 检查缓存
    if (useCache && cacheTTL > 0) {
      const cached = cache.get(urlValue)
      if (cached && Date.now() - cached.timestamp < cached.ttl) {
        data.value = transform(cached.data)
        return
      }
    }

    // 检查是否有进行中的相同请求
    const pendingKey = `${method}:${urlValue}`
    if (pendingRequests.has(pendingKey)) {
      try {
        const result = await pendingRequests.get(pendingKey)
        data.value = transform(result)
      } catch (e) {
        // 去重请求的错误由首次发起者处理
      }
      return
    }

    isLoading.value = true
    error.value = null

    const requestPromise = fetch(urlValue, {
      method,
      headers: { 'Content-Type': 'application/json' },
      body: bodyValue ? JSON.stringify(bodyValue) : undefined,
    })
      .then(async (res) => {
        if (!res.ok) {
          throw new Error(`HTTP ${res.status}: ${res.statusText}`)
        }
        return res.json()
      })

    pendingRequests.set(pendingKey, requestPromise)

    try {
      const result = await requestPromise

      // 写入缓存
      if (cacheTTL > 0) {
        cache.set(urlValue, {
          data: result,
          timestamp: Date.now(),
          ttl: cacheTTL,
        })
      }

      data.value = transform(result)
    } catch (e) {
      const err = e instanceof Error ? e : new Error(String(e))
      error.value = err
      onError?.(err)
    } finally {
      isLoading.value = false
      pendingRequests.delete(pendingKey)
    }
  }

  // 响应式 URL 变化时自动重新请求
  watch(
    () => toValue(url),
    () => execute(),
    { immediate }
  )

  return {
    data,
    isLoading,
    error,
    execute: () => execute(true),
    refresh: () => execute(false),
  }
}

3.2 useForm:表单状态与校验的组合式管理

// composables/useForm.ts
import { ref, reactive, computed, type Ref } from 'vue'

interface FieldRule<T> {
  /** 校验函数,返回 true 表示通过 */
  validate: (value: T, form: Record<string, unknown>) => boolean
  /** 校验失败时的错误信息 */
  message: string
}

interface FieldConfig<T> {
  /** 初始值 */
  initial: T
  /** 校验规则列表 */
  rules?: FieldRule<T>[]
}

interface UseFormOptions<T extends Record<string, unknown>> {
  /** 字段配置 */
  fields: { [K in keyof T]: FieldConfig<T[K]> }
  /** 提交处理函数 */
  onSubmit: (values: T) => Promise<void> | void
}

interface UseFormReturn<T extends Record<string, unknown>> {
  /** 表单值(响应式) */
  values: T
  /** 各字段的错误信息 */
  errors: Record<keyof T, string[]>
  /** 整体是否通过校验 */
  isValid: Ref<boolean>
  /** 是否正在提交 */
  isSubmitting: Ref<boolean>
  /** 校验单个字段 */
  validateField: (key: keyof T) => boolean
  /** 校验所有字段 */
  validateAll: () => boolean
  /** 提交表单 */
  submit: () => Promise<void>
  /** 重置表单到初始值 */
  reset: () => void
}

export function useForm<T extends Record<string, unknown>>(
  options: UseFormOptions<T>
): UseFormReturn<T> {
  const { fields, onSubmit } = options

  // 初始化表单值
  const values = reactive({} as T)
  const initialValues = {} as T
  for (const key in fields) {
    values[key] = fields[key].initial
    initialValues[key] = fields[key].initial
  }

  // 初始化错误信息
  const errors = reactive(
    {} as Record<keyof T, string[]>
  )
  for (const key in fields) {
    errors[key] = []
  }

  const isSubmitting = ref(false)

  const isValid = computed(() => {
    return Object.values(errors).every(
      (errs) => errs.length === 0
    )
  })

  function validateField(key: keyof T): boolean {
    const fieldConfig = fields[key]
    if (!fieldConfig.rules || fieldConfig.rules.length === 0) {
      errors[key] = []
      return true
    }

    const fieldErrors: string[] = []
    for (const rule of fieldConfig.rules) {
      if (!rule.validate(values[key], values)) {
        fieldErrors.push(rule.message)
      }
    }
    errors[key] = fieldErrors
    return fieldErrors.length === 0
  }

  function validateAll(): boolean {
    let allValid = true
    for (const key in fields) {
      if (!validateField(key)) {
        allValid = false
      }
    }
    return allValid
  }

  async function submit(): Promise<void> {
    if (!validateAll()) {
      return
    }

    isSubmitting.value = true
    try {
      await onSubmit({ ...values } as T)
    } catch (e) {
      // 提交失败时的错误由调用方处理
      throw e
    } finally {
      isSubmitting.value = false
    }
  }

  function reset(): void {
    for (const key in fields) {
      values[key] = initialValues[key]
      errors[key] = []
    }
  }

  return {
    values,
    errors,
    isValid,
    isSubmitting,
    validateField,
    validateAll,
    submit,
    reset,
  }
}

四、Composable 设计的边界与权衡

粒度选择:Composable 的粒度直接影响复用性。粒度过细(如 useCounter)复用场景有限,粒度过粗(如 useAdminPanel)失去灵活性。经验法则是:一个 Composable 应只封装一个"可独立测试的逻辑单元"。useFetch 封装了"数据请求"这个单元,useForm 封装了"表单状态管理"这个单元。判断标准是:如果移除某个 Composable 后,剩余逻辑仍然可以独立运行,说明粒度合理。

副作用清理:Composable 中注册的 watchaddEventListener、定时器等副作用,必须在组件卸载时清理。Vue 的 onScopeDisposeonUnmounted 是清理的时机,但并非所有 Composable 都在组件上下文中使用——在非组件上下文(如 Pinia Store)中使用时,需要手动管理生命周期。建议在 Composable 内部通过 getCurrentScope() 检测上下文,自动注册清理逻辑。

响应式泄漏:Composable 返回的 ref 如果被外部直接修改,可能破坏内部状态一致性。对于只读的状态(如 isLoading、error),应使用 readonly() 包装后返回,防止外部误修改。这是"最小暴露原则"在 Composable 设计中的体现。

服务端渲染兼容:Composable 中如果有 DOM 操作或浏览器 API 调用(如 window、document),在 SSR 环境下会报错。解决方案是将浏览器特定的逻辑延迟到 onMounted 中执行,或通过 import.meta.client 条件导入。

五、总结

Vue3 组合式函数的设计核心是"以函数为复用单元,以 ref 为数据载体"。分层架构(业务→基础→基础设施)确保了职责清晰,"ref 进、ref 出"的模式确保了响应式数据流的连贯。生产级 Composable 需要处理缓存与去重(useFetch)、校验与提交(useForm)、副作用清理和响应式泄漏防护等工程细节。建议从基础 Composable(useFetch、useForm、useStorage)起步,逐步构建业务 Composable 库,避免过早抽象,但也不要等到代码重复三次才提取。

更多推荐