Skip to content
前端开发中的设计模式
概述
设计模式是开发中反复出现的代码组织方式。在前端领域,它们体现在框架内部实现、业务封装和性能治理等不同层面。本书不追求完整覆盖经典的 23 种面向对象模式,只讨论在业务代码、框架源码和底层机制中高频出现的那些模式。
结构的编排没有按类型枚举,而是先独立梳理每种模式在前端中的具体形态及其在框架中的实现,再讨论多种模式在实际系统中的协同,最后通过横向比较和边界分析,说明不同模式在 JavaScript 环境中的适用情况。
模块模式
基本概念
模块模式的核心是封装:将数据和行为收拢在一个作用域内,只暴露有限的公开接口。在 JavaScript 中,模块化的演进路径是从手动创建私有作用域,到语言原生的静态模块系统。
从 IIFE 到 ES 模块
早期没有原生模块系统时,立即调用函数表达式是创建私有作用域的主要手段:
javascript
const UserModule = (function () {
const _users = [];
function _validate(user) {
return user.name && user.email;
}
return {
add(user) {
if (_validate(user)) {
_users.push(user);
return true;
}
return false;
},
getAll() {
return [..._users];
}
};
})();IIFE 返回的闭包里,_users 和 _validate 是私有成员,外部只能通过 add 和 getAll 访问。这种方式的局限性在于无法做静态分析——构建工具无法判断 IIFE 内部的哪些代码真正被引用,不利于 Tree Shaking。
ES Module 通过 export 显式声明模块的公开接口,import 在解析阶段即可确定依赖关系。打包工具可以遍历整个导入导出图,标记未被引用的导出,并在最终产物中剔除。Vite 的开发模式甚至直接利用浏览器原生 ES Module 加载,无需预先打包。
注意点
模块模式已逐步内化到语言标准和构建工具中,实际项目里几乎不再需要手工编写 IIFE 来模拟模块。
单例模式
基本概念
单例保证一个类只有一个实例,并提供全局访问点。最简单的实现是在构造函数里检查是否已存在实例:
javascript
class StoreManager {
static instance = null;
constructor() {
if (StoreManager.instance) return StoreManager.instance;
this.state = {};
StoreManager.instance = this;
}
}构造函数中的 if 检查确保多次 new StoreManager() 返回的是同一个实例。前端状态管理库(Vuex、Pinia、Redux)在应用层级通常表现为单例——整个应用维护一份共享的 Store。
服务端渲染中的状态隔离
在 Node.js 服务端渲染场景下,一个进程会处理多个请求。如果把 Store 挂载为模块级单例,不同请求之间可能串扰。例如请求 A 设置的某些用户相关状态,可能被请求 B 在重置之前读取到。
解决方式是按请求维度隔离状态:
javascript
const stores = new WeakMap();
function getStore(request) {
if (!stores.has(request)) {
stores.set(request, new StoreManager());
}
return stores.get(request);
}首次调用 getStore 时,WeakMap 中不存在对应条目,因此创建新实例并写入;后续同一请求再次调用则直接返回已缓存的实例。这里使用 WeakMap 而不是 Map,是为了让 request 对象被垃圾回收时,对应的 Store 也会自动释放,避免手动清理。
限制
微前端环境下(如 qiankun 或 Module Federation),每个子应用可能各自维护一个 Store 实例,全局单例的假设并不总是成立。
观察者模式与发布/订阅模式
基本概念
观察者模式中,主题直接持有观察者列表,并在自身状态变化时通知它们:
javascript
class Subject {
constructor() {
this.observers = [];
}
subscribe(o) { this.observers.push(o); }
unsubscribe(o) {
this.observers = this.observers.filter(x => x !== o);
}
notify(data) {
this.observers.forEach(o => o.update(data));
}
}subscribe 将观察者加入列表,unsubscribe 通过过滤移除,notify 遍历列表依次调用每个观察者的 update 方法。调用栈从 notify 直接进入观察者,链路清晰。
发布/订阅模式则引入了一个事件通道,发布者和订阅者之间不直接引用:
javascript
class EventBus {
constructor() {
this.events = {};
}
on(event, handler) {
(this.events[event] ||= []).push(handler);
}
off(event, handler) {
this.events[event] = this.events[event]?.filter(h => h !== handler);
}
emit(event, data) {
this.events[event]?.forEach(h => h(data));
}
}on 按事件名注册处理器,off 按引用移除,emit 触发时遍历该事件下的所有处理器。发布者调用 emit 时不需要知道订阅者是谁,订阅者调用 on 时也不需要知道发布者是谁。
差异与实际影响
Observer 的耦合度更高,但调用栈通常清晰,方便调试。Pub/Sub 完全解耦,但一次 emit 触发的执行链不易追踪:注册了哪些处理器、执行顺序是什么、某个处理器报错是否会阻断后续处理器,都需要额外处理。
在实际系统中,如果手动实现 Pub/Sub,需要注意内存管理。一个常见的问题是组件挂载时通过 on 注册事件,但卸载时忘记 off,导致 EventBus 持有对已卸载组件的闭包引用,使 DOM 节点和组件实例无法被垃圾回收。在长时间运行的页面里,这种情况会累积为明显的内存泄漏。
防御性手段包括:
- 利用
AbortController统一管理订阅生命周期。 - 在 React 的
useEffect清理函数或 Vue 的onUnmounted中进行取消订阅。 - 使用框架内置的响应式机制(如 Vue 的
watch、watchEffect,React 的状态提升或 Context)代替手动 EventBus,框架会在组件卸载时自动清理。
在前端框架中的表现
观察者模式在前端生态中广泛存在:
- DOM 事件:事件目标是 Subject,事件处理函数是 Observer。
- Vue 的响应式系统:响应式数据相当于 Subject,computed 和 watch 回调相当于 Observer。内部通过
track收集依赖,trigger通知更新。 - RxJS 的 Observable:基于推模型的观察者扩展,增加了操作符链。
- Redux 的
store.subscribe:允许添加监听函数,在每次状态变化后执行。
工厂模式
在框架 API 中的体现
工厂模式用于创建对象,但与构造函数不同的是,它可以返回不同类型的对象,或者在返回之前进行额外的初始化。
React.createElement(type, props, children) 是一个典型的工厂函数——根据 type 的不同,创建字符串、函数组件或 Class 组件对应的 VNode。Axios 的 axios.create(config) 也通过工厂生成带有默认配置的请求实例。
在 Vue 3 中,createApp、createRouter、createStore 都采用工厂函数而非 new。原因是工厂可以隐藏具体实现,且可以在返回实例之前完成内部初始化。
示例:动态表单组件
一种常见的需求是根据服务端返回的字段类型动态渲染对应的表单控件:
jsx
function FormFieldFactory({ type, ...props }) {
const map = {
text: <Input {...props} />,
select: <Select {...props} />,
date: <DatePicker {...props} />
};
return map[type] || null;
}map 对象在组件每次渲染时都会重新创建,但由于其内容仅依赖固定的组件类型,实际开销可以忽略。需要关注的是以下问题:
- 组件 key 的稳定性:如果
type在运行时切换,React 会销毁旧组件并创建新组件,导致内部状态丢失。如果希望保持状态,需要保持 key 不变或使用其他切换方式。 - props 透传的边界:
{...props}会透传所有属性,如果某些属性只对特定控件有意义,透传可能导致 React 的未知属性警告。建议在工厂内部区分公共属性和特定属性。 - 按需加载与 Tree Shaking:如果
map对象在定义时就引用所有控件组件,打包工具会认为它们都被依赖,无法剔除。需要按需加载的场景可以配合React.lazy或动态import()实现。
装饰器模式的前端形态
React 高阶组件
HOC 是 React 早期逻辑复用的主要方式,本质上是一个接收组件并返回新组件的装饰器函数:
jsx
function withAuth(Component) {
return function AuthComponent(props) {
const { user } = useAuth();
if (!user) return <LoginPage />;
return <Component {...props} user={user} />;
};
}withAuth 返回的新组件在渲染时会先检查用户状态,未登录则渲染登录页,已登录则将 user 注入原组件。HOC 组合多个装饰时会产生嵌套地狱,例如 withA(withB(withC(Component))),DevTools 中会看到层层包裹的匿名组件。此外,不同 HOC 注入的同名 props 可能导致冲突。
Render Props(过渡形态)
Render Props 通过一个值为函数的 prop 来共享逻辑,部分解决了 HOC 的嵌套问题,但也产生了新的回调嵌套。随着 React Hooks 的推出,Render Props 的使用量明显下降。
Vue 3 Composables
Vue 3 的 Composables 函数让逻辑复用回归到纯函数组合:
javascript
function usePagination(fetchFn) {
const page = ref(1);
const data = ref([]);
const loading = ref(false);
async function loadPage(p) {
loading.value = true;
data.value = await fetchFn(p);
loading.value = false;
}
return { page, data, loading, loadPage };
}调用 usePagination 会返回一组响应式引用,组件的模板或 watch 可以直接使用。Composables 与 React Hooks 的共同思路是用函数组合代替嵌套装饰。Vue 基于 Proxy 的响应式系统让 Composables 不需要处理闭包陷阱,在异步场景下的行为更容易预测。
演进路径对比
- React 的逻辑复用:HOC → Render Props → Custom Hooks
- Vue 的逻辑复用:Mixins → Composables
它们的共同趋势是从嵌套结构转向显式导入的函数组合。Mixins 的消亡有其原因:命名冲突、来源不透明、this 上的属性难以分辨是 data 还是 mixin 注入的。Composables 通过显式 import 和结构解构解决了这些问题。
策略模式
基本概念
策略模式将一组可互换的算法封装为独立策略,使客户端可以在运行时选择。在前端里最直接的应用场景是表单验证:
javascript
const validators = {
required: v => !!v || '必填',
email: v =>
/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v) || '邮箱格式错误',
minLength: len => v => v.length >= len || `最少${len}个字符`
};
function validate(value, rules) {
for (const rule of rules) {
const [name, ...args] = Array.isArray(rule) ? rule : [rule];
const result = validators[name](...args)(value);
if (result !== true) return result;
}
return true;
}validate 遍历规则数组,对每条规则从 validators 中取出对应函数并执行。添加新规则时不需要修改 validate 函数,符合开闭原则。实际项目中经常需要支持异步验证(如"用户名是否已存在"),此时策略函数的签名需统一为 (value) => Promise<string | true>。
应用
- React 的 Lane 优先级调度、Vue 的 异步更新队列调度都隐含着策略模式:不同优先级的更新任务采用不同的调度策略。
- 支付场景下,微信支付、支付宝、银行卡各自封装为策略,通过统一的支付接口调用。
代理模式与 Proxy
语言级代理
ES6 的 Proxy 让代理模式成为 JavaScript 的语言特性。Vue 3 的响应式系统完全基于 Proxy 实现:
javascript
function reactive(target) {
return new Proxy(target, {
get(obj, key, receiver) {
track(obj, key);
const result = Reflect.get(obj, key, receiver);
if (typeof result === 'object' && result !== null) {
return reactive(result);
}
return result;
},
set(obj, key, value, receiver) {
const oldValue = obj[key];
const result = Reflect.set(obj, key, value, receiver);
if (oldValue !== value) {
trigger(obj, key);
}
return result;
}
});
}get 陷阱中调用 track 收集依赖,然后通过 Reflect.get 获取原始值;如果值是对象,递归调用 reactive 实现懒代理。set 陷阱中先通过 Reflect.set 写入原始值,再比较新旧值,仅在变化时调用 trigger 派发更新。
Vue 3 响应式系统的核心模块
@vue/reactivity 包将响应式拆分为几个核心部件:
- ReactiveEffect:依赖收集的执行单元。
run()方法在执行传入函数前将自己设为全局activeEffect,执行期间触发get时调用track建立依赖关系;执行后恢复上一个 effect,形成栈式追踪。scheduler字段允许调度器控制 effect 的执行方式,Vue 的异步更新队列就是通过 scheduler 将组件更新合并到微任务中的。 - track / trigger:使用
WeakMap<target, Map<key, Set<ReactiveEffect>>>构建依赖图。track在get陷阱中调用,将当前activeEffect添加到对应 key 的依赖集合,并建立反向引用。trigger在set陷阱中调用,找到该 key 对应的依赖集合并调度执行。遍历前会复制一份 Set,防止清理和新增导致的迭代异常。 - 懒代理:嵌套对象仅在访问时被转换为响应式,而不是在创建时递归处理整个对象树。对于初始化包含大量数据的对象,这比 Vue 2 的
Object.defineProperty递归劫持方式快得多。
其他落点
- Immer:利用 Proxy 追踪对草稿对象的修改,在操作结束时生成一个新的不可变对象。
- 全局 fetch 拦截:通过
new Proxy(fetch, { apply })代理fetch函数,实现请求去重、超时控制或 mock 数据注入。apply陷阱拦截的是函数调用,而非对象属性。 - SolidJS 的 Signal:以 Proxy 驱动的细粒度响应式,没有虚拟 DOM,变化时直接更新对应的 DOM 节点。
适配器模式
基本概念
适配器的核心价值是将第三方接口转换为业务代码期望的统一格式,从而把外部变更的影响限制在适配层内部。
示例:统一地图 SDK 接口
javascript
class MapAdapter {
constructor(lib = 'amap') {
this.lib = lib;
}
async search(keyword) {
const raw = this.lib === 'amap'
? await AMap.search({ keywords: keyword })
: await BaiduMap.searchPoi(keyword);
return raw.map(r => ({
name: r.name || r.title,
lng: r.location?.lng || r.point?.lng,
lat: r.location?.lat || r.point?.lat
}));
}
}构造函数接收底层 SDK 的标识,search 方法根据标识调用不同 SDK 的接口,然后将返回的原始数据统一映射为 { name, lng, lat } 格式。无论底层 SDK 如何升级字段名或方法签名,只要适配器接口不变,业务代码就不受影响。适配器也是测试时的天然切入点——可以替换掉真实的 SDK 调用,注入 mock 数据,避免完整模拟第三方库。
应用
适配器同样适用于后端接口的协议适配、多端(Web、小程序、Electron)的统一 API 封装等场景。
状态机模式
基本概念
React 的 useState 适合简单状态,useReducer 可以覆盖较复杂的状态转换。但某些交互(如请求生命周期:idle → loading → success/error)中的非法状态转换需要通过显式的状态机来杜绝。
一个简单的状态机骨架:
javascript
class RequestState {
constructor() {
this.states = {
idle: { canStart: true },
loading: { canCancel: true },
success: { canRetry: true },
error: { canRetry: true }
};
this.current = 'idle';
}
transition(state, data) {
if (!this.states[state]) throw new Error(`非法状态: ${state}`);
this.current = state;
this.states[state].onEnter?.(data);
}
}states 对象定义了每个状态允许的能力,transition 先检查目标状态是否存在,再切换当前状态并执行进入回调。
更完备的做法是使用有限状态机,明确每个状态允许的转换路径。XState 是这一方案中较为完整的库,但引入成本较高。在轻量场景下,可以通过 TypeScript 的可辨识联合类型结合 useReducer 确保类型层面的状态安全:
typescript
type State =
| { status: 'idle' }
| { status: 'loading'; cancel: () => void }
| { status: 'success'; data: Response }
| { status: 'error'; error: Error };
function reducer(state: State, action: Action): State {
switch (state.status) {
case 'idle':
if (action.type === 'FETCH') return { status: 'loading', cancel: action.cancel };
break;
case 'loading':
if (action.type === 'SUCCESS') return { status: 'success', data: action.data };
if (action.type === 'ERROR') return { status: 'error', error: action.error };
break;
}
return state;
}switch 语句先判断当前 state.status,再根据 action 类型决定下一个状态。TypeScript 会根据 state.status 收窄 state 的类型,非法状态转换在编译阶段就会被拦截。
命令模式与历史记录
基本概念
命令模式将每一个操作封装为命令对象,其中包含执行和撤销方法。统一的历史管理器负责维护命令队列:
javascript
class CommandHistory {
constructor() {
this.history = [];
this.index = -1;
}
execute(command) {
command.execute();
this.history = this.history.slice(0, this.index + 1);
this.history.push(command);
this.index++;
}
undo() {
if (this.index < 0) return;
this.history[this.index].undo();
this.index--;
}
redo() {
if (this.index >= this.history.length - 1) return;
this.index++;
this.history[this.index].execute();
}
}slice(0, this.index + 1) 确保在执行若干次 undo 之后如果执行了新的命令,被回退掉的命令将无法再 redo,这是大多数编辑器的标准行为。
在编辑器中的应用
富文本编辑器(如 Slate.js)将用户的每一次操作都编码为一个 Operation 对象,例如 insert_text、remove_node、set_selection。这些 Operation 构成完整的编辑历史,使协同编辑(基于 OT 或 CRDT 的冲突处理)得以建立在"同步操作序列"的基础之上。
命令模式天然适合生成操作日志:每个命令携带足够的上下文信息,可用于审计、回放和自动化测试的录制。
责任链模式
中间件模型
责任链在前端的经典实现是中间件模式。Koa 的 compose 函数将多个中间件组合成洋葱模型:
javascript
function compose(middlewares) {
return function (ctx) {
let index = -1;
function dispatch(i) {
if (i <= index) return Promise.reject(new Error('next() called multiple times'));
index = i;
const fn = middlewares[i];
if (!fn) return Promise.resolve();
try {
return Promise.resolve(fn(ctx, () => dispatch(i + 1)));
} catch (err) {
return Promise.reject(err);
}
}
return dispatch(0);
};
}关键点:
i <= index检测阻止了在同一个中间件里多次调用next()。index记录当前正在执行的中间件位置,再次调用dispatch时如果传入的i不大于index,说明同一个next()被调用了多次。- 通过
Promise.resolve包裹返回值,保证无论中间件返回同步值还是 Promise,行为一致。 - 上游中间件
await next()后可以获取下游的执行结果,形成"请求前 → 下一个中间件 → 响应后"的洋葱圈。
Redux 的同步管道
Redux middleware 的装配使用 applyMiddleware,其核心是管道模型:
javascript
function applyMiddleware(...middlewares) {
return (createStore) => (reducer, preloadedState) => {
const store = createStore(reducer, preloadedState);
let dispatch = store.dispatch;
const middlewareAPI = {
getState: store.getState,
dispatch: (action) => dispatch(action)
};
const chain = middlewares.map(m => m(middlewareAPI));
dispatch = compose(...chain)(store.dispatch);
return { ...store, dispatch };
};
}middlewareAPI.dispatch 通过闭包引用外部变量 dispatch,在装配完成后自动指向增强后的 dispatch,保证中间件内部触发的 action 也能经过完整链。
Redux middleware 的结构为三层嵌套函数:(store) => (next) => (action) => {},典型的 logger 中间件:
javascript
const logger = (store) => (next) => (action) => {
console.log('before', action);
next(action);
console.log('after', store.getState());
};Redux 与 Koa 的关键差异:
| 维度 | Koa compose | Redux applyMiddleware |
|---|---|---|
| 执行方向 | 洋葱模型(in → next → out) | 单向管道(A → B → C) |
| 异步支持 | Promise 驱动,天然异步 | 同步链,异步 action 需要中间件拦截 |
| next 多次调用 | 检测并抛错 | 不检测,可以多次调用 |
| 返回值 | Promise<Response> | 无返回值(dispatch 返回 action 本身) |
依赖注入
基本概念
依赖注入在前端的应用不及服务端普遍,但 Angular 将 DI 作为框架的一等公民来设计。一个简化版的 DI 容器:
javascript
class Container {
constructor() {
this.services = new Map();
}
register(key, factory) {
this.services.set(key, factory);
}
resolve(key) {
const factory = this.services.get(key);
if (!factory) throw new Error(`Service ${key} not registered`);
return factory();
}
}这只是一个概念示例。成熟的 DI 容器还需要处理:
- 作用域:单例、瞬态、请求级等不同生命周期。
- 循环依赖检测:在依赖图构建阶段发现循环并提前报错,防止运行时栈溢出。
- 懒加载:在第一次
resolve时才实际创建实例。
即使不使用 Angular,DI 的思路对于解耦基础设施(API 客户端、Logger、Analytics 等)与业务模块依然有效——通过依赖注入,测试时可以方便地替换为 mock 实现。Vue 3 的 provide/inject 和 React Context 都可以视为组件树级别的 DI 机制。需要留意 provide/inject 默认是非响应式的;如果注入的值是 ref,模板中仍需要显式 .value。
前端架构的演进:MVC、MVVM 与 Flux
MVC 在前端遇到的问题
经典的 MVC 模式中,Controller 处理用户输入,操作 Model,Model 再通知 View 更新。但在前端,View 和 Model 之间天然存在双向交互:用户操作 View 修改 Model,Model 变化又需要更新 View。Controller 很容易变成笨重的协调层,负责在两者之间同步状态。
MVVM 与数据绑定
Vue 是典型的 MVVM 实现:
- Model:响应式数据(
data()、ref()) - View:模板
- ViewModel:
computed、methods、watch
核心是数据绑定自动化——开发者不需要手动操作 DOM 和事件监听,响应式系统自动完成 View 和 ViewModel 之间的同步。
Flux 的单向数据流
Redux 带来了单向数据流架构:
Action → Dispatcher → Store → View优点是极强的可预测性:任何时刻的 UI 状态都可以由"初始状态 + Action 序列"完整还原,这也是 Redux DevTools 时间旅行调试的基础。代价则是模版代码量较大,每个状态变更都必须通过 Action → Reducer → Store → 重新渲染的完整链路。
当前趋势
近年来状态管理方案进一步分化:
- 原子状态(Jotai、Recoil):状态分散在原子单元,组件按需订阅,不依赖全局 Store。
- Signals(SolidJS、Preact Signals):细粒度响应式,绕过虚拟 DOM,直接更新 DOM。
- 服务端/客户端状态分离(React Query、SWR):将接口请求的状态(加载、缓存、刷新)独立管理,不再塞进全局 Store。
不同的状态类型往往需要不同的管理方式,并不存在一套方案适用于所有场景。
模式组合:实际系统中的协同
多个模式在实际系统中往往同时存在并互相协作。下面以两个系统为例展示模式的层次关系。
富文本编辑器
一个实际运行的编辑器(Slate.js、Quill 等)至少同时运行以下模式:
| 模式 | 在编辑器中的角色 |
|---|---|
| Command | 每个编辑操作编码为 Operation 对象,支撑 undo/redo |
| Observer | 文档变更通知视图、光标、协作层 |
| State | 编辑态/只读态/预览态的状态转换 |
| Strategy | 不同节点类型(段落、标题、列表)的渲染和序列化策略 |
| Composite | 文档是嵌套的节点树(段落包含文本,列表包含列表项) |
| Decorator | 插件系统扩展行为(如 @mention、#hashtag) |
这些模式并非并列关系:
- 键盘输入被编码为
insert_text命令(Command),执行后修改文档数据; - 文档数据变更通过 Observer 通知视图更新、协作层同步和历史记录;
- 编辑器状态变化(如进入只读模式)通过 State 模式通知装饰器插件,例如禁用 @mention 的弹出框。
低代码搭建引擎
低代码平台的核心搭建引擎同样是模式的密集区:
- Factory:根据组件的 JSON Schema 创建不同属性的编辑器。
- Proxy:属性面板修改时,通过 Proxy 拦截实现实时预览。
- Command:添加、删除、移动等操作封装为命令,支持 undo/redo。
- Chain of Responsibility:组件生命周期钩子(
beforeAdd、afterDelete)形成责任链,允许插件拦截和修改行为。 - Adapter:同一份页面 Schema 输出 PC、H5、小程序等不同平台的代码。
协作时的约束
多模式协同带来的主要挑战是执行流不透明。为避免无序耦合,通常会遵循以下原则:
- 单向依赖:上层模式依赖下层,避免反向依赖(如 Observer 不应直接依赖 Command 的存在)。
- 事件溯源:所有状态变更都来源于 Command,即使 Observer 有能力直接修改数据,也必须走 Command 通道,保证 undo/redo 的完整性。
- 生命周期钩子:在模式交叉点暴露生命周期事件(Command 执行前/后、State 转换前/后),让其他模式通过钩子介入,而不是直接调用。
组合模式与享元模式
组合模式
组合模式在渲染层几乎无处不在:DOM 树、React/Vue 组件树、抽象语法树都是树状结构,通过统一的接口处理单个节点与组合节点。它已融入框架底层,很少需要手动实现。
享元模式
享元模式在渲染层的价值更高,主要体现为复用对象以降低内存或 DOM 开销。
虚拟列表是其经典应用:10 万条数据只渲染屏幕上可见的少量 DOM 节点。数据项的多条记录与 DOM 元素分离,通过 scrollTop 与 itemHeight 计算当前需要渲染哪些数据,然后挂载到复用的 DOM 节点上。
其他形式还包括:
- 对象池:高频创建/销毁的对象(如 WebGL 粒子系统、Canvas 渲染对象),通过复用池避免频繁触发垃圾回收。
- CSS 类复用:
.text-red-500这类原子类在浏览器内可以被缓存为规则,而style="color: red"则是每个元素独立存储,前者内存占用更小。
模式使用的常见问题
这些模式如果在错误场景下过度使用,反而会成为负担。下面是几个被误用时出现频率较高的问题。
EventBus 内存泄漏
手动实现的 EventBus 如果不在组件卸载时调用 off,会导致已卸载的组件实例和关联的 DOM 节点无法被垃圾回收。长时间运行的页面(如后台管理系统)会因此出现持续的内存增长,最终导致标签页崩溃。
预防措施:
- 始终在组件卸载的阶段调用解除订阅。
- 优先使用框架内置的响应式机制(Vue 的
watch/watchEffect,React 的 Context 或状态提升),它们会在组件卸载时自动清理。
SSR 单例状态泄漏
在 Node.js 服务端渲染中,如果 Store 是模块级单例并且没有按照请求隔离,不同请求之间可能会互相干扰。表现形式包括用户 A 看到了用户 B 的私有数据,或者购物车/地址信息错乱。
解决方式是通过请求上下文(如框架的 SSR 插件)创建独立 Store 实例,并在响应结束后销毁。核心机制与前文单例模式中 WeakMap<Request, Store> 隔离一致。
响应式系统导致的渲染风暴
当大量数据更新在短时间涌入时,Vue 3 的 Proxy 响应式可能会触发大量组件重新渲染。虽然 Vue 对组件更新 effect 的调度做了合并(通过微任务队列),但手动创建的 watch(尤其是 flush: 'sync')会在每次变更时同步执行,导致渲染卡顿。
应对策略:
- 将相关更新放在同一个同步块中,利用组件的异步调度合并更新。
- 对高频更新的数据使用
shallowRef或shallowReactive降低响应式深度。 - 对大数据列表仅监听顶层引用替换,而不监听内部属性变更。
权衡与边界
模式在 JavaScript 环境中的现实处境
| 模式 | 前端现状 | 原因 |
|---|---|---|
| Singleton | 框架内置(Store) | 全局状态本身是单例需求 |
| Observer | 框架内置(响应式系统) | 数据驱动视图是现代前端的基石 |
| Proxy | 成为语言特性 | Proxy 已是 JavaScript 语法 |
| Factory | 融入框架 API | createApp、createElement |
| Strategy | 散落在业务代码 | 框架不强制,场景分散 |
| Chain of Resp. | 融入中间件体系 | Redux/Vue Router/Axios 拦截器 |
| Command | 编辑器/协作场景手动实现 | 大多数应用不需要 undo/redo |
| DI | Angular 内置,其他框架通过 Context/Provide 模拟 | 组件树天然适合层级 DI |
| Decorator | Hooks/Composables 替代传统实现 | 函数组合比嵌套更符合 JS 习惯 |
| Adapter | 需要手动编写 | 第三方 SDK 封装、多端适配 |
| State | useReducer / XState | 复杂交互的刚性需求 |
| Flyweight | 虚拟列表 | 长列表渲染的标配方案 |
与渲染层和数据流相关的模式(Observer、Proxy、Composite、Flyweight)多数已被框架吸收;与业务组织相关的模式(Strategy、Adapter、Command、State)仍需要在具体场景中自行判断是否引入。
引入模式的隐性成本
每个模式都伴随权衡:
| 模式 | 收益 | 代价 | 过度使用的信号 |
|---|---|---|---|
| Observer | 解耦数据生产与消费 | 调用栈断裂,调试困难 | 一次 emit 触发大量 handler,执行链不可见 |
| Command | undo/redo 能力 | 每个操作需序列化上下文,内存随操作数线性增长 | 为所有操作封装命令,但实际根本不使用 undo |
| Strategy | 消除 if/else | 策略数量膨胀,运行时查找不如 switch 直接 | 两个策略的实现差异只有一行代码 |
| EventBus | 跨组件通信 | 事件名为字符串,无类型检查,重构易遗漏 | 项目中出现全局 bus.emit 而难以追踪谁响应 |
| DI | 方便测试替换 | 依赖关系转移到容器配置,追踪链路变长 | 为"可能替换"而注入所有依赖 |
| HOC | 逻辑复用 | 嵌套地狱、props 冲突、DevTools 不可读 | 一个组件被三个以上 HOC 包裹 |
在架构决策中,应优先识别框架已经解决的问题(例如 Vue 的响应式已是 Observer + Proxy + Scheduler 的组合),优先使用语言自身的能力(如高阶函数、闭包、Proxy),并将构建时的产物特性(Tree Shaking、Code Splitting)也纳入设计考量。两个代码片段看起来相似并不意味着必须立即抽象,只有在确认它们会因相同原因发生变化时,合并才有意义。
常见问题
Vue 3 的响应式系统中,
track和trigger各在哪个 Proxy 陷阱中调用?targetMap为什么外层选用 WeakMap?track在get陷阱中调用,trigger在set陷阱中调用。targetMap外层用 WeakMap 是为了让被代理的 target 在其他地方不再被引用时可以被垃圾回收,避免依赖图本身造成内存泄漏。EventBus 和 Vue 的
provide/inject都能跨组件通信,在什么情况下必须用 EventBus 而不能用 provide/inject?provide/inject遵循组件树层级,只能从祖先传到后代,无法在兄弟组件间直接通信。如果两个组件在组件树中没有直接或间接的祖先-后代关系,或者需要解耦到完全不知道对方存在的程度,EventBus(或全局状态管理)会更合适。但应注意内存管理和类型安全。Koa 的洋葱模型和 Redux 的管道模型,如果要把 Redux 的同步管道改成类似 Koa 的异步洋葱模型,会破坏什么?
Redux 的同步管道保证了每个dispatch调用完成后 Store 的状态是确定的。改为异步洋葱模型后,next()会返回 Promise,中间件需要await next()才能继续执行。这会破坏 Redux 现有的许多假设:action 按顺序处理且每次 dispatch 后状态立即更新。此外,需要处理异步中间件中状态可能被并发修改的问题,这与 Redux 的单向、同步执行理念相悖。一个富文本编辑器同时用了 Command、Observer、Composite 三个模式。如果用户输入一个字符,画出从键盘事件到 DOM 更新的完整执行流。
键盘事件被捕获后,由责任链处理快捷键判断,最终进入文本插入逻辑:- 创建一个
insert_textCommand,包含位置和字符内容。 - Command 执行时修改文档的 Composite 树(在对应文本节点插入字符)。
- 文档数据变更通过 Observer 通知:视图层更新对应的 DOM 节点;历史记录将该 Command 保存到历史栈;协作层将操作序列化发送给其他用户。
- 创建一个
DI 容器的循环依赖检测怎么做?写出一个 O(V+E) 的检测算法。
在注册依赖时构建依赖图,使用拓扑排序检测环。如果某个 class A 依赖 class B,B 又直接或间接依赖 A,拓扑排序将无法完成,即可检测出循环依赖。深度优先搜索配合三色标记可以在 O(V+E) 时间内完成。虚拟列表(享元模式)中,
scrollTop变化导致的 DOM 复用和浏览器的 layout/paint 之间如何配合才不会出现白屏和抖动?
通常通过requestAnimationFrame节流滚动事件,在合适的时间点更新 DOM 并保证在scroll事件处理期间先做数据计算和虚拟节点分配,再一次性更新真实 DOM。同时要预留缓冲区(上下额外渲染几项),避免快速滚动时出现短暂空白。HOC 和 Custom Hook 都能做逻辑复用,什么时候 HOC 是比 Hook 更好的选择?给出一个具体的实例。
当需要复用的逻辑必须包裹整个组件,并且可能需要在渲染阶段之前拦截或修改注入的 props 时,HOC 更直接。例如一个全局的错误边界 HOC(withErrorBoundary)需要捕获组件树中的所有异常,这类包裹型逻辑用 HOC 实现比 Hook 更自然,因为 Hook 无法控制组件树的结构。一个 SSR 应用的单例 Store 泄漏了用户 A 的状态给用户 B,你会怎么在排查初期用 Chrome DevTools 和 Node.js 日志快速定位到是单例问题而不是 cookie/session 问题?
首先检查 cookie/session 是否一致:在客户端和服务端日志中打印请求 ID 和用户标识,如果用户标识已经不同但返回的数据依然相同,说明不是会话识别错误。然后在 Node.js 端为每个请求的 Store 实例添加唯一标识并记录日志。如果连续两个不同请求的日志显示使用了相同的 Store 实例标识,即可确认是单例泄漏。此外可以通过 Chrome DevTools 的 Network 面板检查响应内容,发现包含其他用户的特有数据,结合服务端日志可快速定位。
参考链接
- ECMAScript 6 入门教程(阮一峰)
- Vue 3 官方文档 - 响应式基础
- Redux - applyMiddleware 源码
- Koa - compose 源码
- Slate.js - Operation 模型
- RxJS 官方文档
- XState 文档
- Design Patterns: Elements of Reusable Object-Oriented Software (GoF)
