解构前端王牌:Vue × React 源码里的 5 大设计模式,为什么值得学?(含通俗代码示例)
一句话导读:Vue 和 React 不只是工具箱,更是设计模式的“活教材”。本文用最少概念 + 可运行的迷你示例,带你把源码思路学到手、落到业务里,并附面试速答卡。
目录
- 为什么是这 5 个模式
- ① Observer / Publish–Subscribe(观察者/发布-订阅)
- ② Composite(组合)
- ③ Strategy(策略:列表 diff/
key规则) - ④ Template Method(模板方法:Hooks/Composables 的骨架)
- ⑤ Dependency Injection(依赖注入:Context / Provide-Inject)
- 加分项:React 的 Scheduler/Fiber 与 Vue 的“解释-渲染”双阶段
- 面试速答清单
- 参考与延伸
为什么是这 5 个模式
框架要解决的本质是:把“状态变化”稳定、可控、性能友好地映射到 UI。
这 5 个模式正好覆盖了状态传播(Observer)、树形 UI(Composite)、高性价比更新(Strategy)、可复用骨架(Template Method)和跨层数据解耦(DI)。理解它们,就理解了 Vue/React 为什么“长这样”。
① Observer / Publish–Subscribe(观察者/发布-订阅)
作用:状态改变时,自动通知订阅者更新。
框架里怎么用:React 的渲染/外部 store 订阅链路;Vue 通过 getter 收集依赖、setter/Proxy 触发更新。
React:10 行写个可订阅的 Store + useSyncExternalStore 接入
// store.ts – Tiny observable store (TypeScript friendly)
type Listener = () => void;
class CounterStore {
private count = 0;
private listeners = new Set<Listener>();
getSnapshot = () => this.count;
subscribe = (fn: Listener) => (this.listeners.add(fn), () => this.listeners.delete(fn));
inc = () => { this.count++; this.listeners.forEach(l => l()); }
}
export const counterStore = new CounterStore();
// React component – connect via useSyncExternalStore
import { useSyncExternalStore } from 'react';
import { counterStore } from './store';
export default function Counter() {
const count = useSyncExternalStore(counterStore.subscribe, counterStore.getSnapshot);
return (
<div>
<p>count: {count}</p>
<button onClick={counterStore.inc}>+1</button>
</div>
);
}
你可以这样解释:我把 UI 当成“观察者”,store 当成“主题”,
useSyncExternalStore让订阅/取消订阅与快照读取对齐 React 的渲染流程,这就是观察者模式的工程化。
Vue 3:ref + watchEffect = 观察者“读即订阅、写即触发”
<script setup lang="ts">
import { ref, watchEffect } from 'vue';
const count = ref(0);
// Any reactive read inside watchEffect becomes a dependency automatically.
watchEffect(() => {
console.log('[effect] count changed to', count.value);
});
const inc = () => count.value++;
</script>
<template>
<p>count: {{ count }}</p>
<button @click="inc">+1</button>
</template>
你可以这样解释:
watchEffect运行时读取了count,因此“被动订阅”;count.value++时触发依赖的 effect,这就是观察者模式在 Vue 里的实现方式。
② Composite(组合)
作用:用同一套接口对待“整体(容器)”和“部分(叶子)”。
框架里怎么用:React 的元素树/Fiber 树、Vue 的组件树与递归组件。
React:Compound Components(复合组件)= 组合模式的应用
// Tabs.tsx – Container establishes a shared context for its parts.
import React, { createContext, useContext, useState } from 'react';
type Ctx = { active: number; setActive: (i: number) => void };
const TabsCtx = createContext<Ctx | null>(null);
const useTabs = () => useContext(TabsCtx)!;
export function Tabs({ children }: { children: React.ReactNode }) {
const [active, setActive] = useState(0);
return <TabsCtx.Provider value={{ active, setActive }}>{children}</TabsCtx.Provider>;
}
Tabs.List = function List({ children }: { children: React.ReactNode }) {
return <div role="tablist">{children}</div>;
};
Tabs.Tab = function Tab({ index, children }: { index: number; children: React.ReactNode }) {
const { active, setActive } = useTabs();
const isActive = active === index;
return (
<button
role="tab"
aria-selected={isActive}
onClick={() => setActive(index)}
style={{ fontWeight: isActive ? 'bold' : 'normal' }}
>
{children}
</button>
);
};
Tabs.Panel = function Panel({ index, children }: { index: number; children: React.ReactNode }) {
const { active } = useTabs();
return active === index ? <div role="tabpanel">{children}</div> : null;
};
// Usage
<Tabs>
<Tabs.List>
<Tabs.Tab index={0}>Home</Tabs.Tab>
<Tabs.Tab index={1}>Profile</Tabs.Tab>
</Tabs.List>
<Tabs.Panel index={0}>Home Content</Tabs.Panel>
<Tabs.Panel index={1}>Profile Content</Tabs.Panel>
</Tabs>
你可以这样解释:
Tabs(整体)和Tab/Panel(部分)通过共享上下文协作,以统一接口共同组成 UI,这就是组合模式。
Vue:用递归组件渲染树形结构
<script setup lang="ts">
type Node = { id: string; name: string; children?: Node[] };
const props = defineProps<{ node: Node }>();
</script>
<template>
<li>
{{ props.node.name }}
<ul v-if="props.node.children?.length">
<TreeNode v-for="child in props.node.children" :key="child.id" :node="child" />
</ul>
</li>
</template>
<!-- Usage -->
<script setup lang="ts">
import TreeNode from './TreeNode.vue';
const data = { id: 'root', name: 'Root', children: [{ id: 'a', name: 'A' }] };
</script>
<template>
<ul><TreeNode :node="data" /></ul>
</template>
你可以这样解释:递归组件既是“整体”的一部分,也是“整体”的容器,用相同接口处理节点与子树,典型组合模式。
③ Strategy(策略:列表 diff / key 规则)
作用:对不同情形采用不同策略,以性能最佳地完成更新。
框架里怎么用:Diff “启发式”规则(类型不同直接替换、同类型浅比较、列表靠稳定key)。
React:错误 key(索引) vs 正确 key(业务 id)
// ❌ Anti-pattern: index as key – may cause wrong reuse on reorder
{items.map((item, index) => (
<TodoRow key={index} item={item} />
))}
// ✅ Good: stable business key
{items.map(item => (
<TodoRow key={item.id} item={item} />
))}
你可以这样解释:Diff 使用“策略”去近似最优,
key是列表策略的核心输入。稳定 key 才能复用正确节点,避免位置错乱与性能损失。
Vue:同理 —— v-for 必须用稳定 :key
<!-- ❌ index as key -->
<TodoRow v-for="(todo, i) in todos" :key="i" :todo="todo" />
<!-- ✅ stable key -->
<TodoRow v-for="todo in todos" :key="todo.id" :todo="todo" />
④ Template Method(模板方法:Hooks/Composables 的骨架)
作用:固定流程 + 可插点。框架定义“何时做什么”,用户只填“做什么”。
框架里怎么用:useEffect/useLayoutEffect的注册-执行-清理节奏;VueonMounted/onUnmounted/watch组合出固定骨架。
React:useFetch —— 固定骨架 + 可插入的请求地址
import { useEffect, useState } from 'react';
export function useFetch<T>(url: string) {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
let cancelled = false; // cleanup guard
setLoading(true);
fetch(url).then(r => r.json()).then(d => !cancelled && setData(d))
.finally(() => !cancelled && setLoading(false));
return () => { cancelled = true; }; // <-- cleanup step
}, [url]);
return { data, loading };
}
// Usage
const { data, loading } = useFetch<User[]>('/api/users');
你可以这样解释:Hook 规定了“挂载时发起请求、卸载时清理”的模板,调用方只提供
url,符合模板方法思想。
Vue:useFetch Composable
// useFetch.ts
import { ref, onMounted, onUnmounted, watch } from 'vue';
export function useFetch<T>(urlRef: { value: string }) {
const data = ref<T | null>(null);
const loading = ref(false);
let cancelled = false;
const run = async () => {
loading.value = true;
try {
const res = await fetch(urlRef.value);
const json = await res.json();
if (!cancelled) data.value = json;
} finally {
if (!cancelled) loading.value = false;
}
};
onMounted(run);
watch(urlRef, run);
onUnmounted(() => { cancelled = true; });
return { data, loading };
}
⑤ Dependency Injection(依赖注入:Context / Provide-Inject)
作用:跨层级传递依赖,降低耦合。
框架里怎么用:React 的 Context、Vue 的provide/inject。
React:Theme Context
// theme.tsx
import React, { createContext, useContext, useState } from 'react';
type Theme = 'light' | 'dark';
const ThemeCtx = createContext<{ theme: Theme; toggle: () => void } | null>(null);
export const useTheme = () => useContext(ThemeCtx)!;
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<Theme>('light');
const toggle = () => setTheme(t => (t === 'light' ? 'dark' : 'light'));
return <ThemeCtx.Provider value={{ theme, toggle }}>{children}</ThemeCtx.Provider>;
}
// usage
function Button() {
const { theme, toggle } = useTheme();
return <button onClick={toggle} className={theme}>Toggle Theme</button>;
}
你可以这样解释:把“主题”当依赖注入到子树里,消费方只关心接口,不关心来源。
Vue:provide / inject
<!-- ThemeProvider.vue -->
<script setup lang="ts">
import { ref, provide } from 'vue';
type Theme = 'light' | 'dark';
const theme = ref<Theme>('light');
const toggle = () => (theme.value = theme.value === 'light' ? 'dark' : 'light');
provide('theme', theme);
provide('toggle', toggle);
</script>
<template>
<slot />
</template>
<!-- Button.vue -->
<script setup lang="ts">
import { inject } from 'vue';
const theme = inject<'light' | 'dark'>('theme')!;
const toggle = inject<() => void>('toggle')!;
</script>
<template>
<button :class="theme" @click="toggle">Toggle Theme</button>
</template>
你可以这样解释:上游组件
provide依赖,下游inject即取,不必层层 props 传递,降低耦合。
加分项
A. React:让 UI 更“顺滑”的调度(Scheduler / startTransition)
import { useState, useMemo, startTransition } from 'react';
export default function SearchBox({ items }: { items: string[] }) {
const [q, setQ] = useState('');
const [list, setList] = useState<string[]>(items);
const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const next = e.target.value;
setQ(next); // urgent (keep input responsive)
startTransition(() => {
// non-urgent (filter can be heavy)
setList(items.filter(x => x.includes(next)));
});
};
const rendered = useMemo(() => list.map(x => <li key={x}>{x}</li>), [list]);
return (<>
<input value={q} onChange={onChange} placeholder="type to search..." />
<ul>{rendered}</ul>
</>);
}
要点:把“用户输入”与“重计算”分到不同优先级,保持输入顺滑。
B. Vue:computed 缓存派生值,避免重复重算
import { ref, computed } from 'vue';
const items = ref<string[]>([]);
const q = ref('');
// expensive derive but cached until deps change
const filtered = computed(() => items.value.filter(x => x.includes(q.value)));
要点:
computed只有在依赖变更时才重算,避免无谓开销。
面试速答清单(背下来就能“秒回”)
- 观察者:React 的
useSyncExternalStore/外部 store;Vue 的watchEffect读即订阅、写即触发。 - 组合:React 复合组件/ Fiber 树统一遍历;Vue 递归组件渲染树。
- 策略:Diff 启发式;列表必须用稳定
key。 - 模板方法:
useEffect/ Composable 的“固定流程 + 可插点”。 - 依赖注入:React Context、Vue
provide/inject让跨层解耦。 - 加分:React
startTransition/调度优先级;Vuecomputed缓存与响应式追踪。
参考与延伸(便于面试时引用)
- React:Reconciliation(调和)与
key规则(官方与社区) - React:Fiber / Scheduler / Concurrent 渲染(优先级与可中断)
- Vue:Reactivity In Depth(effect/dep 术语)、Vue 2 getter/setter 与 Vue 3 Proxy 的差异
- Vue:模板编译到渲染函数、虚拟 DOM + Patch 的实现思路