文章

设计模式实战深度解析

从弹窗管理、表单校验到 API 请求封装,用单例/命令/策略/代理/装饰器模式 把前端最常见的三块重复代码,重构成可扩展、可测试的结构。

设计模式实战深度解析

一句话概括

设计模式最怕”看书都懂,上手不会”。前端和 Java/C++ 不同,经典教材里那些银行、咖啡机的例子离日常太远。这篇文章不绕弯子,直接拿前端天天写的三件事开刀:弹窗管理、表单校验、API 请求封装。

你会发现它们背后全是老熟人:单例模式管住全局唯一的弹窗管理器、命令模式把”打开/关闭弹窗”封装成可撤销的操作、策略模式让表单校验规则可插拔、代理模式 + 装饰器模式用拦截链给请求叠加 token/日志/缓存、工厂模式统一创建规则与实例。设计模式不是炫技,本质是”把会变的留口子、不变的收进去”——也就是开闭原则。

核心知识点

1. 单例模式:全局唯一的弹窗管理器

弹窗最容易出的问题是”多个容器、z-index 互殴”。根因是到处 new,导致存在多个弹窗根节点。单例模式保证全局只有一个管理器,统一管容器、z-index 和弹窗栈。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// ❌ 每次都 new 一个,多个容器并存,关闭 A 后 B 的层级全乱
class DialogManager {
  constructor() { document.body.appendChild(document.createElement('div')); }
}

// ✅ 单例:全局只有一个实例,统一调度
class DialogManager {
  private static _instance: DialogManager | null = null;
  private container: HTMLDivElement;
  private zIndex = 1000;

  private constructor() {
    this.container = document.createElement('div');
    document.body.appendChild(this.container);
  }

  static get instance(): DialogManager {
    if (!DialogManager._instance) DialogManager._instance = new DialogManager();
    return DialogManager._instance;
  }

  open(opts: { title: string; content: string }): string {
    const id = `dlg_${Date.now()}`;
    const box = document.createElement('div');
    box.style.zIndex = String(++this.zIndex); // 统一递增,杜绝层级打架
    box.innerHTML = `<h3>${opts.title}</h3><div>${opts.content}</div>`;
    this.container.appendChild(box);
    return id;
  }

  // 测试友好:必要时重置(单例的隐藏坑见 FAQ)
  static reset() {
    DialogManager._instance?.container.remove();
    DialogManager._instance = null;
  }
}

DialogManager.instance.open({ title: '提示', content: 'hello' });

2. 命令模式:把”打开/关闭弹窗”封装成可撤销的操作

弹窗的一个高级需求是”撤销”——用户误关了,能一键恢复。命令模式把”一次操作”包成一个对象,带上 execute 和 undo,操作历史就能被记录、重放。

1
2
3
4
5
6
7
8
9
10
11
12
13
interface Command { execute(): void; undo(): void; }

class OpenDialogCommand implements Command {
  private openedId: string | null = null;
  constructor(private mgr: DialogManager, private opts: { title: string; content: string }) {}
  execute() { this.openedId = this.mgr.open(this.opts); }
  undo() { if (this.openedId) this.mgr.close(this.openedId); }
}

// 调用方不关心"怎么打开",只管"执行 / 撤销"
const cmd = new OpenDialogCommand(DialogManager.instance, { title: '确认', content: '?' });
cmd.execute(); // 打开
cmd.undo();    // 关闭

价值在于:操作被对象化后,可以进栈、批量撤销、甚至做事务回滚,比直接在事件里调 open() 灵活得多。

3. 策略模式:表单校验规则可插拔

表单校验是 if/else 重灾区——规则写死在函数里,加一种就要改一处,还无法单独测试。策略模式把每条规则封成独立对象,按字段自由组合。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// ❌ 规则硬编码在 if/else 里:加一种规则 = 改这个函数,越写越长还测不了单条
function validate(data: any) {
  const errs: Record<string, string> = {};
  if (!data.email) errs.email = '必填';
  else if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(data.email)) errs.email = '格式错';
  // ...更多规则继续堆
  return errs;
}

// ✅ 策略模式:每条规则是一个独立策略,组合使用
interface Rule { message: string; test: (v: any) => boolean; }

const rules = {
  required: { message: '必填', test: (v: any) => v != null && v !== '' },
  email:    { message: '邮箱格式错误', test: (v: any) => /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(v) },
  minLen: (n: number) => ({ message: `至少 ${n} 位`, test: (v: any) => String(v).length >= n }),
};

function validateField(value: any, fieldRules: Rule[]): string[] {
  return fieldRules.filter(r => !r.test(value)).map(r => r.message);
}

// 使用:字段 → 规则数组,新增规则只加 key 不动原函数
validateField('', [rules.required, rules.email]);        // ['必填', '邮箱格式错误']
validateField('a@b.com', [rules.required, rules.email]); // []
validateField('ab', [rules.required, rules.minLen(6)]);  // ['至少 6 位']

收益:新增策略只加一个对象,原函数一字不改(开闭原则),每条规则能独立单测、运行时动态注册。

4. 代理模式:用拦截器链控制请求的生与死

每个请求都要做 token 注入、超时、统一的错误处理。代理模式在”真正发请求”的前后插一道关,把横切逻辑收口,业务代码只管调接口。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
type ReqConfig = { url: string; method: string; headers?: Record<string, string> };

// 代理:在真实请求前对 config 做链式加工(token、日志……)
const requestInterceptors: Array<(c: ReqConfig) => ReqConfig> = [
  c => ({ ...c, headers: { ...c.headers, Authorization: `Bearer ${getToken()}` } }), // 鉴权
  c => { console.log('→', c.method, c.url); return c; },                             // 日志
];

async function request<T = unknown>(config: ReqConfig): Promise<T> {
  const final = requestInterceptors.reduce((c, fn) => fn(c), config); // 代理:链式加工
  const res = await fetch(final.url, { method: final.method, headers: final.headers });
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  return res.json() as Promise<T>;
}

要点:真实请求(fetch)被”代理对象”包住,调用方感觉不到拦截层的存在,但每次请求都被统一加工——这正是代理模式”控制访问”的本意。

5. 装饰器模式:给请求”叠加” token/日志/缓存

代理和装饰器很像,但装饰器更强调能力的横向叠加:每个装饰器只做一件事,可任意组合,且彼此不感知。把上一步的拦截器拆成独立”装饰器”,可读性更强。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 装饰器模式:每个能力是一个独立函数,叠加使用、互不耦合
const withAuth = (c: ReqConfig): ReqConfig =>
  ({ ...c, headers: { ...c.headers, Authorization: `Bearer ${getToken()}` } });

const withLog = (c: ReqConfig): ReqConfig => {
  console.log('→', c.method, c.url);
  return c;
};

const cache = new Map<string, unknown>();
const withCache = (c: ReqConfig): ReqConfig => {
  if (c.method === 'GET') {
    const hit = cache.get(c.url);
    if (hit) return { ...c, headers: { ...c.headers, 'X-Cache': 'HIT' } }; // 示意命中
  }
  return c;
};

// 像套娃一样叠加:业务代码完全不关心这些能力从哪来
function buildRequest(c: ReqConfig) {
  return [withAuth, withLog, withCache].reduce((cfg, deco) => deco(cfg), c);
}

对比代理模式:代理是”一个对象替另一个对象把关”;装饰器是”一层层包上去加能力”。拦截器链其实是装饰器在前端的常见变体(见 FAQ)。

6. 工厂模式:规则与实例的统一创建入口

当创建逻辑变复杂(比如规则有多种构造参数),用工厂把”怎么 new”收口,调用方只传名字和参数。表单校验的规则注册表就是典型工厂。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 工厂模式:规则名 → 创建规则实例,调用方不关心构造函数细节
const ruleFactory = {
  create(name: string, ...args: any[]): Rule {
    switch (name) {
      case 'required': return rules.required;
      case 'email':    return rules.email;
      case 'minLen':   return rules.minLen(args[0] as number);
      default: throw new Error(`未知规则: ${name}`);
    }
  },
};

// 配置驱动:从一份 JSON 描述直接生成校验器,新增规则不改调用方
const fieldRules = ['required', 'email'].map(n => ruleFactory.create(n));

工厂把”创建”和”使用”解耦:插件系统、组件库按需注册能力,底层都是同一套工厂思路。

其实你每天都在用

  • 全局弹窗 / 全局 loading / 全局 message:整个应用只想要一个根节点,背后就是单例
  • 富文本编辑器的”撤销/重做”:每次操作被包成命令对象进栈,就是命令模式
  • form.validate() 里每个字段配一组规则:每条规则一个策略,组合校验,就是策略模式
  • axios 的 interceptors.request/response:请求前后统一加工,就是代理模式
  • 给 axios 实例加 withCredentials、重试、错误上报:一层层包能力,就是装饰器模式
  • antd Form 的 rules={[{ required: true }, { type: 'email' }]}:规则数组就是策略的集合
  • UI 库的 createXXX 工厂函数(如 createApp、createStore):统一创建实例的入口
  • 你写的 if (type === 'a') ... else if (type === 'b'):它就是”该用策略模式却还没用”的信号

常见误解(FAQ)

❌ 误区一:”单例模式就是全局变量的优雅包装,随便用”

单例确实提供了全局访问点,但它带来两个真问题:隐式耦合(谁都能拿到,依赖关系看不见)和难测试(测试之间状态互相污染)。正经做法是配合依赖注入——把 DialogManager.instance 通过 props/context 传进去,测试时换 Mock 实例;或者像上面那样提供 reset() 在每个测试前清场。小项目图省事可以单例,中大型务必留好注入口。

❌ 误区二:”策略模式就是拿 Map 存函数,本质还是 if/else”

关键差异在扩展点和开闭原则:if/else 加分支要改原函数(动一处、冒一片风险);策略模式加分支只加一个策略对象,原函数和调用方都不动。而且策略能被独立单测、运行时动态注册(插件系统就靠这个)。Map 只是载体,真正的价值是”规则可插拔、可组合”。

❌ 误区三:”拦截器就是装饰器模式,两者没区别”

很像,但调用方式不同。装饰器模式是静态嵌套:new A(new B(new C())),编译期就定好组合,类型安全;拦截器链是运行时数组顺序执行,可随时增删。axios 的 interceptors 是后者(装饰器的变体)。经验:编译期确定的扩展用装饰器,运行期动态变化的用拦截器链。

❌ 误区四:”设计模式要从小项目一开始就全上”

千万别。模式在复杂度拐点之后才划算——项目只有两三个弹窗、一条表单时,三个 if/else 比一整套策略引擎清爽得多。判据很简单:当你第三次想”再加一个 if”时,就该重构了。过早引入模式 = 用复杂度换还没发生的灵活性,属于过度设计。

一句话总结

弹窗、表单、请求这三块”人人都写、人人都乱”的代码,用单例管实例、命令管操作、策略管规则、代理+装饰器管横切、工厂管创建,就能从”一团散装 if/else”变成”留好口子、各管各的”结构——设计模式落地的本质,永远是把会变的留出来,把不变的收进去。

本文由作者按照 CC BY 4.0 进行授权