解构前端王牌:Vue × React 源码里的 5 大设计模式,为什么值得学?

117 阅读6分钟

解构前端王牌: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 的注册-执行-清理节奏;Vue onMounted/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/调度优先级;Vue computed 缓存与响应式追踪。

参考与延伸(便于面试时引用)

  • React:Reconciliation(调和)与 key 规则(官方与社区)
  • React:Fiber / Scheduler / Concurrent 渲染(优先级与可中断)
  • Vue:Reactivity In Depth(effect/dep 术语)、Vue 2 getter/setter 与 Vue 3 Proxy 的差异
  • Vue:模板编译到渲染函数、虚拟 DOM + Patch 的实现思路