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))
}
typescript
// 原则 4:Composable 的组合——大的 Composable 由小的 Composable 组成
export function useInfiniteScroll(
  container: MaybeRef<HTMLElement | null>,
  callback: () => Promise<void>,
  options: { threshold?: number } = {}
) {
  const { threshold = 100 } = options
  const loading = ref(false)

  // 复用其他 Composable
  const { y: scrollY } = useScroll(container)
  const { height: containerHeight } = useElementSize(container)

  watch(scrollY, async (newY) => {
    const el = unref(container)
    if (!el || loading.value) return

    const scrollHeight = el.scrollHeight
    if (scrollHeight - newY - containerHeight.value < threshold) {
      loading.value = true
      await callback()
      loading.value = false
    }
  })

  return { loading }
}

Composables 与 React Hooks 的底层差异

虽然 Composables 在形式上类似 React Hooks,但两者的运行时模型有根本区别:

Vue 的 setup() 只执行一次,通过响应式系统的依赖追踪实现精确更新;React 的函数组件每次渲染都重新执行,依赖 useMemo/useCallback 等手动优化。这意味着 Vue Composables 不需要担心 React Hooks 中的"闭包陷阱"和"无限循环"问题。

19.2 Renderless Components 与 Headless UI

行为与视觉的分离

Renderless Component(无渲染组件)是一种将交互逻辑视觉呈现完全分离的模式。组件只负责状态管理和行为逻辑,通过作用域插槽(Scoped Slots)将状态暴露给父组件,由父组件决定如何渲染:

typescript
// components/RenderlessToggle.vue
import { defineComponent, ref } from 'vue'

export default defineComponent({
  name: 'RenderlessToggle',
  props: {
    initialValue: {
      type: Boolean,
      default: false
    }
  },
  emits: ['change'],
  setup(props, { slots, emit }) {
    const isOn = ref(props.initialValue)

    function toggle() {
      isOn.value = !isOn.value
      emit('change', isOn.value)
    }

    function setOn() { isOn.value = true; emit('change', true) }
    function setOff() { isOn.value = false; emit('change', false) }

    // 不渲染任何 DOM,只通过插槽暴露状态和方法
    return () =>
      slots.default?.({
        isOn: isOn.value,
        toggle,
        setOn,
        setOff
      })
  }
})
vue
<!-- 使用方完全控制视觉呈现 -->
<RenderlessToggle v-slot="{ isOn, toggle }">
  <!-- 方案 A:简单按钮 -->
  <button @click="toggle">
    {{ isOn ? '开启' : '关闭' }}
  </button>
</RenderlessToggle>

<RenderlessToggle v-slot="{ isOn, toggle }">
  <!-- 方案 B:滑动开关 -->
  <div
    class="switch"
    :class="{ active: isOn }"
    @click="toggle"
  >
    <div class="slider" />
  </div>
</RenderlessToggle>

作用域插槽的运行时原理

Renderless Component 的核心是作用域插槽。在 Vue 的运行时中,插槽被编译为函数:

typescript
// 编译器将 v-slot 编译为函数
// <RenderlessToggle v-slot="{ isOn, toggle }">
//   <button @click="toggle">{{ isOn ? '开' : '关' }}</button>
// </RenderlessToggle>

// 编译结果(简化):
createVNode(RenderlessToggle, null, {
  default: withCtx(({ isOn, toggle }: { isOn: boolean; toggle: () => void }) => [
    createVNode('button', { onClick: toggle }, isOn ? '开' : '关')
  ])
})

// runtime-core/src/componentSlots.ts
// 插槽本质上是一个返回 VNode 数组的函数
type Slot = (...args: any[]) => VNode[]

// 组件渲染时调用 slots.default?.({ ...props })
// 将状态作为参数传给插槽函数,生成对应的 VNode

Headless UI 组件库的架构

Headless UI 将 Renderless 模式发展为完整的组件库架构。以一个 Headless Dropdown 为例:

typescript
// headless/useDropdown.ts
import { ref, computed, provide, inject, type InjectionKey } from 'vue'

interface DropdownContext {
  isOpen: Ref<boolean>
  activeIndex: Ref<number>
  items: Ref<string[]>
  open: () => void
  close: () => void
  toggle: () => void
  select: (index: number) => void
  onKeyDown: (e: KeyboardEvent) => void
}

const DropdownKey: InjectionKey<DropdownContext> = Symbol('Dropdown')

export function useDropdownProvider() {
  const isOpen = ref(false)
  const activeIndex = ref(-1)
  const items = ref<string[]>([])

  function open() { isOpen.value = true; activeIndex.value = 0 }
  function close() { isOpen.value = false; activeIndex.value = -1 }
  function toggle() { isOpen.value ? close() : open() }

  function select(index: number) {
    activeIndex.value = index
    close()
  }

  function onKeyDown(e: KeyboardEvent) {
    switch (e.key) {
      case 'ArrowDown':
        e.preventDefault()
        activeIndex.value = Math.min(activeIndex.value + 1, items.value.length - 1)
        break
      case 'ArrowUp':
        e.preventDefault()
        activeIndex.value = Math.max(activeIndex.value - 1, 0)
        break
      case 'Enter':
        e.preventDefault()
        if (activeIndex.value >= 0) select(activeIndex.value)
        break
      case 'Escape':
        close()
        break
    }
  }

  const context: DropdownContext = {
    isOpen, activeIndex, items, open, close, toggle, select, onKeyDown
  }

  provide(DropdownKey, context)
  return context
}

export function useDropdownConsumer(): DropdownContext {
  const context = inject(DropdownKey)
  if (!context) throw new Error('Dropdown compound components must be used within <Dropdown>')
  return context
}

基于 VitePress 构建