跳到主要内容
预计阅读 39 分钟

前端设计模式实战 —— 发布订阅、中间件、插件架构、依赖注入

经典设计模式诞生于 C++/Java 时代,而前端生态有自己独特的需求和约束。这一章我们不再逐一讲解经典模式,而是聚焦前端开发中最常见的四种架构级模式,看看它们如何在 React/Vue/Node.js 等真实场景中落地。

📋 开篇自测:你已经知道多少?

  1. “发布-订阅”和”观察者模式”有什么区别?Node.js 的 EventEmitter 属于哪一种?
  2. Express 的中间件为什么要调用 next()?不调用会怎样?
  3. 你能解释 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 做了这个决定?官方推荐的替代方案是使用外部的轻量级事件库,如 mitttiny-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 3provide/inject API 就是一种轻量级的依赖注入。
  • 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、微服务模式如何在系统层面运用设计思想,同时学会识别那些”看起来像好办法但实际上会把你引入泥潭”的反模式。


📝 结尾自测

  1. 发布-订阅模式和观察者模式的结构性区别是什么?为什么发布-订阅更适合大型应用?
  2. 中间件模式的”洋葱圈”执行顺序是怎样的?一个中间件不调用 next() 会有什么效果?
  3. 插件架构中,“钩子”(Hook)的作用是什么?它和回调函数有什么区别?
  4. 依赖注入中,“单例注册”和”瞬态注册”的区别是什么?各适合什么场景?
  5. 请用发布-订阅模式设计一个简单的”主题切换”功能:用户切换主题后,导航栏、侧边栏、内容区都要更新样式。写出核心代码结构。

购买课程解锁全部内容

写出优雅代码:10 章掌握 23 种设计模式

¥29.90