前端设计模式实战 —— 发布订阅、中间件、插件架构、依赖注入
经典设计模式诞生于 C++/Java 时代,而前端生态有自己独特的需求和约束。这一章我们不再逐一讲解经典模式,而是聚焦前端开发中最常见的四种架构级模式,看看它们如何在 React/Vue/Node.js 等真实场景中落地。
📋 开篇自测:你已经知道多少?
- “发布-订阅”和”观察者模式”有什么区别?Node.js 的 EventEmitter 属于哪一种?
- Express 的中间件为什么要调用
next()?不调用会怎样?- 你能解释 Angular 的”依赖注入”在解决什么问题吗?
一、发布-订阅模式:前端通信的基石
1.1 观察者 vs 发布-订阅:一字之差,天壤之别
上一章讲的观察者模式中,Subject(被观察者)和 Observer(观察者)是直接引用的关系——Subject 内部维护了一个 Observer 列表,直接调用 Observer 的方法。
发布-订阅模式在二者之间插入了一个事件总线(Event Bus / Event Channel)。发布者不知道谁在订阅,订阅者不知道谁在发布,它们通过”事件名”这根纽带联系在一起。
观察者模式: Subject ──直接通知──> Observer
发布-订阅: Publisher ──> EventBus ──> Subscriber
这个区别在大型前端应用中至关重要——它使得完全不相关的模块可以通信,而无需持有对方的引用。
1.2 实现一个类型安全的 EventBus
// 事件类型定义:用 TypeScript 的映射类型确保类型安全
interface AppEvents {
'user:login': { userId: string; timestamp: number };
'user:logout': { userId: string };
'cart:add': { productId: string; quantity: number };
'cart:remove': { productId: string };
'order:created': { orderId: string; total: number };
'theme:change': { theme: 'light' | 'dark' };
}
type EventHandler<T> = (payload: T) => void;
class TypedEventBus<Events extends Record<string, any>> {
private handlers = new Map<keyof Events, Set<EventHandler<any>>>();
on<K extends keyof Events>(event: K, handler: EventHandler<Events[K]>): () => void {
if (!this.handlers.has(event)) {
this.handlers.set(event, new Set());
}
this.handlers.get(event)!.add(handler);
// 返回取消订阅函数
return () => {
this.handlers.get(event)?.delete(handler);
};
}
once<K extends keyof Events>(event: K, handler: EventHandler<Events[K]>): () => void {
const wrappedHandler: EventHandler<Events[K]> = (payload) => {
handler(payload);
this.handlers.get(event)?.delete(wrappedHandler);
};
return this.on(event, wrappedHandler);
}
emit<K extends keyof Events>(event: K, payload: Events[K]): void {
const eventHandlers = this.handlers.get(event);
if (eventHandlers) {
for (const handler of eventHandlers) {
try {
handler(payload);
} catch (error) {
console.error(`事件处理器出错 [${String(event)}]:`, error);
}
}
}
}
off<K extends keyof Events>(event: K, handler?: EventHandler<Events[K]>): void {
if (handler) {
this.handlers.get(event)?.delete(handler);
} else {
this.handlers.delete(event); // 移除该事件的所有处理器
}
}
// 调试用:查看当前注册的事件
listEvents(): Array<{ event: string; handlerCount: number }> {
const result: Array<{ event: string; handlerCount: number }> = [];
this.handlers.forEach((handlers, event) => {
result.push({ event: String(event), handlerCount: handlers.size });
});
return result;
}
}
使用——完全类型安全:
const bus = new TypedEventBus<AppEvents>();
// TypeScript 会自动推断 payload 的类型
bus.on('user:login', (payload) => {
console.log(`用户 ${payload.userId} 登录于 ${new Date(payload.timestamp)}`);
// payload.userId: string ✓
// payload.foo // TypeScript 报错:不存在 foo 属性
});
bus.on('cart:add', (payload) => {
console.log(`添加商品 ${payload.productId},数量 ${payload.quantity}`);
});
// 发布事件
bus.emit('user:login', { userId: 'u-001', timestamp: Date.now() });
// 类型不匹配会在编译时报错
// bus.emit('user:login', { userId: 123 }); // Error: userId 应该是 string
1.3 防止内存泄漏:自动清理
前端应用中,发布-订阅最大的隐患是内存泄漏——组件销毁了但事件监听器还在。一个好的实践是利用返回的取消函数:
// React 组件中的使用模式
function useAppEvent<K extends keyof AppEvents>(
event: K,
handler: EventHandler<AppEvents[K]>
): void {
useEffect(() => {
const unsubscribe = bus.on(event, handler);
return unsubscribe; // 组件卸载时自动取消订阅
}, [event, handler]);
}
🤔 想一想 Vue 3 移除了
$on、$off、$once实例方法(不再建议把组件实例当 EventBus 用,$emit仍保留用于子组件向父组件触发事件)。为什么 Vue 做了这个决定?官方推荐的替代方案是使用外部的轻量级事件库,如 mitt 或 tiny-emitter;对于更复杂的跨组件通信场景,则建议使用 Pinia 等状态管理方案。
二、中间件模式:洋葱圈里的请求处理
2.1 中间件的本质
中间件模式在 Node.js 后端框架(Express、Koa)和前端状态管理(Redux)中无处不在。它的本质是:将请求的处理过程分解为多个独立的处理环节,每个环节可以选择继续传递或终止处理。
这和责任链模式有血缘关系,但中间件模式有一个独特特点:每个中间件可以在”请求进入”和”响应返回”两个阶段分别执行逻辑——这就是著名的”洋葱圈模型”。
2.2 实现一个通用中间件引擎
type MiddlewareContext = {
request: {
path: string;
method: string;
headers: Record<string, string>;
body?: any;
};
response: {
status: number;
headers: Record<string, string>;
body?: any;
};
state: Record<string, any>; // 中间件之间传递数据
};
type NextFunction = () => Promise<void>;
type Middleware = (ctx: MiddlewareContext, next: NextFunction) => Promise<void>;
class MiddlewareEngine {
private middlewares: Middleware[] = [];
use(middleware: Middleware): this {
this.middlewares.push(middleware);
return this;
}
async execute(ctx: MiddlewareContext): Promise<void> {
let lastIndex = -1;
const dispatch = async (i: number): Promise<void> => {
if (i <= lastIndex) {
throw new Error('next() 不能被多次调用');
}
lastIndex = i;
if (i >= this.middlewares.length) return;
await this.middlewares[i](ctx, () => dispatch(i + 1));
};
await dispatch(0);
}
}
编写各种中间件:
// 中间件 1:请求日志(洋葱最外层)
const loggerMiddleware: Middleware = async (ctx, next) => {
const start = Date.now();
console.log(`→ ${ctx.request.method} ${ctx.request.path}`);
await next(); // 先让内层中间件处理完
const duration = Date.now() - start;
console.log(`← ${ctx.response.status} ${ctx.request.path} [${duration}ms]`);
};
// 中间件 2:身份认证
const authMiddleware: Middleware = async (ctx, next) => {
const token = ctx.request.headers['authorization'];
if (!token) {
ctx.response.status = 401;
ctx.response.body = { error: '未提供认证信息' };
return; // 不调用 next(),终止后续处理
}
// 模拟 token 解析
ctx.state.userId = 'user-from-token';
await next();
};
// 中间件 3:响应时间头
const timingMiddleware: Middleware = async (ctx, next) => {
const start = Date.now();
await next();
ctx.response.headers['X-Response-Time'] = `${Date.now() - start}ms`;
};
// 中间件 4:业务逻辑
const businessMiddleware: Middleware = async (ctx, next) => {
if (ctx.request.path === '/api/users' && ctx.request.method === 'GET') {
ctx.response.status = 200;
ctx.response.body = [
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' },
];
}
await next();
};
组合使用:
const app = new MiddlewareEngine();
app.use(loggerMiddleware);
app.use(timingMiddleware);
app.use(authMiddleware);
app.use(businessMiddleware);
// 模拟请求
const ctx: MiddlewareContext = {
request: { path: '/api/users', method: 'GET', headers: { authorization: 'Bearer xxx' } },
response: { status: 200, headers: {} },
state: {},
};
await app.execute(ctx);
// → GET /api/users
// ← 200 /api/users [3ms]
console.log(ctx.response.headers['X-Response-Time']); // '3ms'
执行顺序(洋葱圈):logger进 → timing进 → auth进 → business进 → business出 → auth出 → timing出 → logger出。
三、插件架构:让应用可扩展
3.1 什么是插件架构
Webpack、Vite、VS Code、Figma——这些工具的成功都离不开它们的插件生态。插件架构的核心是:定义一组生命周期钩子,允许第三方代码在特定时机介入主流程,从而扩展应用能力。
3.2 实现一个构建工具的插件系统
// 生命周期钩子定义
interface BuildHooks {
'config:loaded': { config: BuildConfig };
'build:start': { entryPoints: string[] };
'file:transform': { filePath: string; content: string; setContent: (c: string) => void };
'build:complete': { outputDir: string; duration: number };
'build:error': { error: Error };
}
interface BuildConfig {
entry: string;
output: string;
plugins: BuildPlugin[];
}
// 插件接口
interface BuildPlugin {
name: string;
setup(hooks: PluginHookRegistrar): void;
}
type HookHandler<T> = (payload: T) => void | Promise<void>;
class PluginHookRegistrar {
private hooks = new Map<string, Array<HookHandler<any>>>();
on<K extends keyof BuildHooks>(hook: K, handler: HookHandler<BuildHooks[K]>): void {
if (!this.hooks.has(hook)) {
this.hooks.set(hook, []);
}
this.hooks.get(hook)!.push(handler);
}
async trigger<K extends keyof BuildHooks>(hook: K, payload: BuildHooks[K]): Promise<void> {
const handlers = this.hooks.get(hook) || [];
for (const handler of handlers) {
await handler(payload);
}
}
}
// 构建引擎
class BuildEngine {
private hookRegistrar = new PluginHookRegistrar();
constructor(private config: BuildConfig) {
// 注册所有插件
for (const plugin of config.plugins) {
console.log(`加载插件: ${plugin.name}`);
plugin.setup(this.hookRegistrar);
}
}
async build(): Promise<void> {
try {
await this.hookRegistrar.trigger('config:loaded', { config: this.config });
await this.hookRegistrar.trigger('build:start', { entryPoints: [this.config.entry] });
// 模拟文件转换
const files = ['src/index.ts', 'src/utils.ts', 'src/app.ts'];
for (const filePath of files) {
let content = `// content of ${filePath}`;
const payload = {
filePath,
content,
setContent: (c: string) => { content = c; },
};
await this.hookRegistrar.trigger('file:transform', payload);
}
const duration = 1234;
await this.hookRegistrar.trigger('build:complete', {
outputDir: this.config.output,
duration,
});
} catch (error) {
await this.hookRegistrar.trigger('build:error', { error: error as Error });
}
}
}
编写插件:
// 插件 1:TypeScript 编译插件
const typescriptPlugin: BuildPlugin = {
name: 'typescript-compiler',
setup(hooks) {
hooks.on('file:transform', async (payload) => {
if (payload.filePath.endsWith('.ts')) {
console.log(` [TS] 编译: ${payload.filePath}`);
payload.setContent(payload.content.replace(/: string|: number/g, ''));
}
});
},
};
// 插件 2:文件压缩插件
const minifyPlugin: BuildPlugin = {
name: 'minifier',
setup(hooks) {
hooks.on('file:transform', async (payload) => {
console.log(` [Minify] 压缩: ${payload.filePath}`);
payload.setContent(payload.content.replace(/\s+/g, ' ').trim());
});
hooks.on('build:complete', async (payload) => {
console.log(` [Minify] 构建完成,输出目录: ${payload.outputDir}`);
});
},
};
// 插件 3:构建耗时统计
const statsPlugin: BuildPlugin = {
name: 'build-stats',
setup(hooks) {
hooks.on('build:start', async () => {
console.log(' [Stats] 构建开始...');
});
hooks.on('build:complete', async (payload) => {
console.log(` [Stats] 构建耗时: ${payload.duration}ms`);
});
},
};
使用:
const engine = new BuildEngine({
entry: 'src/index.ts',
output: 'dist',
plugins: [typescriptPlugin, minifyPlugin, statsPlugin],
});
await engine.build();
四、依赖注入:控制反转的实践
4.1 什么是依赖注入
依赖注入(DI)不是一个 GoF 模式,而是一种实现”依赖倒置原则”的技术手段。核心思想是:对象不自己创建它所依赖的对象,而是从外部接收。
// 没有 DI:UserService 自己创建依赖
class UserService {
private db = new PostgresDatabase(); // 写死了
private mailer = new SendGridMailer(); // 写死了
}
// 有 DI:依赖从外部注入
class UserService {
constructor(
private db: Database, // 接口,不关心具体实现
private mailer: Mailer // 接口,不关心具体实现
) {}
}
4.2 实现一个轻量级 DI 容器
type Constructor<T = any> = new (...args: any[]) => T;
type Factory<T = any> = () => T;
interface ServiceRegistration<T> {
factory: Factory<T>;
singleton: boolean;
instance?: T;
}
class Container {
private services = new Map<string, ServiceRegistration<any>>();
// 注册单例服务
registerSingleton<T>(token: string, factory: Factory<T>): void {
this.services.set(token, { factory, singleton: true });
}
// 注册瞬态服务(每次 resolve 都创建新实例)
registerTransient<T>(token: string, factory: Factory<T>): void {
this.services.set(token, { factory, singleton: false });
}
// 注册已有实例
registerInstance<T>(token: string, instance: T): void {
this.services.set(token, { factory: () => instance, singleton: true, instance });
}
// 解析服务
resolve<T>(token: string): T {
const registration = this.services.get(token);
if (!registration) {
throw new Error(`服务未注册: ${token}`);
}
if (registration.singleton) {
if (!registration.instance) {
registration.instance = registration.factory();
}
return registration.instance;
}
return registration.factory();
}
// 检查服务是否已注册
has(token: string): boolean {
return this.services.has(token);
}
}
使用:
// 接口定义
interface Logger { log(msg: string): void; }
interface UserRepository { findById(id: string): Promise<User | null>; }
interface EmailService { send(to: string, subject: string, body: string): Promise<void>; }
// 具体实现
class ConsoleLogger implements Logger {
log(msg: string): void { console.log(`[LOG] ${msg}`); }
}
class PgUserRepository implements UserRepository {
constructor(private logger: Logger) {}
async findById(id: string): Promise<User | null> {
this.logger.log(`查询用户: ${id}`);
return { id, name: 'Alice', email: 'alice@example.com' } as User;
}
}
class SmtpEmailService implements EmailService {
constructor(private logger: Logger) {}
async send(to: string, subject: string, body: string): Promise<void> {
this.logger.log(`发送邮件至 ${to}: ${subject}`);
}
}
// 业务服务
class UserProfileService {
constructor(
private userRepo: UserRepository,
private emailService: EmailService,
private logger: Logger
) {}
async updateProfile(userId: string, updates: Partial<User>): Promise<void> {
const user = await this.userRepo.findById(userId);
if (!user) throw new Error('用户不存在');
this.logger.log(`更新用户 ${userId} 的资料`);
// 应用更新...
await this.emailService.send(user.email, '资料已更新', '您的个人资料已成功更新');
}
}
// 组装容器
const container = new Container();
container.registerSingleton<Logger>('Logger', () => new ConsoleLogger());
container.registerSingleton<UserRepository>('UserRepository', () =>
new PgUserRepository(container.resolve('Logger'))
);
container.registerSingleton<EmailService>('EmailService', () =>
new SmtpEmailService(container.resolve('Logger'))
);
container.registerTransient<UserProfileService>('UserProfileService', () =>
new UserProfileService(
container.resolve('UserRepository'),
container.resolve('EmailService'),
container.resolve('Logger')
)
);
// 使用
const profileService = container.resolve<UserProfileService>('UserProfileService');
await profileService.updateProfile('u-001', { name: 'Alice Updated' });
4.3 DI 在前端框架中的体现
- Angular:内置完整的 DI 系统,通过
@Injectable()装饰器和providers配置实现。 - Vue 3:
provide/injectAPI 就是一种轻量级的依赖注入。 - React:Context API 可以看作一种 DI 机制——Provider 提供依赖,Consumer 消费依赖。
- NestJS:完全借鉴 Angular 的 DI 体系,是 Node.js 后端中 DI 的典型实践。
🤔 想一想 有人说”DI 容器就是一个高级的全局对象注册表”,你同意吗?DI 容器和单例模式有什么本质区别?
在测试中,DI 如何帮助你实现”用 Mock 替换真实依赖”?
五、本章小结
本章从前端实战视角出发,深入讲解了四种架构级模式:
- 发布-订阅:通过事件总线解耦模块通信,是前端最常用的模式之一。注意类型安全和内存泄漏防范。
- 中间件:洋葱圈模型,将请求处理分解为可组合的环节。Express、Koa、Redux 的核心机制。
- 插件架构:通过生命周期钩子让第三方代码介入主流程。Webpack、Vite、VS Code 的成功之道。
- 依赖注入:将对象的创建和使用分离,通过容器管理依赖关系。Angular、NestJS 的核心设计。
这四种模式有一个共同的哲学:把”不变的框架”和”可变的扩展”分开,让系统在保持稳定性的同时拥有灵活性。
到这里,我们已经从经典的 GoF 模式走到了前端实战模式。但设计的世界不止于此——当视角从”类与对象”提升到”系统与架构”,你会发现同样的思想在更大的尺度上重现。下一章,我们将走进架构模式的领域,看看 MVC/MVVM、微服务模式如何在系统层面运用设计思想,同时学会识别那些”看起来像好办法但实际上会把你引入泥潭”的反模式。
📝 结尾自测
- 发布-订阅模式和观察者模式的结构性区别是什么?为什么发布-订阅更适合大型应用?
- 中间件模式的”洋葱圈”执行顺序是怎样的?一个中间件不调用
next()会有什么效果?- 插件架构中,“钩子”(Hook)的作用是什么?它和回调函数有什么区别?
- 依赖注入中,“单例注册”和”瞬态注册”的区别是什么?各适合什么场景?
- 请用发布-订阅模式设计一个简单的”主题切换”功能:用户切换主题后,导航栏、侧边栏、内容区都要更新样式。写出核心代码结构。
购买课程解锁全部内容
写出优雅代码:10 章掌握 23 种设计模式
¥29.90