Vue3 组合式架构深度实践:从响应式原理到全栈状态管理

cover

一、组合式 API 不是语法糖:响应式系统的工程化挑战

Vue3 的组合式 API(Composition API)常被误解为选项式 API(Options API)的语法糖。这种理解忽略了两者在响应式系统层面的根本差异。选项式 API 的响应式依赖收集发生在组件初始化阶段,由框架自动完成;组合式 API 的响应式依赖是显式声明的,开发者需要理解 Proxy 代理机制和依赖追踪的时机。

这种差异在复杂组件中尤为明显。一个数据看板页面,包含 6 个图表、3 个筛选器、2 个联动表格。用选项式 API 实现,data、computed、watch 散落在不同选项中,逻辑碎片化严重。用组合式 API,可以把每个图表的逻辑封装为独立的 composable,代码组织清晰。但如果不理解响应式的底层机制,很容易写出"看似响应式实则不更新"的代码——比如解构了 reactive 对象,丢失了 Proxy 代理。

全栈应用中,状态管理的复杂度更高。服务端数据、客户端 UI 状态、路由参数、表单状态——这些数据有不同的生命周期和更新策略。Pinia 解决了全局状态的问题,但服务端数据缓存、乐观更新、请求去重这些场景,Pinia 本身并不覆盖。

二、Vue3 响应式引擎的底层机制:Proxy 与依赖追踪

Vue3 的响应式系统基于 ES6 Proxy 实现。理解 Proxy 的拦截机制和依赖追踪的触发时机,是正确使用组合式 API 的前提。

flowchart TB
    A[reactive / ref 创建] --> B[Proxy 代理对象]
    B --> C[组件渲染 / effect 执行]
    C --> D[读取属性 → 触发 get 拦截]
    D --> E[收集当前 effect 为依赖]
    E --> F[属性变更 → 触发 set 拦截]
    F --> G[通知所有依赖 effect 重新执行]
    G --> H[组件重新渲染]

    I[ref 创建] --> J[RefImpl 对象]
    J --> K[.value 访问 → 触发 getter]
    K --> E
    J --> L[.value 赋值 → 触发 setter]
    L --> G

    M[computed 创建] --> N[ComputedRefImpl]
    N --> O[惰性求值:首次读取才计算]
    O --> P[缓存结果:依赖不变则不重算]
    P --> Q[依赖变更 → 标记 dirty]
    Q --> R[下次读取时重新计算]

    style D fill:#ffd93d,color:#333
    style F fill:#ff6b6b,color:#fff
    style O fill:#6bcb77,color:#fff

上图展示了三种响应式原语的内部机制。关键点:

  • reactive:深层 Proxy 代理,嵌套对象也会被递归代理。但解构会丢失代理,因为解构拿到的是原始值而非 Proxy
  • ref:通过 .value 的 getter/setter 实现依赖追踪。模板中自动解包(不需要 .value),但 JS 中必须显式访问
  • computed:惰性求值 + 缓存。只在读取时才计算,且依赖不变时直接返回缓存值。这比 watch 的主动触发更高效

依赖追踪的时序陷阱:在 setTimeoutfetch 回调中读取响应式属性,此时没有活跃的 effect,依赖不会被收集。必须确保在 effect 的同步执行阶段完成依赖读取。

三、生产级全栈状态管理:从 Composable 到数据层

3.1 Composable 设计模式:逻辑封装与复用

// composables/useChart.ts — 图表逻辑封装,关注点分离
import { ref, computed, watch, onUnmounted } from 'vue'
import type { Ref } from 'vue'

interface ChartConfig {
  fetchFn: (params: Record<string, unknown>) => Promise<unknown[]>
  autoRefresh?: boolean
  refreshInterval_ms?: number
}

interface ChartState<T> {
  data: Ref<T[]>
  loading: Ref<boolean>
  error: Ref<string | null>
  lastUpdated: Ref<Date | null>
  refresh: () => Promise<void>
}

/**
 * 通用图表 composable
 * 封装数据获取、加载状态、错误处理、自动刷新
 */
export function useChart<T = Record<string, unknown>>(
  config: ChartConfig,
  params: Ref<Record<string, unknown>>
): ChartState<T> {
  const data = ref<T[]>([]) as Ref<T[]>
  const loading = ref(false)
  const error = ref<string | null>(null)
  const lastUpdated = ref<Date | null>(null)

  let timer: ReturnType<typeof setInterval> | null = null

  const refresh = async () => {
    loading.value = true
    error.value = null
    try {
      const result = await config.fetchFn(params.value)
      data.value = result as T[]
      lastUpdated.value = new Date()
    } catch (err) {
      error.value = err instanceof Error ? err.message : '数据加载失败'
    } finally {
      loading.value = false
    }
  }

  // 参数变更时自动重新加载
  watch(
    params,
    (newParams) => {
      if (Object.keys(newParams).length > 0) {
        refresh()
      }
    },
    { deep: true }
  )

  // 自动刷新
  if (config.autoRefresh && config.refreshInterval_ms) {
    timer = setInterval(refresh, config.refreshInterval_ms)
  }

  // 组件卸载时清理定时器
  onUnmounted(() => {
    if (timer) {
      clearInterval(timer)
    }
  })

  return { data, loading, error, lastUpdated, refresh }
}

3.2 服务端数据管理:请求去重与缓存策略

// composables/useQuery.ts — 服务端数据获取,带缓存与去重
import { ref, shallowRef, computed } from 'vue'
import type { Ref, ShallowRef } from 'vue'

interface QueryOptions<T> {
  /** 请求函数 */
  queryFn: () => Promise<T>
  /** 缓存 key,相同 key 共享缓存 */
  key: string
  /** 缓存时间(毫秒),0 表示不缓存 */
  staleTime?: number
  /** 是否在创建时立即执行 */
  immediate?: boolean
}

interface QueryResult<T> {
  data: ShallowRef<T | undefined>
  error: Ref<Error | null>
  isLoading: Ref<boolean>
  isRefetching: Ref<boolean>
  /** 手动重新获取 */
  refetch: () => Promise<void>
  /** 使缓存失效 */
  invalidate: () => void
}

// 全局缓存与请求去重
const cache = new Map<string, { data: unknown; timestamp: number }>()
const pendingRequests = new Map<string, Promise<unknown>>()

export function useQuery<T>(options: QueryOptions<T>): QueryResult<T> {
  const data = shallowRef<T>() as ShallowRef<T | undefined>
  const error = ref<Error | null>(null)
  const isLoading = ref(false)
  const isRefetching = ref(false)

  const staleTime = options.staleTime ?? 0

  const fetchData = async (isRefetch = false): Promise<void> => {
    // 检查缓存是否有效
    if (!isRefetch && staleTime > 0) {
      const cached = cache.get(options.key)
      if (cached && Date.now() - cached.timestamp < staleTime) {
        data.value = cached.data as T
        return
      }
    }

    // 请求去重:相同 key 的并发请求共享同一个 Promise
    const existing = pendingRequests.get(options.key)
    if (existing) {
      isLoading.value = true
      try {
        const result = await existing as T
        data.value = result
      } catch (err) {
        error.value = err as Error
      } finally {
        isLoading.value = false
      }
      return
    }

    if (isRefetch) {
      isRefetching.value = true
    } else {
      isLoading.value = true
    }

    const promise = options.queryFn()
    pendingRequests.set(options.key, promise)

    try {
      const result = await promise
      data.value = result
      error.value = null

      // 写入缓存
      if (staleTime > 0) {
        cache.set(options.key, { data: result, timestamp: Date.now() })
      }
    } catch (err) {
      error.value = err as Error
    } finally {
      isLoading.value = false
      isRefetching.value = false
      pendingRequests.delete(options.key)
    }
  }

  const refetch = async () => {
    await fetchData(true)
  }

  const invalidate = () => {
    cache.delete(options.key)
  }

  // 立即执行
  if (options.immediate !== false) {
    fetchData()
  }

  return { data, error, isLoading, isRefetching, refetch, invalidate }
}

3.3 全栈表单状态管理

// composables/useForm.ts — 表单状态管理,校验与提交
import { ref, reactive, computed } from 'vue'

interface FieldConfig {
  initialValue: unknown
  rules?: Array<(value: unknown) => string | true>
}

interface FormConfig {
  fields: Record<string, FieldConfig>
  onSubmit: (values: Record<string, unknown>) => Promise<void>
}

export function useForm(config: FormConfig) {
  // 表单值:用 reactive 保持深层响应
  const values = reactive<Record<string, unknown>>({})
  // 错误信息
  const errors = reactive<Record<string, string>>({})
  // 脏检查:追踪哪些字段被修改过
  const dirtyFields = new Set<string>()

  const isSubmitting = ref(false)
  const submitCount = ref(0)

  // 初始化表单值
  for (const [key, field] of Object.entries(config.fields)) {
    values[key] = field.initialValue
  }

  // 校验单个字段
  const validateField = (fieldName: string): boolean => {
    const field = config.fields[fieldName]
    if (!field?.rules) {
      delete errors[fieldName]
      return true
    }

    for (const rule of field.rules) {
      const result = rule(values[fieldName])
      if (result !== true) {
        errors[fieldName] = result
        return false
      }
    }
    delete errors[fieldName]
    return true
  }

  // 校验全部字段
  const validate = (): boolean => {
    let allValid = true
    for (const fieldName of Object.keys(config.fields)) {
      if (!validateField(fieldName)) {
        allValid = false
      }
    }
    return allValid
  }

  // 表单是否被修改
  const isDirty = computed(() => dirtyFields.size > 0)

  // 表单是否有错误
  const hasErrors = computed(() => Object.keys(errors).length > 0)

  // 提交表单
  const handleSubmit = async () => {
    if (!validate()) return

    isSubmitting.value = true
    submitCount.value++
    try {
      await config.onSubmit({ ...values })
    } finally {
      isSubmitting.value = false
    }
  }

  // 重置表单
  const resetForm = () => {
    for (const [key, field] of Object.entries(config.fields)) {
      values[key] = field.initialValue
      delete errors[key]
    }
    dirtyFields.clear()
  }

  // 标记字段为脏
  const markDirty = (fieldName: string) => {
    dirtyFields.add(fieldName)
  }

  return {
    values,
    errors,
    isSubmitting,
    submitCount,
    isDirty,
    hasErrors,
    validateField,
    validate,
    handleSubmit,
    resetForm,
    markDirty,
  }
}

四、组合式架构的工程代价:响应式陷阱与心智模型负担

响应式丢失的隐蔽性。 从 reactive 对象中解构属性、将 reactive 赋值给普通变量、在非 effect 上下文中读取 ref——这些操作都不会报错,但响应式会静默失效。toRefs 可以解决解构问题,但增加了认知负担。团队中只要有人不理解 Proxy 机制,就会写出难以排查的 Bug。

shallowRef 与深层响应的取舍。 大型对象(如服务端返回的嵌套数据)用 reactive 代理,深层递归 Proxy 的性能开销不可忽视。shallowRef 只代理 .value 层,内部对象不做代理,性能更好但丧失了深层响应。修改内部属性需要手动触发 triggerRef(),这又回到了手动管理更新的老路。

Composable 的命名与职责边界。 什么逻辑应该抽成 composable?一个按钮的点击处理需要抽吗?判断标准是复用性:如果同一段逻辑在两个以上组件中使用,就值得抽取。但过度抽取会导致 composable 数量膨胀,每个 composable 只在一个组件中使用,反而增加了间接层。

服务端数据缓存的失效策略。 useQuery 的缓存机制在单页面应用中有效,但跨路由导航时缓存何时失效?全局缓存的 key 设计需要包含路由参数和筛选条件,否则不同页面可能读到脏数据。staleTime 的设置也需要根据数据更新频率调整——用户信息可以缓存 5 分钟,实时数据不应该缓存。

适用边界:中小型应用(< 50 个组件)用 Pinia + composable 足够;大型应用需要引入更完善的数据层方案(如 TanStack Query 的 Vue 版本),统一管理缓存、重试、乐观更新。

五、总结

Vue3 组合式架构的核心价值是逻辑的关注点分离。Composable 将相关逻辑封装为独立单元,解决了选项式 API 的逻辑碎片化问题。但组合式 API 不是银弹,响应式系统的 Proxy 机制带来了新的认知负担,解构丢失响应式、深层代理性能开销、缓存失效策略都是需要权衡的工程问题。

落地路线:先用 composable 封装可复用的业务逻辑(数据获取、表单管理、图表配置);再建立统一的服务端数据层,解决缓存与去重问题;最后根据应用规模决定是否引入更完善的状态管理方案。组合式架构的原则是:每个 composable 只做一件事,每个 ref 都有明确的归属,每次响应式更新都有可追溯的触发路径。

更多推荐