Vue 3 设计与实现

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

作者 杨艺韬 · 11,882 字

第 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 运行时中的实现基础和性能特征。

为什么一本讲源码的书需要一章讲架构?

前 18 章你学到了 Vue 3 的”每个齿轮怎么转”——响应式追踪、虚拟 DOM diff、调度器、SSR Hydration……这些都是”how”层面的知识。但真实项目里最让团队头疼的不是”怎么实现一个 computed”——那 Vue 已经帮你做好了——而是”这个逻辑应该放在 Composable、Renderless Component 还是 Store 里”、“这个深层嵌套的数据到底用 props 还是 provide/inject”、“团队十个人协作,代码怎么组织才不会在半年后烂掉”。这些都是”why”和”where”层面的问题,Vue 源码不会直接回答。

这一章要做的就是把前面章节学到的底层知识和现实架构决策连起来:Composable 为什么必须在同步阶段调用——因为 currentInstance 机制(第 10 章);状态机为什么要用 shallowRef——因为响应式代理的递归代价(第 4、5 章);微前端为什么需要独立的 Pinia 实例——因为模块级状态共享导致请求间泄漏(第 17 章 SSR 的孪生问题)。所有架构决策最终都能追溯到底层机制——这就是为什么读源码是比读”最佳实践列表”更根本的学习方式。

本章也会和第 14 章(DI 与插件)、第 15 章(Pinia)、第 17 章(SSR)多次呼应——这些章节已经从机制层面讲清楚了相应能力,本章从怎么组织这些能力的视角再审视一遍,两边结合才能把”框架会用”升级到”架构会做”。

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

从 Mixins 到 Composables 的进化

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

// ❌ 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() 都会创建独立的闭包,状态天然隔离。同时,返回值是显式的,使用方清楚知道每个变量的来源。

把这个”从 Mixins 到 Composables”的演进放到整个前端历史里看,你会发现它对应着一种更大的范式转移——从”约定式到”组合式。Mixins / HOC 这类早期方案的共同特点是:框架规定了一套固定的扩展点(data、methods、mounted…),用户把代码”填进去。这种方式在功能简单时很直观,但组合多个扩展时就会撞上命名空间的墙——两个 Mixin 都想定义 data.loading,怎么办?框架只能定出一套合并规则(Vue 2 的 Options 合并策略文档有一整页),但规则越多,用户的心智负担越重。

Composition API 走了完全不同的路径——让用户用 JavaScript 语言本身的组合能力来表达逻辑。函数作为第一等公民、闭包承载状态、显式返回值约定依赖——这些都是 JavaScript 语言天然就擅长的事。框架不用再发明”合并策略”,因为 JavaScript 已经有了——就是普通的函数调用和返回值。这次范式转移的启示是:当一个问题的既有解法越来越复杂时,先看看语言层面能不能直接解决,而不是在库/框架里堆更多约定。这和第 14 章讲 DI 时反复强调的”借语言的台子”是同一种工程审美的延续。

Composables 的运行时机制

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

// 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

这个”同步阶段约束”是 Vue 3 组合式 API 最常被忽视的一条规则——也是最容易出 bug 的地方。一个典型的错误模式:开发者写了一个 useFetch,内部在 Promise resolve 后调用 onUnmounted(stopPolling)——代码看起来很清爽,但运行时会发现清理函数永远不跑,因为注册时 currentInstance 已经是 null 了。更隐蔽的版本是跨组件的 currentInstance 污染:如果你的 useXxx 在 async 边界外触发了一个 setTimeout,回调里 onMounted 注册到的 instance 就是”回调执行时正在 setup 的那个组件”,可能完全不是你期望的组件。

Vue 3.3 引入的 hasInjectionContext() / getCurrentInstance() 工具让这件事变得可检查——在 Composable 入口加一行”如果没有 instance 就抛错”的守卫,能把这类 bug 在开发阶段暴露出来。VueUse 库里几乎每个 composable 都做了这个检查,这是一个值得在自己代码里效仿的习惯。本质上,同步约束是 Vue 为了”隐式传递 currentInstance”这个便利设计付出的一个确定代价——理解了这个 trade-off,你再看到相关报错时就不会困惑”为什么这个规则这么严格”了。

// ❌ 错误:异步调用 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 的设计契约:

// 原则 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`))
// 原则 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')
// 原则 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))
}
// 原则 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,但两者的运行时模型有根本区别:

graph LR
  subgraph Vue Composables
    A[setup 只执行一次] --> B[返回响应式引用]
    B --> C[响应式系统自动追踪依赖]
    C --> D[精确更新:只有依赖变化的部分重新执行]
  end

  subgraph React Hooks
    E[每次渲染都执行组件函数] --> F[重新创建闭包]
    F --> G[依赖数组手动声明]
    G --> H[整个组件函数重新执行]
  end

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

这个差异带来的开发体验差别有多大?——在 React 里,“这个 useEffect 的依赖数组漏了一个变量” 是生产事故的常见起点,eslint 的 exhaustive-deps 规则为此存在;“useMemo 什么时候值得用”是每个 React 开发者都要反复判断的小决策。这些心智负担在 Vue 里几乎不存在——你写的 ref.value++ 会自然地触发订阅者更新,你写的 computed 会自动缓存并精确失效,你不需要在每个副作用前停下来想”我依赖了哪些东西”。

这种差异并非谁”更好”,而是”一致性换省心”和”省心换一致性”两种权衡的结果。React 模型下函数组件每次都纯函数式地跑一遍,心智上”每次渲染都是一次新鲜的执行”,调试时不需要想”这个状态是哪一次渲染留下的”——这是纯函数式的优雅;Vue 模型下状态活在 setup 的闭包里,跨 render 存活,需要响应式系统来精确管理依赖——代价是响应式系统本身的复杂度。这两种路线在 2020 年前后基本各自锁定,目前看不出哪种会””——它们服务于不同的团队喜好和项目场景。你读到这里能明白两者的根本差异,就不会在网上那些”React vs Vue”的争论里被带偏——你会看透这些争论大多数都在比表层特性,没有触及核心的运行时模型。

19.2 Renderless Components 与 Headless UI

Composables 解决了”复用逻辑”,但它处理不了另一类问题——“我需要一组有 DOM 结构的行为组件,但不同场景要用不同的视觉”。比如你的产品需要三种外观的 dropdown:业务后台用传统下拉、C 端用精美动画、移动端用底部弹出——交互逻辑完全一样(键盘导航、焦点陷阱、选中高亮),视觉天差地别。Composable 层面无法表达”我要控制一组子组件的结构和协作”,这时就轮到 Renderless 组件和 Headless UI 登场。

行为与视觉的分离

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

// 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
      })
  }
})
<!-- 使用方完全控制视觉呈现 -->
<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" />
</RenderlessToggle>

作用域插槽的运行时原理

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

// 编译器将 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 为例:

// 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
}

这种模式的核心优势是:逻辑只写一次,视觉可以任意定制。无论你使用 Tailwind CSS、Element Plus 还是自定义样式系统,底层的键盘导航、焦点管理、ARIA 属性都可以复用。

Headless UI 为什么在 2023 年之后突然火起来?——有两个推力。第一个推力是设计系统工业化。大型公司纷纷建立自己的内部设计语言(Material Design、Ant Design、Fluent、Carbon、Base),每个设计语言有自己的视觉规范,但背后的交互(选择器的键盘导航、弹窗的焦点陷阱、表单的校验链)却几乎完全一样。把视觉从交互里拆出来,让交互部分可以跨设计系统共享——这是工业化的必然。第二个推力是 Tailwind CSS 的流行。Tailwind 让”用 utility class 写自己的视觉”变得极其舒服,但组件库通常自带了一套视觉规范——两者冲突。Headless UI 给 Tailwind 用户提供了”我用你的交互、我自己画我的视觉”的解法,这正好填上了空白。

Vue 生态里,Radix Vue、Headless UI Vue、Reka UI 等都是这个思路的代表。它们的源码特点几乎一致:大部分代码在处理可访问性(a11y)和键盘导航,视觉部分几乎为零。读这些库的源码是理解”一个好组件的交互到底要处理多少细节”的最好材料——你会发现一个看似简单的下拉菜单,背后要处理的状态、键盘事件、ARIA 属性加起来有上百行代码。很多自己写组件库的团队低估了这部分工作量,最后做出来的组件”看起来对、用起来不对”,这就是差距所在。

Renderless vs Composable:如何选择

graph TD
  A[需要复用逻辑?] -->|是| B{逻辑是否涉及 DOM 结构?}
  B -->|不涉及 DOM| C[使用 Composable]
  B -->|需要控制渲染结构| D{是否需要组合多个子组件?}
  D -->|单一行为| E[使用 Renderless Component]
  D -->|复合组件模式| F[使用 Headless 组件 + provide/inject]
  C --> G[例:useFetch / useLocalStorage]
  E --> H[例:RenderlessToggle / RenderlessForm]
  F --> I[例:Dropdown / Tabs / Combobox]

经验法则:如果逻辑不涉及 DOM 结构(纯数据/状态),用 Composable;如果需要控制渲染结构的组合关系(父子、兄弟组件的协调),用 Headless 组件。

这条规则的背后是”抽象粒度”的判断:Composable 的粒度是”一个函数”——适合抽取”把某段逻辑打包成可复用的函数式单元”这种需求;Headless 组件的粒度是”一组协作的组件”——适合抽取”多个组件之间约定好的状态共享和事件协议”这种需求。用错了粒度,代码会显得别扭:用 Composable 试图控制 5 个兄弟组件的状态协调,会变成一大串复杂的 ref 和函数返回;用 Headless 组件处理一个简单的 useMousePosition,会无端引入插槽作用域的模板复杂度。好的抽象是”匹配粒度”的艺术——这种判断力没法从书里直接学会,需要在真实项目里写一两个错配的案例、被反复重构教训之后才能内化。

19.3 状态机模式:用 XState 管理复杂交互

我的表单怎么又有 bug 了?“——每个前端同学在职业生涯早期都会遇到一个表单状态管理失控的故事。一个看似简单的登录表单:输入用户名→输入密码→点提交→后端校验→失败显示错误→用户修改→再次提交……用布尔标志写到第三版你就会发现代码难以维护,用 setState 的嵌套分支写到第五版就难以阅读。根本原因不是你写得不好——是这类问题本身就不适合用”一堆布尔值”建模。本节要讲的状态机模式,就是把这类问题从”用笨方法硬写”切换到”用合适的抽象自然表达”。

为什么布尔标志不够用

随着交互复杂度增长,用布尔标志管理状态会迅速失控:

// ❌ 布尔标志地狱:一个异步表单的状态
const isLoading = ref(false)
const isSubmitted = ref(false)
const hasError = ref(false)
const isRetrying = ref(false)
const isValidating = ref(false)

// 哪些组合是合法的?isLoading && hasError?isSubmitted && isRetrying?
// 没有任何机制阻止非法状态组合
function submit() {
  if (isLoading.value || isSubmitted.value) return  // 防御性编程
  isLoading.value = true
  isValidating.value = false
  hasError.value = false
  // ... 每个操作都要手动同步多个标志
}

布尔标志的根本问题是:N 个布尔值有 2^N 种组合,但合法的业务状态远少于此。状态机通过显式声明合法状态和转换来消除这个问题。

这是一个非常经典的”类型系统范畴错误”问题。布尔标志背后隐含的类型是 { isLoading: bool, isSubmitted: bool, hasError: bool, ... } 也就是 2^N 种状态组合;而业务真正需要的类型是一个 sum type(代数数据类型里的””)——'idle' | 'validating' | 'submitting' | 'error' | 'success',只有 5 种状态。用 product type(乘积类型)去模拟 sum type,就相当于”用笛卡尔积模拟并集”——绝大多数组合都是非法的、无意义的,但类型系统没法告诉你哪些合法。状态机等价于把这个隐含的 sum type 变得显式,让”非法状态不可表示”。

非法状态不可表示”(make illegal states unrepresentable)是 F# 社区首先推广的一条口号,后来在 Elm、Rust、Haskell 社区广泛流传。它的精神是:依赖运行时的”我检查一下状态组合是否合法”来保证正确性,远不如让类型系统在编译期就堵死非法状态。JavaScript / TypeScript 的类型系统天然偏向 product type(对象字面量的 {x, y}),要用 sum type 得靠 tagged union 和辅助库。XState 这类库的价值正是”把 sum type 的表达力带进 JavaScript 的状态管理”——它用运行时的额外代码弥补类型系统的不足,让”非法状态不可表示”在 JS 里也成为可能。

有限状态机的核心概念

stateDiagram-v2
  [*] --> idle
  idle --> validating : SUBMIT
  validating --> submitting : VALID
  validating --> idle : INVALID
  submitting --> success : RESOLVE
  submitting --> error : REJECT
  error --> submitting : RETRY
  error --> idle : RESET
  success --> [*]

用状态机重写上面的表单逻辑:

// stores/formMachine.ts
import { createMachine, assign } from 'xstate'

interface FormContext {
  data: Record<string, any>
  errors: string[]
  retryCount: number
}

type FormEvent =
  | { type: 'SUBMIT'; data: Record<string, any> }
  | { type: 'VALID' }
  | { type: 'INVALID'; errors: string[] }
  | { type: 'RESOLVE' }
  | { type: 'REJECT'; error: string }
  | { type: 'RETRY' }
  | { type: 'RESET' }

export const formMachine = createMachine<FormContext, FormEvent>({
  id: 'form',
  initial: 'idle',
  context: {
    data: {},
    errors: [],
    retryCount: 0
  },
  states: {
    idle: {
      on: {
        SUBMIT: {
          target: 'validating',
          actions: assign({ data: (_, event) => event.data, errors: [] })
        }
      }
    },
    validating: {
      invoke: {
        src: 'validateForm',
        onDone: 'submitting',
        onError: {
          target: 'idle',
          actions: assign({ errors: (_, event) => event.data })
        }
      }
    },
    submitting: {
      invoke: {
        src: 'submitForm',
        onDone: 'success',
        onError: {
          target: 'error',
          actions: assign({
            errors: (_, event) => [event.data.message]
          })
        }
      }
    },
    error: {
      on: {
        RETRY: {
          target: 'submitting',
          guard: (ctx) => ctx.retryCount < 3,
          actions: assign({ retryCount: (ctx) => ctx.retryCount + 1 })
        },
        RESET: {
          target: 'idle',
          actions: assign({ errors: [], retryCount: 0 })
        }
      }
    },
    success: {
      type: 'final'
    }
  }
})

在 Vue 中集成 XState

// composables/useMachine.ts
import { ref, shallowRef, onMounted, onUnmounted } from 'vue'
import { interpret, type AnyStateMachine, type StateFrom } from 'xstate'

export function useMachine<TMachine extends AnyStateMachine>(machine: TMachine) {
  const state = shallowRef(machine.initialState)
  const service = interpret(machine)

  // 使用 shallowRef 避免对 XState 状态对象的深度响应式转换
  // XState 的状态是不可变对象,用 shallowRef 即可
  service.onTransition((newState) => {
    state.value = newState
  })

  onMounted(() => service.start())
  onUnmounted(() => service.stop())

  function send(event: Parameters<typeof service.send>[0]) {
    service.send(event)
  }

  return {
    state,
    send,
    service
  }
}
<script setup lang="ts">
import { computed } from 'vue'
import { formMachine } from '@/stores/formMachine'
import { useMachine } from '@/composables/useMachine'

const { state, send } = useMachine(formMachine)

// 状态派生:直接从状态机读取,无需手动维护
const isLoading = computed(() => state.value.matches('submitting'))
const canRetry = computed(() =>
  state.value.matches('error') && state.value.context.retryCount < 3
)

function handleSubmit(formData: Record<string, any>) {
  send({ type: 'SUBMIT', data: formData })
}
</script>

<template>
  <form @submit.prevent="handleSubmit({ name, email })">
    <fieldset :disabled="isLoading">
      <!-- 表单字段 -->
    </fieldset>

    <div v-if="state.matches('error')" class="error">
      {{ state.context.errors[0] }}
      <button v-if="canRetry" @click="send({ type: 'RETRY' })">
        重试({{ 3 - state.context.retryCount }} 次剩余)
      </button>
      <button @click="send({ type: 'RESET' })">重置</button>

    <div v-if="state.matches('success')" class="success">
      提交成功!
  </form>
</template>

注意 shallowRef 的使用

上面的 useMachine 使用了 shallowRef 而非 ref。这不是随意的选择——XState 的状态对象包含大量内部属性和循环引用,如果使用 ref(即 reactive 的深度代理),Vue 的响应式系统会递归遍历整个对象,导致严重的性能问题甚至栈溢出。shallowRef 只追踪引用本身的变化,XState 每次状态转换都会生成新的不可变状态对象,因此 shallowRef 完全够用。

这个例子里藏着一条更普适的经验:不是所有对象都值得让 Vue 响应式系统代理。回到第 4、5 章讲过的响应式代理代价——每次属性访问走 Proxy get trap、每次写入触发依赖遍历——对于”自身已经管理好变更语义”的对象(XState 的不可变状态、Three.js 的场景图、D3 选择集、Y.js 的 CRDT 对象),让它们进入 Vue 响应式系统几乎只有坏处:不仅性能变差,还可能触发原对象内部的保护机制产生错误。

这类场景的统一处理原则是:shallowRef / markRaw / shallowReactive 之类的”响应式退出”API 把它们排除在 Vue 追踪之外,只追踪引用层面的变化。这类对象的变更语义通常是”整体替换”(XState 每次转换生成新状态、Three.js 每帧生成新场景树),shallow 级别的响应式刚好和这种变更模式对齐。工程的高阶技巧不是”用更多工具”,而是”让每个工具做它擅长的事、离开它不擅长的场景——shallowRef 就是 Vue 响应式系统给开发者留的”主动退出”通道,善用它能避开很多性能陷阱。

19.4 依赖注入 vs Props Drilling vs Store:三种通信模式的权衡

第 14 章讲 DI 时我们说过”3 层以内用 props,超过 3 层再考虑 DI”;第 15 章讲 Pinia 时我们说过”真正跨组件共享的业务状态才放 store”——这两条经验法则在本节里汇合,构成了 Vue 应用里组件间通信这件事的完整决策图谱。把它们放在一起讲的目的不是重复,而是从更高层次看清三种方式在”数据流的认知成本 vs 方便性”这个光谱上的相对位置——读完这一节你应该能在下次团队 code review 时立刻指出”这里用 store 是杀鸡用牛刀”或者”这里还在 props drilling 已经该升级到 provide 了”。

Vue 提供了多种组件间通信方式,每种都有其适用场景和代价。理解它们的运行时实现,才能做出正确的架构选择。

Props Drilling:显式但冗长

Props 是最基本的通信方式——数据通过组件树逐层向下传递:

<!-- GrandParent.vue -->
<Parent :theme="theme" :locale="locale" :user="user" />

<!-- Parent.vue — 自己不用这些 props,只是"透传" -->
<Child :theme="theme" :locale="locale" :user="user" />

<!-- Child.vue — 真正消费这些 props 的组件 -->
<div :class="theme">{{ user.name }}</div>

Props Drilling 的优势是完全显式——数据流清晰可追踪,任何 IDE 都能跳转到 props 的定义和使用处。但在深层组件树中(超过 3-4 层),中间层组件被迫声明和传递自己不关心的 props,增加了维护负担。

依赖注入(provide/inject):跨层级的响应式通道

Vue 的 provide/inject 在运行时是如何工作的?

// runtime-core/src/apiInject.ts

export function provide<T>(key: InjectionKey<T> | string, value: T) {
  if (currentInstance) {
    let provides = currentInstance.provides

    // 默认情况下,实例继承父组件的 provides 对象
    const parentProvides = currentInstance.parent?.provides

    if (parentProvides === provides) {
      // 第一次调用 provide 时,创建自己的 provides 对象
      // 使用原型链继承父组件的 provides
      provides = currentInstance.provides = Object.create(parentProvides)
    }

    provides[key as string] = value
  }
}

export function inject<T>(
  key: InjectionKey<T> | string,
  defaultValue?: T
): T | undefined {
  const instance = currentInstance
  if (instance) {
    // 沿原型链查找——这就是为什么 inject 能"穿透"多层组件
    const provides = instance.parent?.provides
    if (provides && (key as string) in provides) {
      return provides[key as string]
    } else if (defaultValue !== undefined) {
      return defaultValue
    }
  }
}

关键发现:provide 使用 Object.create(parentProvides) 创建原型链。这意味着 inject 的查找是沿着原型链进行的,时间复杂度是 O(深度),而非 O(1)。在绝大多数应用中这可以忽略不计,但在极深的组件树中值得注意。

响应式注入的正确姿势

// ✅ 提供响应式值:使用 ref 或 reactive
// theme/provider.ts
import { ref, provide, inject, readonly, type InjectionKey, type Ref } from 'vue'

interface ThemeContext {
  current: Readonly<Ref<'light' | 'dark'>>
  toggle: () => void
}

const ThemeKey: InjectionKey<ThemeContext> = Symbol('Theme')

export function provideTheme() {
  const current = ref<'light' | 'dark'>('light')

  function toggle() {
    current.value = current.value === 'light' ? 'dark' : 'light'
  }

  // 使用 readonly 防止消费方直接修改状态
  provide(ThemeKey, {
    current: readonly(current),
    toggle
  })
}

export function useTheme(): ThemeContext {
  const context = inject(ThemeKey)
  if (!context) {
    throw new Error('useTheme() must be used within a ThemeProvider')
  }
  return context
}

全局 Store(Pinia):应用级的状态管理

// stores/user.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

export const useUserStore = defineStore('user', () => {
  // State
  const profile = ref<UserProfile | null>(null)
  const permissions = ref<string[]>([])

  // Getters
  const isAdmin = computed(() => permissions.value.includes('admin'))
  const displayName = computed(() => profile.value?.name ?? '未登录')

  // Actions
  async function login(credentials: LoginCredentials) {
    const response = await api.login(credentials)
    profile.value = response.profile
    permissions.value = response.permissions
  }

  function logout() {
    profile.value = null
    permissions.value = []
  }

  return { profile, permissions, isAdmin, displayName, login, logout }
})

三种模式的决策矩阵

graph TD
  A[组件间需要共享数据] --> B{数据的作用范围?}
  B -->|父 → 直接子组件| C[Props — 最简单最显式]
  B -->|祖先 → 深层后代| D{数据是否有全局语义?}
  B -->|任意组件间| E[Store — Pinia]
  D -->|是:用户信息、权限、主题| E
  D -->|否:局部的 UI 状态| F[provide/inject]

  style C fill:#e8f5e9
  style E fill:#e3f2fd
  style F fill:#fff3e0
维度Propsprovide/injectPinia Store
数据流方向单向下行祖先→后代任意方向
类型安全完整(props 定义)需要 InjectionKey完整(defineStore)
DevTools 支持优秀一般优秀
SSR 安全天然安全需注意作用域需要每请求创建
可测试性直接传入需要 wrapper可独立测试
适用深度1-3 层3+ 层不限

实践原则:从 Props 开始,当层级超过 3 层且中间组件不消费数据时,改用 provide/inject。当数据具有全局语义(用户状态、权限、购物车)或需要跨路由持久化时,使用 Store。

这条原则里藏着一个重要判断:通信方式的”强度”应该和数据的”范围”匹配。用 Store 传一个只在一对父子之间用的数据是过度工程(任何人都能 inject 到,后续维护者会迷失”谁该修改它”);用 props 传一个全局用户信息是痛苦(每个页面都要在路由层面注入,到处都是重复样板)。选择通信方式不是”什么最强就用什么”——是”数据的生命周期、所有权、变更频率”决定应该用什么。

把这三种通信模式和前面章节讲的内容串起来:Props 是组件契约的一部分(第 10 章讲过);provide/inject 是 DI 机制(第 14 章详述过);Store 是 Pinia 这种结构化 DI(第 15 章)。第 14 章讲反模式时就强调过”3 层以内用 props”这条经验法则——本章是从架构视角再次印证同一条规律。相同的规律从不同视角反复出现,是你建立”工程直觉”的信号:当你看到一个决策在组件层、DI 层、状态管理层都指向同一方向时,它基本就是对的。

19.5 微前端中的 Vue 架构决策

微前端的核心挑战

微前端将一个大型前端应用拆分为多个独立开发、独立部署的子应用。Vue 应用作为微前端的子应用或主应用时,面临三个核心挑战:样式隔离状态隔离路由协调

为什么要有微前端?——很多教程一上来就讲实现,跳过了”要不要上”这个前置问题。微前端不是银弹,它带来的架构复杂度、沟通成本、调试难度都不小。真正需要微前端的场景只有两类:第一类是组织规模驱动的分治——团队超过 50 人、多个产品线共享一个大壳、不同团队用了不同框架(Vue + React + Angular 并存),这时候微前端是让多团队并行推进的唯一合理方案。第二类是技术栈迁移的过渡——公司要从 Vue 2 切到 Vue 3,几百万行代码不可能一口气切完,用微前端让新老应用并存两年、逐步替换——这是微前端最合理的用法之一。

如果你只是 5 个人的小团队,千万不要跟风上微前端——你以为你在”分治”,实际上只是在给自己额外加了一层工程负担。微前端适合的组织,基本都是”每个子应用有独立的发布节奏和独立的 owner team”——这是门槛,不满足就不要上。

graph TB
  subgraph 主应用 Shell
    Router[路由调度器]
    SharedState[共享状态总线]
    StyleScope[样式隔离层]
  end

  subgraph 子应用 A - Vue 3
    VA_Router[Vue Router]
    VA_Store[Pinia Store]
    VA_Components[组件树]
  end

  subgraph 子应用 B - Vue 3
    VB_Router[Vue Router]
    VB_Store[Pinia Store]
    VB_Components[组件树]
  end

  Router --> VA_Router
  Router --> VB_Router
  SharedState -.->|事件总线| VA_Store
  SharedState -.->|事件总线| VB_Store
  StyleScope --> VA_Components
  StyleScope --> VB_Components

Vue 应用的隔离挂载

每个 Vue 子应用需要独立的应用实例,避免全局状态污染:

// micro-app/bootstrap.ts
import { createApp, type App } from 'vue'
import { createPinia } from 'pinia'
import { createRouter, createMemoryHistory } from 'vue-router'
import RootComponent from './App.vue'
import { routes } from './routes'

let app: App | null = null

// 微前端生命周期:挂载
export function mount(container: HTMLElement, props?: Record<string, any>) {
  app = createApp(RootComponent)

  // 每个子应用独立的 Pinia 实例——状态不会跨应用泄漏
  const pinia = createPinia()

  // 使用 memory history 而非 browser history
  // 由主应用统一管理浏览器 URL,子应用使用内存路由
  const router = createRouter({
    history: createMemoryHistory(props?.basePath || '/'),
    routes
  })

  app.use(pinia)
  app.use(router)

  // 通过 provide 注入主应用传递的共享数据
  if (props?.sharedState) {
    app.provide('shared-state', props.sharedState)
  }

  app.mount(container)
}

// 微前端生命周期:卸载
export function unmount() {
  if (app) {
    app.unmount()
    app = null
  }
}

样式隔离策略

样式隔离是微前端里最容易遇到”看似能用但偶发出问题”的地方。<style scoped> 的隔离是”每个组件的类名加一个唯一 hash”——在纯 Vue 项目里够用,但微前端场景下全局样式、UI 库注入到 body 的 modal/tooltip、第三方库的样式表依然会穿透边界。真实踩过坑的团队通常会把样式隔离做到 Shadow DOM 级别——虽然 Shadow DOM 有自己的学习曲线(内部访问不了外部 CSS 变量、事件冒泡规则有差异),但它是目前唯一”彻底不泄露”的方案。选择时取决于你对隔离严格度的要求——“偶尔有冲突可以容忍”就用 CSS 前缀命名空间,“绝对不能有任何冲突”就上 Shadow DOM,二者之间没有中庸之道。

Vue 的 <style scoped> 通过为 DOM 元素添加 data-v-xxxxx 属性并改写 CSS 选择器来实现组件级样式隔离。但在微前端场景中,这还不够——全局样式、CSS 变量、第三方库的样式仍然可能互相干扰。

// 方案 1:Shadow DOM 隔离(最彻底)
export function mount(container: HTMLElement) {
  const shadowRoot = container.attachShadow({ mode: 'open' })
  const mountPoint = document.createElement('div')
  shadowRoot.appendChild(mountPoint)

  app = createApp(RootComponent)
  app.mount(mountPoint)

  // 注意:Shadow DOM 内部无法访问外部样式
  // 需要手动注入基础样式
  const styleSheet = new CSSStyleSheet()
  styleSheet.replaceSync(baseStyles)
  shadowRoot.adoptedStyleSheets = [styleSheet]
}

// 方案 2:CSS 命名空间(兼容性更好)
// 每个子应用的所有样式都包裹在唯一的命名空间下
// vue.config.ts 或 vite.config.ts
export default defineConfig({
  css: {
    preprocessorOptions: {
      scss: {
        additionalData: `.micro-app-a { `,
      }
    },
    postcss: {
      plugins: [
        // 自动为所有选择器添加命名空间前缀
        prefixSelector({ prefix: '.micro-app-a' })
      ]
    }
  }
})

跨应用通信

跨应用通信是微前端里最考验判断力的一环。好的跨应用通信应该少而精——每增加一条通信通道,就多一条子应用间的耦合,长期会变成难以拆解的”共享信道”噩梦。实战经验是:只有三类数据适合通过跨应用通信传递——用户身份(login/logout)、全局导航指令(主应用切换路由)、少量业务级别的事件(购物车更新、通知提醒)。其他所有数据都应该通过独立的 API 请求各自获取,而不是从其他子应用”传过来”。把这条纪律守住,微前端才真正是”分治”而不是”披着微前端皮的整体应用”。

微前端子应用之间的通信应该遵循松耦合原则——子应用不直接引用其他子应用的代码:

// shared/event-bus.ts
// 使用 CustomEvent 作为跨应用通信机制
type EventPayload = {
  'user:login': { userId: string; token: string }
  'user:logout': void
  'cart:update': { itemCount: number }
  'navigation:change': { path: string }
}

class MicroFrontendBus {
  emit<K extends keyof EventPayload>(
    event: K,
    payload: EventPayload[K]
  ) {
    window.dispatchEvent(
      new CustomEvent(`mf:${event}`, { detail: payload })
    )
  }

  on<K extends keyof EventPayload>(
    event: K,
    handler: (payload: EventPayload[K]) => void
  ): () => void {
    const listener = (e: Event) => {
      handler((e as CustomEvent).detail)
    }
    window.addEventListener(`mf:${event}`, listener)
    return () => window.removeEventListener(`mf:${event}`, listener)
  }
}

export const bus = new MicroFrontendBus()
// 在 Vue 子应用中使用
// composables/useMicroFrontend.ts
import { onUnmounted } from 'vue'
import { bus } from '@shared/event-bus'

export function useMicroFrontendEvent<K extends keyof EventPayload>(
  event: K,
  handler: (payload: EventPayload[K]) => void
) {
  const unsubscribe = bus.on(event, handler)
  onUnmounted(unsubscribe)  // 子应用卸载时自动取消订阅
}

19.6 大型项目的模块化架构(Feature-Sliced Design)

一个 Vue 项目从 0 到 50 个组件,目录结构几乎怎么建都不会出大问题——小有小的好处,改个文件名、挪个目录都是分钟级的事。真正让架构痛苦开始的是”从 50 到 500 个组件”那个区间——新功能要放哪里、某段逻辑应该抽到 composable 还是 util、这个 store 该按页面分还是按业务分……每一个小决定都是 PR review 里的摩擦点,累积起来团队效率会明显下降。到了 500+ 组件规模,没有架构规范的项目基本都会进入”谁也改不动”的状态——改一个小功能要牵动十几个文件,每个新同事上手要一个月才敢动核心代码。FSD 是社区针对这个痛点给出的一套比较成熟的规范——不是唯一解,但在 2024-2026 年已经成为事实上的主流选择之一。

传统项目结构的问题

大多数 Vue 项目从”按技术类型分目录”开始——components、composables、stores、utils、types。当项目规模达到数百个文件时,这种结构的问题暴露无遗:

❌ 按技术类型分目录(规模大了之后)
src/
├── components/       # 200+ 个组件,哪些属于哪个功能?
│   ├── UserAvatar.vue
│   ├── UserProfile.vue
│   ├── CartItem.vue
│   ├── CartSummary.vue
│   └── ...
├── composables/      # useFetch 被 12 个功能引用
├── stores/           # store 之间存在隐式依赖
├── utils/            # 成了垃圾桶
└── types/            # 类型定义远离使用处

Feature-Sliced Design(FSD)架构

FSD 是一套适用于前端项目的模块化架构规范,将代码组织为层(Layer)、**切片(Slice)段(Segment)**三个维度:

graph TB
  subgraph Layer 层级
    direction TB
    App[app — 全局初始化、Provider]
    Pages[pages — 页面组件、路由配置]
    Widgets[widgets — 组合多个 feature 的复合区块]
    Features[features — 用户交互功能]
    Entities[entities — 业务实体]
    Shared[shared — 基础设施、UI Kit]
  end

  App --> Pages
  Pages --> Widgets
  Widgets --> Features
  Features --> Entities
  Entities --> Shared

  style App fill:#ffcdd2
  style Pages fill:#f8bbd0
  style Widgets fill:#e1bee7
  style Features fill:#c5cae9
  style Entities fill:#b3e5fc
  style Shared fill:#c8e6c9

核心规则:上层可以引用下层,但下层不能引用上层。同层之间的切片(如两个不同的 feature)不能直接互相引用。

以一个电商项目为例:

✅ Feature-Sliced Design
src/
├── app/                    # 全局初始化
│   ├── App.vue
│   ├── providers/          # Pinia、Router、i18n 的初始化
│   └── styles/             # 全局样式、CSS 变量

├── pages/                  # 页面 = 路由入口
│   ├── home/
│   │   └── ui/HomePage.vue
│   ├── product/
│   │   └── ui/ProductPage.vue
│   └── cart/
│       └── ui/CartPage.vue

├── widgets/                # 复合 UI 区块
│   ├── header/
│   │   └── ui/AppHeader.vue
│   └── product-list/
│       └── ui/ProductList.vue

├── features/               # 用户交互功能
│   ├── add-to-cart/
│   │   ├── ui/AddToCartButton.vue
│   │   ├── model/useAddToCart.ts
│   │   └── api/cartApi.ts
│   ├── search-products/
│   │   ├── ui/SearchBar.vue
│   │   ├── model/useSearch.ts
│   │   └── api/searchApi.ts
│   └── auth/
│       ├── ui/LoginForm.vue
│       ├── model/useAuth.ts
│       └── api/authApi.ts

├── entities/               # 业务实体
│   ├── product/
│   │   ├── ui/ProductCard.vue
│   │   ├── model/types.ts
│   │   └── api/productApi.ts
│   └── user/
│       ├── ui/UserAvatar.vue
│       ├── model/types.ts
│       └── api/userApi.ts

└── shared/                 # 基础设施
    ├── ui/                 # Button、Input、Modal 等基础组件
    ├── lib/                # 工具函数
    ├── api/                # HTTP 客户端配置
    └── config/             # 环境变量、常量

在 Vue 中实施 FSD 的公共 API 模式

每个切片通过 index.ts 暴露公共 API,内部实现对外不可见:

// features/add-to-cart/index.ts
// 只导出外部需要的内容
export { default as AddToCartButton } from './ui/AddToCartButton.vue'
export { useAddToCart } from './model/useAddToCart'
export type { AddToCartOptions } from './model/types'

// 内部 API(cartApi.ts)不导出——外部无法直接引用
// features/add-to-cart/model/useAddToCart.ts
import { ref } from 'vue'
import { useUserStore } from '@/entities/user'       // ✅ feature → entity(上→下)
import { cartApi } from '../api/cartApi'              // ✅ 内部引用
import type { Product } from '@/entities/product'     // ✅ feature → entity

// ❌ import { useSearch } from '@/features/search-products'
// 同层 feature 之间不能直接引用!

export function useAddToCart() {
  const loading = ref(false)
  const userStore = useUserStore()

  async function addToCart(product: Product, quantity: number = 1) {
    if (!userStore.isLoggedIn) {
      throw new Error('请先登录')
    }

    loading.value = true
    try {
      await cartApi.add({
        productId: product.id,
        quantity,
        userId: userStore.profile!.id
      })
    } finally {
      loading.value = false
    }
  }

  return { addToCart, loading }
}

通过 ESLint 强制分层约束

架构规则如果不自动化检查就会逐渐腐化。使用 eslint-plugin-boundarieseslint-plugin-import 强制 FSD 的分层规则:

// .eslintrc.cjs(关键规则)
module.exports = {
  plugins: ['boundaries'],
  settings: {
    'boundaries/elements': [
      { type: 'app', pattern: 'src/app/*' },
      { type: 'pages', pattern: 'src/pages/*' },
      { type: 'widgets', pattern: 'src/widgets/*' },
      { type: 'features', pattern: 'src/features/*' },
      { type: 'entities', pattern: 'src/entities/*' },
      { type: 'shared', pattern: 'src/shared/*' },
    ],
    'boundaries/dependency-nodes': ['import'],
  },
  rules: {
    'boundaries/element-types': [
      'error',
      {
        default: 'disallow',
        rules: [
          // 每一层只能引用它下面的层
          { from: 'app', allow: ['pages', 'widgets', 'features', 'entities', 'shared'] },
          { from: 'pages', allow: ['widgets', 'features', 'entities', 'shared'] },
          { from: 'widgets', allow: ['features', 'entities', 'shared'] },
          { from: 'features', allow: ['entities', 'shared'] },
          { from: 'entities', allow: ['shared'] },
          { from: 'shared', allow: ['shared'] },
        ]
      }
    ]
  }
}

FSD 的权衡

FSD 不是银弹。对于小型项目(< 30 个组件),它的目录嵌套和公共 API 约束是过度工程。建议在以下条件下考虑采用 FSD:

  • 团队超过 3 人协作
  • 项目预期会持续迭代超过 1 年
  • 存在多个相对独立的业务领域
  • 需要对不同功能模块设置不同的代码所有者(CODEOWNERS)

FSD 和”按功能分目录”(feature-based folder)有什么本质差别?——表面上都是”按业务而不是按技术类型组织代码”,但 FSD 多了一层”分层+单向依赖”的约束。纯 feature-based 结构在小中型项目里够用,但 feature 之间可以自由互相引用——半年后你会发现 feature A 引用了 feature B、B 又反过来引用了 A,形成循环依赖,拆出一个来都拆不干净。FSD 用层级规则强制”feature 不互相依赖、都依赖更底层的 entity / shared”,从架构约束层面消除了这类腐化路径。

这让我想到一个工程界的老定律:任何不被工具强制的约定,长期都会被违反。CODEOWNERS、lint 规则、CI 检查、目录分层——每一种都是把”说好的原则”物化成”违反就报错”的机制。FSD 的价值不在它规定了什么结构(类似结构别家规范也有),而在它给了一套可以直接落成 ESLint 规则的具体约束。“能被机器检查的规范”比”写在文档里的规范”强一百倍——这条心得放在任何一个长周期项目里都适用。

本章小结

设计模式不是教条,而是经过验证的解决方案模板。本章探讨的每种模式都有其适用边界

  1. Composables 是 Vue 3 逻辑复用的基石。它利用闭包实现状态隔离,利用 currentInstance 机制绑定生命周期,在性能和人体工程学上都优于 Mixins 和 HOC。设计高质量 Composable 需要遵循参数归一化、返回值为 ref、副作用自清理等原则。

  2. Renderless Components 与 Headless UI 通过作用域插槽实现行为与视觉的分离。当需要在保持相同交互逻辑的前提下支持多种视觉呈现时,这是最佳选择。Headless 组件库(如 Headless UI、Radix Vue)正在成为主流趋势。

  3. 状态机模式 通过显式声明合法状态和转换,消除了布尔标志组合爆炸的问题。XState 与 Vue 的集成需要注意使用 shallowRef 避免深度响应式的性能陷阱。适用于复杂的异步流程和多步骤交互。

  4. 三种通信模式 各有所长:Props 最显式适合浅层传递,provide/inject 利用原型链实现跨层级注入适合局部共享,Pinia Store 适合全局状态管理。从 Props 开始,按需升级。

  5. 微前端 要求 Vue 子应用实现彻底的隔离——独立的 app 实例、独立的 Pinia 和 Router、内存路由策略、样式命名空间或 Shadow DOM。跨应用通信应使用松耦合的事件总线。

  6. Feature-Sliced Design 将代码按层(app → pages → widgets → features → entities → shared)组织,通过严格的单向依赖规则和公共 API 模式,让大型项目保持可维护性。ESLint 规则可以自动化检查分层约束。

全书收官

从第 1 章的源码概览开始,我们走过了响应式系统的依赖追踪、编译器的静态优化、虚拟 DOM 的 diff 算法、组件系统的生命周期、调度器的三级队列、指令的扩展机制、DI 的原型链继承、Pinia 的响应式集成、Vue Router 的导航守卫、SSR 的 Hydration、性能优化的各种招数……再到本章讲的架构决策。如果你能坚持读到这里,你对 Vue 3 的理解已经不是”会用”的层面,而是”知其所以然”——你能解释每一个 API 的设计动机、每一条最佳实践的底层原因、每一种架构选择的真实代价。

这种”看透框架”的能力比任何具体的 Vue 技能都珍贵——框架会迭代(Vue 3 之后还会有 Vue 4、Vue 5),但”理解一个 UI 框架必须考虑哪些维度、每种设计背后的 trade-off 是什么”的能力会陪伴你一辈子。将来面对 React / Svelte / Solid / Qwik 的新特性时,你不会再被新概念的表层吓到——你知道它们不外乎是”响应式、编译期优化、调度、DI、渲染器”这几个老问题的新答案。框架千变万化,核心问题就那么几个。读透一个框架的源码,你就拿到了”读懂所有同类框架”的钥匙。

延伸阅读

  • VueUse 源码:学 Composable 最直接的案例库——每个 composable 都体现着”参数归一化 / 返回 ref / 副作用自清理”的最佳实践。
  • Radix Vue / Reka UI 源码:学 Headless 模式最透彻的参考,尤其看它们对键盘导航和 a11y 的处理。
  • XState 官方文档 Statecharts 部分:理解状态机 / 状态图的经典资源,作者 David Khourshid 是前端状态机化的主要推动者。
  • feature-sliced.design 官方文档:FSD 的完整规范和中文材料,包含与其他架构(Clean Architecture、DDD、Atomic Design)的对比。
  • Martin Fowler Micro Frontends(martinfowler.com/articles/micro-frontends.html):微前端领域的权威长文,先理解为什么,再决定要不要。
  • Evan You Vue 3 Design Principles(官方博客):作者本人对 Vue 3 设计哲学的第一手阐述,读完你会对本章的架构选择有更深的同理心。

思考题

  1. Composable 的调用时机限制:为什么 Vue 的 Composable 必须在 setup() 的同步执行阶段调用?如果 Vue 改用 React 的”每次渲染都执行函数组件”模型,Composable 的设计会发生什么变化?这两种模型在性能和开发体验上各有什么取舍?

  2. Renderless vs Composable 的边界:假设你需要实现一个”可拖拽列表”组件,支持拖拽排序、动画过渡和自定义渲染。你会选择 Renderless Component 还是 Composable?为什么?如果两种方式都用,它们各自负责什么?

  3. provide/inject 的性能特征:Vue 的 provide 使用 Object.create(parentProvides) 创建原型链。假设组件树有 50 层嵌套,最底层组件 inject 一个最顶层 provide 的值,查找过程是怎样的?你能设计一种避免原型链遍历的替代方案吗?它会带来什么新的权衡?

  4. 状态机的适用边界:不是所有状态管理都需要状态机。请描述一个”不适合”使用状态机的场景,并解释为什么简单的响应式状态(ref/reactive)在该场景下是更好的选择。

  5. FSD 分层规则的代价:Feature-Sliced Design 规定同层的两个 feature 不能直接引用对方。假设”添加到购物车”功能需要检查用户是否已登录(auth 功能),在 FSD 架构下你该如何处理这个跨 feature 的依赖?至少给出两种方案并比较它们的优劣。