Skip to content

第 19 章 设计模式与架构决策

本章要点

  • 组合式函数(Composables)的设计原则:单一职责、参数归一化、返回值契约
  • Composables 如何利用 Vue 的响应式系统实现逻辑复用,以及与 Mixins/HOC 的本质区别
  • Renderless Components 与 Headless UI:将行为逻辑与视觉呈现彻底分离
  • 状态机模式:用有限状态机(FSM)管理复杂交互,杜绝"状态爆炸"问题
  • 依赖注入(provide/inject)、Props Drilling、全局 Store 三种通信模式的适用场景与代价
  • 微前端架构中 Vue 应用的隔离策略:样式隔离、状态隔离、路由协调
  • Feature-Sliced Design:大型项目的模块化分层架构实践
  • 从源码和运行时角度理解每种模式的性能特征与限制

框架提供了原语(primitive),但架构决定了应用的上限。Vue 3 的 Composition API、响应式系统、编译器优化为开发者提供了强大的底层能力,但如何将这些能力组织成可维护、可扩展的应用架构,是每个团队都必须面对的问题。

前 18 章我们深入剖析了 Vue 3 的内核实现——从响应式系统的依赖追踪,到编译器的静态优化,再到组件系统的生命周期调度。本章将视角从"框架如何工作"转向"如何用好框架",探讨 Vue 生态中最重要的设计模式与架构决策。这些模式不是凭空而来的最佳实践,每一个都有其在 Vue 运行时中的实现基础和性能特征。

19.1 组合式函数(Composables):逻辑复用的最佳范式

从 Mixins 到 Composables 的进化

Vue 2 时代的 Mixins 是逻辑复用的主要手段,但它有三个致命缺陷:命名冲突(多个 Mixin 可能定义同名属性)、来源不透明(模板中使用的变量不知道来自哪个 Mixin)、隐式耦合(Mixin 之间可能互相依赖)。Composition API 的设计正是为了彻底解决这些问题。

typescript
// ❌ Vue 2 Mixins:命名冲突 + 来源不透明
const mouseTracker = {
  data() {
    return { x: 0, y: 0 }  // 与其他 Mixin 冲突?
  },
  mounted() {
    window.addEventListener('mousemove', this.update)
  },
  methods: {
    update(e: MouseEvent) {
      this.x = e.pageX
      this.y = e.pageY
    }
  }
}

// ✅ Vue 3 Composables:显式导入 + 可溯源
import { ref, onMounted, onUnmounted } from 'vue'
import type { Ref } from 'vue'

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

export function useMouse(): UseMouseReturn {
  const x = ref(0)
  const y = ref(0)

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

  onMounted(() => window.addEventListener('mousemove', update))
  onUnmounted(() => window.removeEventListener('mousemove', update))

  return { x, y }
}

Composables 之所以能解决 Mixins 的全部问题,关键在于它利用了 JavaScript 的词法作用域——每次调用 useMouse() 都会创建独立的闭包,状态天然隔离。同时,返回值是显式的,使用方清楚知道每个变量的来源。

Composables 的运行时机制

Composables 并不是 Vue 运行时的特殊构造,它本质上就是一个普通函数。它之所以能"绑定"到组件的生命周期,靠的是 Vue 内部的 currentInstance 机制:

typescript
// runtime-core/src/component.ts
// Vue 维护一个全局变量记录当前正在初始化的组件实例
export let currentInstance: ComponentInternalInstance | null = null

export function setCurrentInstance(instance: ComponentInternalInstance) {
  currentInstance = instance
}

// runtime-core/src/apiLifecycle.ts
// 生命周期钩子通过 currentInstance 绑定到当前组件
export function onMounted(hook: () => void) {
  if (currentInstance) {
    // 将钩子注册到当前实例的生命周期队列
    ;(currentInstance.m || (currentInstance.m = [])).push(hook)
  } else if (__DEV__) {
    warn('onMounted is called when there is no active component instance.')
  }
}

这就是为什么 Composables 必须在 setup() 函数的同步执行阶段调用——因为只有在这个阶段,currentInstance 才指向正确的组件实例。如果在异步回调中调用 onMountedcurrentInstance 可能已经指向另一个组件甚至为 null

typescript
// ❌ 错误:异步调用 Composable
async function setup() {
  await someAsyncOperation()
  // 此时 currentInstance 可能已经丢失
  const { x, y } = useMouse()  // 生命周期钩子无法正确绑定
}

// ✅ 正确:同步调用,异步操作放在内部
function setup() {
  const { x, y } = useMouse()  // 同步调用,currentInstance 有效
  const data = useAsyncData('/api/data')  // 异步操作在 Composable 内部处理
  return { x, y, data }
}

高质量 Composable 的设计原则

经过大量实践,社区总结出一套 Composable 的设计契约:

typescript
// 原则 1:参数归一化(MaybeRef 模式)
import { ref, watch, unref, type MaybeRef } from 'vue'

export function useTitle(title: MaybeRef<string>) {
  // 无论传入 string 还是 Ref<string>,都统一处理
  watch(
    () => unref(title),
    (newTitle) => {
      document.title = newTitle
    },
    { immediate: true }
  )
}

// 使用方式灵活
useTitle('静态标题')
useTitle(ref('动态标题'))
useTitle(computed(() => `${page.value} - My App`))
typescript
// 原则 2:返回值使用 ref 而非 reactive
// 这样使用方可以解构而不丢失响应性
export function useFetch<T>(url: MaybeRef<string>) {
  const data = ref<T | null>(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))
      data.value = await response.json()
    } catch (e) {
      error.value = e as Error
    } finally {
      loading.value = false
    }
  }

  // 当 url 是 ref 时,自动重新请求
  watch(() => unref(url), execute, { immediate: true })

  return { data, error, loading, execute }
}

// ✅ 解构后仍然保持响应性
const { data, loading } = useFetch<User[]>('/api/users')
typescript
// 原则 3:副作用自清理
export function useEventListener<K extends keyof WindowEventMap>(
  target: Window | HTMLElement,
  event: K,
  handler: (e: WindowEventMap[K]) => void,
  options?: AddEventListenerOptions
) {
  onMounted(() => target.addEventListener(event, handler as EventListener, options))

  // 组件卸载时自动移除监听器,调用方无需手动清理
  onUnmounted(() => target.removeEventListener(event, handler as EventListener, options))
}

基于 VitePress 构建