Appearance
第 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 才指向正确的组件实例。如果在异步回调中调用 onMounted,currentInstance 可能已经指向另一个组件甚至为 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 })
// 将状态作为参数传给插槽函数,生成对应的 VNodeHeadless 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
}