JavaScript设计模式

张玥 2026年9月20日 阅读时间 50分钟
JavaScript 设计模式 架构 重构 最佳实践
前端开发技巧之JavaScript设计模式

“设计模式”这四个字,在前端圈的口碑相当分裂。一方面,面试八股里它是常客,人人都会背“二十三种设计模式”;另一方面,真实项目里它常常被当成过度设计的代名词——为了用而用,凭空多出十几层抽象,最后谁也看不懂。真相是:设计模式不是让你多写代码的,而是让你在正确的时机少写代码的。它描述的是一类反复出现的问题,以及一种已经被无数人验证过的解法骨架。GoF 在 1994 年总结的 23 种模式,诞生于 C++ 与 Smalltalk 的世界,而 JavaScript 是一门函数是一等公民、没有真正意义上的类、天生事件驱动的语言——很多模式在这门语言里已经“隐身”成了语言特性本身。本篇 50 分钟长文,从模式的本质讲起,按创建型、结构型、行为型三条主线逐一拆解,再补上 JavaScript 独有的惯用法、函数式替代方案、性能与陷阱、以及一场完整的实战重构。读完之后,你未必会立刻在项目里用上十个模式,但你一定能分辨出什么时候该抽象、什么时候该克制。

一、设计模式到底是什么:从套路到共同语言

1994 年,Erich Gamma、Richard Helm、Ralph Johnson、John Vlissides 四人(史称 GoF,Gang of Four)出版了《设计模式:可复用面向对象软件的基础》。书中归纳了 23 种模式,每一种都按照四个要素来描述:

名称 一个简短的词,让团队可以用一句话沟通一整套结构
问题 这个模式要解决的是什么场景下的什么困难
解决方案 参与者的职责、协作方式、静态与动态结构
效果 好处与代价——模式从来不是免费的

模式不是库,也不是算法

初学者最容易搞混的三件事:

  • 算法是明确的步骤序列,像菜谱,照着做就一定有结果。
  • 库 / 框架是可以直接引入的代码实体,有 API、有版本号。
  • 模式是一段可以反复套用的结构描述,它没有代码,只有关系。同一个策略模式,你可以用对象映射实现,也可以用类继承实现,还可以用函数数组实现。

所以“这个项目用了策略模式”这句话,本身不包含任何实现细节,它只是在说:这里把可互换的算法各自封装、运行时选择。

三大分类

GoF 按目的把 23 种模式分成三组,这个分类方式至今仍然好用。点击下面的卡片展开说明:

1
创建型
Creational
关注对象怎么来。把“new 什么、怎么 new、什么时候 new”从业务逻辑里抽离出来。包含:单例、工厂方法、抽象工厂、建造者、原型。
2
结构型
Structural
关注对象怎么组合。在不改动已有类的前提下,重新组装出更大的结构。包含:适配器、装饰器、代理、外观、组合、享元、桥接。
3
行为型
Behavioral
关注对象怎么通信。把“谁通知谁、谁决定谁、谁记住谁”这些交互职责安排清楚。包含:观察者、策略、状态、命令、迭代器、职责链、中介者、备忘录、模板方法、访问者、解释器。
4
JS 惯用法
Idioms
JavaScript 特有的、比 GoF 模式更常用的写法:IIFE、模块模式、揭示模块模式、柯里化、Mixin、中间件、事件委托、惰性求值。

为什么 JavaScript 里的模式“长得不一样”

如果你从 Java 或 C# 转过来,第一次看 JS 的实现往往会觉得“这也算模式?”——一个对象字面量就算策略模式,一个函数返回函数就算工厂。这不是偷工减料,而是因为语言特性替我们承担了很多工作:

  • 函数是一等公民:函数可以当参数传、当返回值返回、存在数组里。很多模式在静态语言里需要一整套类层次,在 JS 里就是一个高阶函数。
  • 原型继承而非类继承:class 只是语法糖,运行时依然是原型链。装饰、代理、Mixin 都可以直接在对象层面完成。
  • 对象字面量与动态属性:不需要为每一种组合预先声明类型,查表即可。
  • 闭包:天然提供了私有状态与封装,模块模式因此成立。
  • 事件循环与回调:观察者、发布订阅、职责链在这门语言里是基础设施级别的存在。
  • ES Module:模块本身就是单例,import 进来的永远是同一个引用。

结论是:不要把 GoF 的实现照搬进 JS。学模式要学的是“它解决什么问题、代价是什么”,实现方式完全可以让语言特性代劳。

反模式:比模式更该认识的东西

反模式(Anti-pattern)是那些看起来像解法、实际上是坑的常见做法。前端常见的反模式包括:

反模式 症状 后果
全局变量满天飞 window.config、window.user 随处可见 命名冲突、无法测试、依赖不可见
修改内置原型 Array.prototype.mySort = ... 污染全局、与未来标准冲突
上帝对象 一个 App.js 三千行 无法复用、无法并行开发
回调地狱 五层嵌套的 then 与 function 错误处理困难、可读性崩塌
深度继承链 A → B → C → D → E 脆弱的基类问题、修改一处崩一片
为模式而模式 一个按钮点击写了工厂 + 策略 + 命令 抽象成本高于收益,维护者崩溃

什么时候该用模式

值得抽象的信号
  • 同一段 if-else 分支在三个月内改过三次以上
  • 两个模块之间需要解耦,但直接引用会造成循环依赖
  • 需要在不改动已有代码的前提下扩展行为
  • 同一份逻辑需要在多处复用,但参数形态各不相同
应该克制的信号
  • “以后可能会用到”——以后再说
  • 只有一个实现,却为它准备了接口和工厂
  • 团队里没人说得清这层抽象在做什么
  • 为了在代码评审里显得“有设计感”

二、创建型模式:控制对象的诞生

创建型模式回答的是“对象从哪来”。在 JavaScript 里,这个问题的重要性比在静态语言里要低一些——因为没有类型声明,new 的负担不重。但当创建过程本身有逻辑、有分支、有资源开销时,把它封装起来就变得很有价值。

2.1 单例:最容易用错的一个

单例保证一个类只有一个实例,并提供全局访问点。在 JS 里,你其实一直在用它,只是没意识到:

// 方式一:ES Module 天然单例
// config.js
export const config = {
  apiBase: '/api/v2',
  timeout: 8000,
};

// 任意模块 import 得到的都是同一个对象引用
// import { config } from './config.js';

// 方式二:闭包单例(无构建工具时的经典写法)
const Store = (function () {
  let instance = null;

  function create() {
    const state = new Map();
    return {
      get(key) { return state.get(key); },
      set(key, value) { state.set(key, value); },
      size() { return state.size; },
    };
  }

  return {
    getInstance() {
      if (!instance) instance = create();
      return instance;
    },
  };
})();

// 方式三:class 静态私有字段 + 空值合并赋值
class Database {
  static #instance = null;
  #connection = null;

  static get instance() {
    return (Database.#instance ??= new Database());
  }

  connect() {
    if (!this.#connection) this.#connection = openSocket();
    return this.#connection;
  }
}

??= 是逻辑空赋值运算符:只有左侧为 null 或 undefined 时才赋值。#instance 是真正的私有字段,外部无法访问,比用闭包更直观。

单例的代价:为什么它常常是坏味道

单例的问题不在于“只有一个”,而在于它把依赖藏了起来:

// 测试时无法替换
import { Store } from './store.js';

function checkout(cart) {
  // Store 是硬编码的全局依赖
  const user = Store.getInstance().get('user');
  return submit(cart, user);
}

// 单元测试里无法注入一个假的 user,
// 只能去操纵全局状态,测试之间互相污染。
适合 日志器、配置、连接池、埋点 SDK
不适合 业务状态、用户会话、购物车
替代 依赖注入:把实例作为参数传进去
判断 “整个应用真的只允许一个吗?”

现代前端框架里的状态管理库(Redux、Pinia、Zustand)本质上也是“单例 store”,但它们通过 Provider、createStore() 这类机制把实例的创建权交给调用者,从而保留了测试时可替换的能力。这是对经典单例的一次重要改良。

2.2 工厂:把 new 的决策推迟

当创建对象需要根据条件判断、需要读取配置、需要缓存或复用实例时,工厂就派上用场了。

// 朴素写法:调用方需要知道所有类型
function createNotification(type) {
  if (type === 'email') return new EmailNotification();
  if (type === 'sms') return new SmsNotification();
  if (type === 'push') return new PushNotification();
  throw new Error('未知的通知类型');
}

// 工厂 + 注册表:新增类型不需要改工厂
const registry = new Map();

function register(type, factory) {
  registry.set(type, factory);
}

function create(type, options) {
  const factory = registry.get(type);
  if (!factory) throw new Error(`未注册的类型:${type}`);
  return factory(options);
}

register('email', (o) => ({ channel: 'email', ...o }));
register('sms', (o) => ({ channel: 'sms', ...o }));

create('email', { to: 'a@b.com' });

这个“注册表 + 工厂函数”的写法在前端非常常见:富文本编辑器的插件系统、图表库的图表类型、表单的动态字段渲染,底层都是这个结构。它的价值在于开闭原则——新增一种类型只需要调用 register,不需要修改 create 里的分支。

2.3 建造者:分步组装复杂对象

当一个对象的构造参数超过四五个,且很多是可选的,构造函数就会变成一团糟。建造者模式用链式调用把“配置”和“构造”分开:

class QueryBuilder {
  #query = { select: ['*'], from: '', where: [], orderBy: null, limit: null };

  select(...fields) {
    this.#query.select = fields.flat();
    return this; // 返回自身,支持链式调用
  }

  from(table) {
    this.#query.from = table;
    return this;
  }

  where(condition) {
    this.#query.where.push(condition);
    return this;
  }

  orderBy(field, dir = 'ASC') {
    this.#query.orderBy = { field, dir };
    return this;
  }

  limit(n) {
    this.#query.limit = n;
    return this;
  }

  build() {
    const { select, from, where, orderBy, limit } = this.#query;
    let sql = `SELECT ${select.join(', ')} FROM ${from}`;
    if (where.length) sql += ` WHERE ${where.join(' AND ')}`;
    if (orderBy) sql += ` ORDER BY ${orderBy.field} ${orderBy.dir}`;
    if (limit) sql += ` LIMIT ${limit}`;
    return sql;
  }
}

const sql = new QueryBuilder()
  .select('id', 'name')
  .from('users')
  .where('age > 18')
  .where("status = 'active'")
  .orderBy('created_at', 'DESC')
  .limit(20)
  .build();

建造者的精髓是每一步都返回 this,让配置过程读起来像一句话。它的代价是构建过程本身不是原子的——如果调用方漏掉了必需的步骤,只有在 build() 时才可能报错。所以 build() 里最好做一次完整校验。

2.4 原型:JavaScript 的老本行

在 JS 里,原型模式不是“一种可选方案”,而是语言本身的继承机制。你可以用 Object.create 直接建立委托关系:

const baseUser = {
  greet() { return `你好,我是 ${this.name}`; },
  role: 'guest',
};

const alice = Object.create(baseUser);
alice.name = 'Alice';
alice.greet(); // "你好,我是 Alice"

// 检查属性是自有还是继承的
Object.hasOwn(alice, 'name'); // true
Object.hasOwn(alice, 'role'); // false

// 浅拷贝
const copy1 = { ...alice };
const copy2 = Object.assign({}, alice);

// 深拷贝(现代浏览器原生支持)
const deep = structuredClone({
  user: alice,
  tags: ['a', 'b'],
  createdAt: new Date(),
});

structuredClone 是近年最重要的 API 之一。它能正确处理嵌套对象、数组、Date、Map、Set、ArrayBuffer,甚至循环引用;但它不能克隆函数、DOM 节点、原型链上的自定义类实例(类实例会被降级为普通对象)。在它出现之前,大家用 JSON.parse(JSON.stringify()) 做深拷贝,那个方法会丢掉 undefined、Date 变成字符串、循环引用直接报错。

四种创建型模式的取舍

模式 一句话 典型场景 主要代价
单例 全局唯一实例 配置、日志、连接池 隐藏依赖、难测试
工厂 把创建逻辑集中 插件系统、多态 UI 多一层间接,调试栈变长
建造者 分步组装 查询构造、请求配置 过程非原子,容易漏步骤
原型 以对象为模板 默认配置、对象克隆 浅拷贝陷阱、引用共享

三、结构型模式:重新组装已有的东西

结构型模式关心的是“怎么把已有的东西拼起来”。它们最大的共同点是:在不修改原有代码的前提下,改变系统的协作方式。这在维护老项目、接入第三方库、做渐进式重构时尤其有价值。

3.1 适配器:让不兼容的接口对上话

你接手了一个项目,里面有个老掉牙的日志工具,接口长这样:

// 第三方/历史遗留代码,不能改
class LegacyLogger {
  logMessage(level, msg) {
    console.log(`[${level.toUpperCase()}] ${msg}`);
  }
}

// 你的新代码希望用统一接口
class LoggerAdapter {
  #legacy;

  constructor(legacy) {
    this.#legacy = legacy;
  }

  info(msg) { this.#legacy.logMessage('info', msg); }
  warn(msg) { this.#legacy.logMessage('warn', msg); }
  error(msg) { this.#legacy.logMessage('error', msg); }
}

const logger = new LoggerAdapter(new LegacyLogger());
logger.info('服务已启动');

适配器的价值在于把丑陋隔离在一处。将来如果换掉底层实现,只需要改适配器,业务代码一行不用动。这在接入多个支付网关、多个地图 SDK、多个埋点平台时几乎是必备操作。

3.2 装饰器:在不改原函数的前提下加料

函数装饰器是 JS 里最常用的模式之一,很多同学天天在用却没意识到:

// 通用:耗时统计
function withTiming(fn, label = fn.name) {
  return function (...args) {
    const start = performance.now();
    try {
      return fn.apply(this, args);
    } finally {
      console.log(`${label} 耗时 ${(performance.now() - start).toFixed(2)}ms`);
    }
  };
}

// 通用:结果缓存(记忆化)
function memoize(fn) {
  const cache = new Map();
  return function (key) {
    if (cache.has(key)) return cache.get(key);
    const result = fn.call(this, key);
    cache.set(key, result);
    return result;
  };
}

// 通用:失败重试
function withRetry(fn, times = 3, delay = 300) {
  return async function (...args) {
    let lastError;
    for (let i = 0; i < times; i++) {
      try {
        return await fn.apply(this, args);
      } catch (err) {
        lastError = err;
        await sleep(delay * (i + 1));
      }
    }
    throw lastError;
  };
}

// 组合使用:装饰器可以层层叠加
const loadData = withTiming(withRetry(memoize(fetchUserList)));

装饰器的核心约定是接口不变:输入输出与原函数一致,只是额外做了点什么。正因为如此,它们才能任意组合、层层包裹。

ES 语言层面也有装饰器语法(TC39 Stage 3 提案),配合 Babel 或 TypeScript 可以写成:

function readonly(target, context) {
  return function (...args) {
    if (context.kind === 'method' && isFrozen) {
      throw new Error('当前处于只读模式');
    }
    return target.apply(this, args);
  };
}

class Cart {
  @readonly
  checkout() { ... }
}

3.3 代理:控制对对象的访问

Proxy 是 ES6 引入的元编程利器,它允许你拦截对象的基本操作——读取、赋值、删除、函数调用、in 判断、for...of 遍历等等。Vue 3 的响应式系统就是建立在 Proxy 之上的。

// 一个极简的响应式实现
let activeEffect = null;
const bucket = new WeakMap();

function track(target, key) {
  if (!activeEffect) return;
  let deps = bucket.get(target);
  if (!deps) bucket.set(target, (deps = new Map()));
  let set = deps.get(key);
  if (!set) deps.set(key, (set = new Set()));
  set.add(activeEffect);
}

function trigger(target, key) {
  bucket.get(target)?.get(key)?.forEach((fn) => fn());
}

function reactive(obj) {
  return new Proxy(obj, {
    get(target, key, receiver) {
      track(target, key);
      return Reflect.get(target, key, receiver);
    },
    set(target, key, value, receiver) {
      const ok = Reflect.set(target, key, value, receiver);
      trigger(target, key);
      return ok;
    },
  });
}

function effect(fn) {
  activeEffect = fn;
  fn();
  activeEffect = null;
}

const state = reactive({ count: 0 });
effect(() => console.log('count =', state.count));
state.count++; // 自动打印 "count = 1"

Proxy 的其他常见用途:

  • 访问控制:get 拦截里检查权限,无权访问的属性直接抛错。
  • 默认值:读不到的属性返回 0 或空数组,省掉大量判空代码。
  • 负数索引:让数组支持 arr[-1] 取最后一个元素。
  • 接口校验:set 拦截里做类型检查,早期发现 bug。
  • 懒加载:属性第一次被访问时才真正去请求数据。

需要注意的是,Proxy 的性能开销明显高于普通对象访问,在超高频的读写路径(比如每帧执行几千次的渲染循环)里要谨慎使用。它对整个对象生效,而不是某个属性,所以不要为了一点点便利把整个大型数据结构都代理掉。

3.4 外观:给复杂系统一张简单脸

外观模式(Facade)就是“封装复杂度”。一个典型例子是浏览器兼容层:

// 把一堆底层 API 包成一个简单函数
const dom = {
  on(el, type, handler) {
    el.addEventListener(type, handler);
    return () => el.removeEventListener(type, handler);
  },

  addClass(el, cls) {
    el.classList.add(cls);
  },

  ready(fn) {
    if (document.readyState !== 'loading') fn();
    else document.addEventListener('DOMContentLoaded', fn, { once: true });
  },

  async request(url, options) {
    const res = await fetch(url, {
      headers: { 'Content-Type': 'application/json' },
      ...options,
    });
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    return res.json();
  },
};

jQuery 在当年之所以流行,本质就是它给混乱的 DOM API 提供了一层外观。今天的 axios、dayjs 也是同样的思路——它们并不提供新能力,只是让旧能力更好用。

3.5 组合:树形结构的统一处理

组合模式让“单个对象”和“对象的集合”拥有一致的接口。文件系统、DOM 树、组织架构、菜单树,都是它的天然应用场景:

class FileNode {
  constructor(name, size) {
    this.name = name;
    this.size = size;
  }
  getSize() { return this.size; }
  list(indent = '') {
    console.log(`${indent}📄 ${this.name} (${this.size}B)`);
  }
}

class FolderNode {
  constructor(name) {
    this.name = name;
    this.children = [];
  }

  add(child) {
    this.children.push(child);
    return this;
  }

  // 与 FileNode 同名同签名,调用方无需区分
  getSize() {
    return this.children.reduce((sum, c) => sum + c.getSize(), 0);
  }

  list(indent = '') {
    console.log(`${indent}📁 ${this.name}/`);
    this.children.forEach((c) => c.list(indent + ' '));
  }
}

const root = new FolderNode('src')
  .add(new FileNode('index.js', 1200))
  .add(new FolderNode('utils')
    .add(new FileNode('date.js', 800))
    .add(new FileNode('format.js', 600)));

root.getSize(); // 2600
root.list();

组合模式的威力在于递归的优雅:调用方只需要写一次 getSize(),不需要判断当前节点是文件还是文件夹。虚拟 DOM 的 diff、React 的组件树、CSS 选择器的匹配,底层都是组合模式的思想。

3.6 享元:共享那些重复的部分

享元模式(Flyweight)把对象的“内部状态”(可共享)与“外部状态”(随场景变化)分离,从而大幅减少对象数量。前端最典型的应用是大量重复元素的渲染:

// 反例:一万个标记对象,每个都存一份颜色和图标
// 10000 * (字符串 + 图片对象) → 内存爆炸

// 享元:把共享的部分抽成池子
const iconPool = new Map();

function getIcon(type) {
  if (!iconPool.has(type)) {
    iconPool.set(type, {
      type,
      src: `/icons/${type}.svg`,
      width: 24,
      height: 24,
    });
  }
  return iconPool.get(type);
}

// 每个标记只保存自己的外部状态(坐标、id)
const markers = points.map((p) => ({
  id: p.id,
  x: p.x,
  y: p.y,
  icon: getIcon(p.type), // 共享引用
}));

同样的思路也适用于:多个组件共享同一份大配置对象、多个请求共享同一个 AbortController 的结构、虚拟列表只渲染可视区域内的节点。

3.7 桥接:把抽象与实现拆开

桥接模式的核心是“让两个维度各自独立变化”。假设你要做一个通知系统,通知类型(普通、紧急)和发送渠道(邮件、短信、站内信)是两个独立的维度。用继承会得到 2×3=6 个类;用桥接只需要 2+3 个:

// 实现维度:渠道
const channels = {
  email: { send: (to, msg) => mailApi(to, msg) },
  sms: { send: (to, msg) => smsApi(to, msg) },
  inApp: { send: (to, msg) => pushToFeed(to, msg) },
};

// 抽象维度:通知类型
class Notification {
  constructor(channel) {
    this.channel = channel; // 桥接点
  }
  send(to, msg) {
    this.channel.send(to, msg);
  }
}

class UrgentNotification extends Notification {
  send(to, msg) {
    super.send(to, `【紧急】${msg}`);
    analytics.track('urgent_sent');
  }
}

new UrgentNotification(channels.sms).send('138****', '服务异常');

判断是否需要桥接的一个简单信号是:“类名里出现了多个维度”,比如 RedCircle、BlueSquare、RedSquare——颜色和形状本该是两个独立维度。

结构型模式速查

模式 解决的问题 前端高频场景
适配器 接口不兼容 接入多个 SDK、旧 API 迁移
装饰器 动态增加职责 日志、缓存、重试、权限校验
代理 控制访问 响应式系统、懒加载、访问控制
外观 复杂度封装 请求库、工具库、兼容层
组合 树形结构统一处理 文件树、菜单、虚拟 DOM
享元 大量重复对象 图标池、虚拟列表、配置共享
桥接 多维度独立变化 通知系统、主题 × 组件

四、行为型模式:把交互的职责安排明白

行为型模式是数量最多、也最贴近日常开发的一类。它们关心的是对象之间“谁通知谁、谁决定谁、谁记住谁”。前端作为事件驱动的 UI 层,几乎每一天都在和这些模式打交道。

4.1 观察者 vs 发布订阅:一字之差,天壤之别

这是被混淆最多的两个概念。它们长得像,但结构完全不同:

// 观察者:Subject 直接持有 Observer 列表
class Subject {
  #observers = new Set();

  attach(observer) {
    this.#observers.add(observer);
  }

  detach(observer) {
    this.#observers.delete(observer);
  }

  notify(data) {
    // 直接调用观察者,双方互相知道对方存在
    this.#observers.forEach((o) => o.update(data));
  }
}
// 发布订阅:中间隔着 EventBus
const bus = new EventEmitter();

// 发布者只认识事件名,不认识订阅者
bus.emit('order:paid', { id: 123 });

// 订阅者只认识事件名,不认识发布者
bus.on('order:paid', (payload) => {
  sendEmail(payload.id);
});

bus.on('order:paid', (payload) => {
  updateInventory(payload.id);
});
观察者 Subject 与 Observer 互相持有引用,耦合度更高但更直接
发布订阅 双方只依赖事件总线,完全解耦,可以跨模块、跨层级通信
前端现状 DOM 事件、Vue 的 emit、Node 的 EventEmitter 都是观察者
前端现状 Redux 的 store、微前端通信、WebSocket 消息分发多为发布订阅

下面是一个生产可用的 EventEmitter 实现,注意 on 返回取消函数这个设计——它比手动记录 off 更不容易出错:

class EventEmitter {
  #events = new Map();

  on(type, handler) {
    if (!this.#events.has(type)) this.#events.set(type, new Set());
    this.#events.get(type).add(handler);
    // 返回取消订阅函数,调用方不必保存 handler 引用
    return () => this.off(type, handler);
  }

  once(type, handler) {
    const off = this.on(type, (...args) => {
      off();
      handler(...args);
    });
    return off;
  }

  off(type, handler) {
    this.#events.get(type)?.delete(handler);
  }

  emit(type, ...args) {
    // 复制一份再遍历,防止回调中修改集合导致跳过
    const handlers = [...(this.#events.get(type) ?? [])];
    handlers.forEach((h) => {
      try { h(...args); }
      catch (err) { console.error(`事件 ${type} 的处理器出错:`, err); }
    });
  }

  clear(type) {
    if (type) this.#events.delete(type);
    else this.#events.clear();
  }
}

三个容易被忽略的细节:用 Set 而不是数组可以自动去重;遍历前复制一份可以避免回调中 off 自己导致的跳过问题;单个 handler 抛错不应该中断其他 handler,所以每个调用都包了 try/catch。

4.2 策略:消灭 if-else 的主力

策略模式把一组可互换的算法各自封装,让它们可以在运行时切换。这是前端重构中最常用、见效最快的一个模式。

// 改造前:每加一种规则就改一次函数
function getPrice(user, price) {
  if (user.level === 'vip') return price * 0.8;
  else if (user.level === 'svip') return price * 0.6;
  else if (user.isNew) return price * 0.9;
  else if (isHoliday()) return price * 0.85;
  return price;
}

// 改造后:策略表 + 责任链式的顺序匹配
const discountStrategies = [
  { when: (u) => u.level === 'svip', calc: (p) => p * 0.6 },
  { when: (u) => u.level === 'vip', calc: (p) => p * 0.8 },
  { when: (u) => u.isNew, calc: (p) => p * 0.9 },
  { when: () => isHoliday(), calc: (p) => p * 0.85 },
];

function getPrice(user, price) {
  const strategy = discountStrategies.find((s) => s.when(user));
  return strategy ? strategy.calc(price) : price;
}

// 新增规则只需往数组里加一项,函数体不动

再来看看表单校验的经典案例。点击下面的按钮,切换不同的校验策略,观察输出:

// 输入值: "abc"
// 校验结果: 校验通过
const validators = {
  required: (v) => v.trim() !== '' || '不能为空',
  email: (v) => /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(v) || '邮箱格式不正确',
  phone: (v) => /^1[3-9]\d{9}$/.test(v) || '手机号格式不正确',
  minLength: (v, n) => v.length >= n || `至少需要 ${n} 个字符`,
  username: (v) => /^[a-zA-Z0-9_]{3,16}$/.test(v) || '用户名需为 3-16 位字母、数字或下划线',
};

function validate(value, rules) {
  for (const rule of rules) {
    const [name, ...params] = rule.split(':');
    const fn = validators[name];
    if (!fn) throw new Error(`未知校验规则:${name}`);
    const result = fn(value, ...params.map(Number));
    if (result !== true) return result;
  }
  return true;
}

validate('abc', ['required', 'minLength:6']);
// → "至少需要 6 个字符"

注意这个 validators 的返回约定:成功返回 true,失败返回错误消息字符串。这比返回布尔值更实用——调用方可以直接把消息显示到界面上,不需要再维护一张错误码表。

4.3 状态:让对象的行为随状态改变

状态模式与策略模式结构相似,但意图不同:策略是外部选择算法,状态是内部根据条件自动迁移。一个典型场景是异步请求的生命周期:

const states = {
  idle: {
    submit(ctx) {
      ctx.transition('loading');
      ctx.request();
    },
    label: '提交',
  },
  loading: {
    submit() { /* 加载中忽略点击 */ },
    label: '提交中…',
  },
  success: {
    submit(ctx) { ctx.transition('idle'); },
    label: '已完成',
  },
  error: {
    submit(ctx) {
      ctx.transition('loading');
      ctx.request();
    },
    label: '重试',
  },
};

class SubmitMachine {
  #state = states.idle;

  transition(name) {
    if (!states[name]) throw new Error(`未知状态:${name}`);
    this.#state = states[name];
    this.render();
  }

  submit() { this.#state.submit(this); }
  get label() { return this.#state.label; }
}

// 状态定义收在一处,新增状态不会散落到各个 if 分支

状态模式最大的收益是把散落在各处的 if (state === 'xxx') 收敛成一张状态表。当状态超过四个、或者状态之间的迁移规则开始变得复杂时,它的价值就体现出来了。再往前一步,就是 XState 这类状态机库的领域。

4.4 命令:把操作变成对象

命令模式把“一次操作”封装成对象,从而支持排队、记录、撤销、重放。富文本编辑器、画板工具、表单撤销栈,都靠它:

class AddTextCommand {
  constructor(editor, text) {
    this.editor = editor;
    this.text = text;
  }

  execute() { this.editor.append(this.text); }
  undo() { this.editor.removeLast(this.text.length); }
}

class CommandHistory {
  #stack = [];
  #redoStack = [];

  run(cmd) {
    cmd.execute();
    this.#stack.push(cmd);
    this.#redoStack.length = 0; // 新操作会清空重做栈
  }

  undo() {
    const cmd = this.#stack.pop();
    if (!cmd) return;
    cmd.undo();
    this.#redoStack.push(cmd);
  }

  redo() {
    const cmd = this.#redoStack.pop();
    if (!cmd) return;
    cmd.execute();
    this.#stack.push(cmd);
  }

  get canUndo() { return this.#stack.length > 0; }
  get canRedo() { return this.#redoStack.length > 0; }
}

命令对象必须同时实现 execute 和 undo,而且 undo 必须精确抵消 execute 的效果。这一点听起来简单,做起来往往是 bug 的来源——尤其是涉及异步操作或外部系统时,“撤销”可能根本无法做到幂等。所以很多系统采用快照式撤销(每次操作前保存全量状态),牺牲内存换取可靠性。

4.5 迭代器与生成器:把遍历抽象出来

迭代器模式提供了统一的遍历接口,让调用方不关心底层数据结构。ES6 之后,这已经成了语言的一部分:

// 自定义可迭代对象
class Range {
  constructor(start, end, step = 1) {
    this.start = start;
    this.end = end;
    this.step = step;
  }

  [Symbol.iterator]() {
    let current = this.start;
    const { end, step } = this;
    return {
      next() {
        if (current <= end) {
          const value = current;
          current += step;
          return { value, done: false };
        }
        return { value: undefined, done: true };
      },
    };
  }
}

[...new Range(1, 5)]; // [1, 2, 3, 4, 5]

// 生成器写法更简洁
function* range(start, end, step = 1) {
  for (let i = start; i <= end; i += step) yield i;
}

// 生成器天然是惰性的,可以表示无限序列
function* naturals() {
  let n = 1;
  while (true) yield n++;
}

const it = naturals();
it.next(); // { value: 1, done: false }

生成器还带来了一个常被忽略的能力:它可以作为协程使用,通过 yield 暂停执行、通过 next(value) 传入数据。这是 async/await 早期的实现原理(co 库),也是 Redux-Saga 的核心机制。

4.6 职责链:每个环节只做一件事

职责链把处理请求的多个环节串成一条链,每个环节决定“处理”还是“交给下一个”。前端的中间件就是它的标准实现:

// 极简版的 Koa 中间件引擎
function compose(middlewares) {
  return function (ctx) {
    let index = -1;

    function dispatch(i) {
      // 防止同一个中间件被调用两次
      if (i <= index) return Promise.reject(new Error('next() 被重复调用'));
      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);
  };
}

const app = compose([
  async (ctx, next) => {
    console.log('1 进入');
    await next();
    console.log('1 离开');
  },
  async (ctx, next) => {
    console.log('2 进入');
    await next();
    console.log('2 离开');
  },
]);

// 输出:1 进入 → 2 进入 → 2 离开 → 1 离开
// 这就是"洋葱模型"

洋葱模型的精髓在于 next() 前后都可以写代码:请求前的日志、鉴权、限流,请求后的埋点、错误处理、响应改写。Express、Koa、Redux、Axios 的拦截器,本质都是职责链。

4.7 备忘录、中介者、模板方法

  • 备忘录(Memento):在不破坏封装的前提下捕获并恢复对象状态。撤销栈、表单草稿、编辑器历史版本,都属于这一类。实践中的常见做法是保存不可变快照,而不是保存反向操作。
  • 中介者(Mediator):用一个中心对象协调多个同事对象之间的通信,避免它们互相引用。表单里的字段联动(选了国家自动刷新省份列表)就是典型场景——让所有字段都只和表单控制器通信,而不是字段之间两两通信。
  • 模板方法(Template Method):在父类中定义算法骨架,把可变步骤留给子类实现。在 JS 里更常见的写法是“传入钩子函数的通用流程”:
// 模板方法:流程固定,步骤可替换
async function withRequestLifecycle({
  beforeRequest,
  request,
  onSuccess,
  onError,
  afterAll,
}) {
  beforeRequest?.();
  try {
    const data = await request();
    onSuccess?.(data);
    return data;
  } catch (err) {
    onError?.(err);
    throw err;
  } finally {
    afterAll?.();
  }
}

行为型模式速查

模式 核心问题 前端高频场景
观察者 状态变化如何通知 DOM 事件、响应式依赖收集
发布订阅 跨模块解耦通信 全局事件总线、微前端通信
策略 消除 if-else 分支 校验、计费、排序、导出格式
状态 行为随状态变化 请求生命周期、播放器、向导
命令 操作可撤销可排队 编辑器、画板、批量任务
迭代器 统一遍历接口 虚拟列表、流式数据、分页
职责链 多环节依次处理 中间件、拦截器、审批流
中介者 多对象通信解耦 表单联动、复杂组件通信
备忘录 状态可回滚 撤销栈、草稿、快照
模板方法 流程固定步骤可变 请求生命周期、构建流程

五、JavaScript 独有的惯用法

有些写法不属于 GoF 的 23 种模式,但在 JavaScript 世界里出现频率极高,甚至比很多正式模式更常用。它们更准确地说是“惯用法”(Idiom)。

5.1 IIFE:立即执行函数表达式

在 ES Module 普及之前,IIFE 是唯一的模块化手段。它利用函数作用域创建私有空间,避免污染全局:

const Counter = (function () {
  // 私有变量,外部无法访问
  let count = 0;

  function increment() { count++; }
  function getCount() { return count; }

  // 只暴露需要暴露的
  return { increment, getCount };
})();

Counter.increment();
Counter.getCount(); // 1
Counter.count; // undefined

注意开头那个分号。如果上一行代码没有分号结尾(比如 const a = b),下一行以 ( 开头,JavaScript 的自动分号插入(ASI)机制可能把它们连成一句,导致运行时错误。这就是所谓的“ASI 陷阱”。如今在模块化项目里,IIFE 主要出现在两种场合:需要立即执行的初始化逻辑,以及打包工具生成的 UMD 包装。

5.2 模块模式与揭示模块模式

模块模式是 IIFE 的升级版,揭示模块模式(Revealing Module Pattern)则是它的一种更清晰的变体——先定义所有函数,最后统一决定暴露哪些:

const UserService = (function () {
  let cache = new Map();

  function fetchUser(id) {
    if (cache.has(id)) return Promise.resolve(cache.get(id));
    return api.get(`/users/${id}`).then((user) => {
      cache.set(id, user);
      return user;
    });
  }

  function clearCache() { cache.clear(); }

  // 揭示:把要公开的引用集中在这里
  return {
    fetchUser,
    clearCache,
  };
})();

揭示模块模式的好处是代码里只有一处 return,一眼就能看出这个模块的公开接口有哪些。相比之下,在函数定义中间随手 return 或者在每个函数上挂 public 标记,都会让接口变得难以追踪。

5.3 柯里化与偏函数

柯里化把多参数函数转换成一系列单参数函数,偏函数则固定部分参数。它们的共同价值是制造可复用的中间函数:

// 通用柯里化
function curry(fn) {
  return function curried(...args) {
    if (args.length >= fn.length) return fn.apply(this, args);
    return (...rest) => curried(...args, ...rest);
  };
}

const add = curry((a, b, c) => a + b + c);
add(1)(2)(3); // 6
add(1, 2)(3); // 6
add(1, 2, 3); // 6

// 偏函数:固定前缀,制造专用函数
function partial(fn, ...preset) {
  return (...rest) => fn(...preset, ...rest);
}

const log = (level, tag, msg) => console.log(`[${level}][${tag}] ${msg}`);
const logAuth = partial(log, 'INFO', 'AUTH');
logAuth('登录成功');
// [INFO][AUTH] 登录成功

柯里化在实际项目里最常见的形态是事件处理器工厂:

// 反例:循环里绑定闭包变量,容易出错
items.forEach((item) => {
  btn.addEventListener('click', () => handle(item.id));
});

// 柯里化后:职责分离,处理器可以单独测试
const handleClick = (id) => (event) => {
  event.stopPropagation();
  selectItem(id);
};

items.forEach((item) => {
  btn.addEventListener('click', handleClick(item.id));
});

5.4 Mixin:用组合代替继承

JavaScript 只支持单继承,但通过 Mixin 可以把多个能力“混入”一个对象。这也是 React 早期解决横切关注点的主流方案(Hooks 出现之前):

const Serializable = {
  toJSON() {
    return JSON.stringify({ ...this });
  },
  fromJSON(json) {
    return Object.assign(new this.constructor(), JSON.parse(json));
  },
};

const Timestamped = {
  touch() { this.updatedAt = Date.now(); },
  get isStale() { return Date.now() - this.updatedAt > 86400000; },
};

class Document {}
Object.assign(Document.prototype, Serializable, Timestamped);

const doc = new Document();
doc.touch();
doc.toJSON();

需要注意 Mixin 的两个陷阱:命名冲突(两个 Mixin 有同名方法时,后者会静默覆盖前者)与来源不明(三个月后你会忘了 doc.isStale 是哪个 Mixin 给的)。所以在现代项目里,更推荐用显式的组合:把能力做成独立的函数或对象,通过参数注入,而不是修改原型。

5.5 惰性求值与惰性初始化

“只在真正需要的时候才算”——这个思路在很多地方都能显著提升性能:

// 惰性单例:第一次访问时才创建
let _heavyInstance = null;
function getHeavy() {
  return (_heavyInstance ??= createHeavyObject());
}

// 惰性属性:首次读取时计算,然后替换为普通属性
function defineLazy(obj, key, compute) {
  Object.defineProperty(obj, key, {
    configurable: true,
    get() {
      const value = compute.call(this);
      // 用普通属性覆盖 getter,下次读取不再执行 compute
      Object.defineProperty(this, key, {
        value,
        writable: true,
        configurable: true,
      });
      return value;
    },
  });
}

5.6 事件委托

严格来说事件委托不算设计模式,但在前端它是“职责转移”思想的绝佳体现:把子元素的事件处理职责,委托给共同的祖先元素。

// 反例:一千个列表项,一千个监听器
document.querySelectorAll('.item').forEach((el) => {
  el.addEventListener('click', onItemClick);
});

// 正解:一个监听器,动态新增的子元素自动生效
list.addEventListener('click', (event) => {
  const item = event.target.closest('.item');
  if (!item || !list.contains(item)) return;
  onItemClick(item);
});

// 一个监听器处理多种按钮
toolbar.addEventListener('click', (event) => {
  const btn = event.target.closest('[data-action]');
  if (!btn) return;
  const handler = actions[btn.dataset.action];
  handler?.(btn);
});

事件委托有三个好处:监听器数量从 N 降到 1、动态新增的元素自动获得能力、内存占用显著下降。它唯一的代价是需要在处理函数里做一次 closest 判断,但这点开销远小于维护上千个监听器。

六、用函数式思维替代经典模式

JavaScript 支持多范式,很多在面向对象语境下需要一整个类层次才能表达的模式,在函数式视角下可以退化成一两行。这不是说函数式“更高级”,而是说同一个问题在不同的抽象工具下,成本完全不同。

// OO 版策略:类 + 接口 + 上下文
class DiscountContext {
  constructor(strategy) { this.strategy = strategy; }
  calc(price) { return this.strategy.calc(price); }
}
class VipDiscount {
  calc(price) { return price * 0.8; }
}
// 函数式版策略:就是一张表
const discounts = {
  vip: (price) => price * 0.8,
  svip: (price) => price * 0.6,
};

const calc = (level, price) =>
  (discounts[level] ?? ((p) => p))(price);

再看几个常见的对应关系:

经典模式 函数式替代 关键差异
策略 查表 / 高阶函数 不需要类,函数本身就是一等值
命令 函数数组 / reducer 撤销需要额外保存快照
观察者 响应式流 / 订阅函数 流可以组合、可以变换
单例 模块作用域常量 ES Module 天然就是单例
装饰器 函数组合 / pipe 组合顺序显式、易于测试
迭代器 生成器 / map / filter 惰性求值、链式组合
模板方法 传入钩子函数的流程函数 不需要继承

函数式的核心工具是 pipe 与 compose,它们让数据流像管道一样清晰:

const pipe = (...fns) => (x) => fns.reduce((acc, fn) => fn(acc), x);
const compose = (...fns) => (x) => fns.reduceRight((acc, fn) => fn(acc), x);

const normalize = (s) => s.trim().toLowerCase();
const split = (s) => s.split(/\s+/);
const unique = (arr) => [...new Set(arr)];
const sort = (arr) => [...arr].sort();

const parseTags = pipe(normalize, split, unique, sort);

parseTags(' Vue react VUE angular ');
// ["angular", "react", "vue"]

注意每个纯函数返回的都是新数组或新字符串,没有修改输入。这种不可变的特性让每一步都能单独测试,也让整个管道易于推理——你不需要担心中间某一步偷偷改了原始数据。

七、六个高频陷阱

模式本身没有问题,出问题的永远是使用方式。以下六个坑,几乎每个前端团队都踩过。

陷阱一:过度设计

最经典的症状是“为了将来可能的需求而抽象”。一个只有一种实现的接口、一个永远不会被替换的策略、一个只有两行代码的工厂——这些抽象的成本(阅读成本 + 跳转成本 + 维护成本)往往远高于它们的收益。

过度设计
IPaymentStrategyFactory → AlipayStrategy → doPay(),三层抽象,实际只有支付宝一种支付方式,且半年内不会变。
恰到好处
直接写一个 pay() 函数。等到真的接入微信支付时,再抽策略表——那时你才知道抽象应该长什么样。

陷阱二:单例导致的测试地狱

单例让依赖关系从函数签名里消失了。测试时你无法从外部注入替身,只能去操作全局状态,导致测试之间互相污染、执行顺序影响结果。解决方法是把创建权交给调用者:

// 难测试
function sendEmail(to) {
  return Mailer.getInstance().send(to);
}

// 易测试:依赖显式传入,默认值保持便利性
function sendEmail(to, mailer = defaultMailer) {
  return mailer.send(to);
}

陷阱三:观察者导致的内存泄漏

订阅了但忘记取消,是最常见的泄漏原因。在单页应用里,组件卸载后监听器仍然持有 DOM 引用,页面切换几十次后内存就会明显上涨。

// 反例:组件卸载后监听器仍然存活
mounted() {
  bus.on('theme:change', this.applyTheme);
}

// 正解:在合适的生命周期里注销
mounted() {
  this.offTheme = bus.on('theme:change', this.applyTheme);
},
unmounted() {
  this.offTheme();
}

// 更稳妥:用 AbortController 统一管理
const controller = new AbortController();
window.addEventListener('resize', onResize, { signal: controller.signal });
window.addEventListener('scroll', onScroll, { signal: controller.signal });

// 一行代码注销全部
controller.abort();

另外,如果订阅的回调里引用了大对象,即使取消订阅也可能因为闭包而延迟回收。对于长生命周期的全局事件总线,建议使用 WeakRef 或定期清理死引用。

陷阱四:this 丢失

把对象方法作为回调传递时,this 会丢失。这在装饰器模式里尤其危险,因为装饰器返回的是一个新函数:

// 危险:装饰器破坏了 this 绑定
function withLog(fn) {
  return function (...args) {
    console.log('调用', args);
    return fn(...args); // ❌ this 丢失
  };
}

// 正确:保留 this 与返回值
function withLog(fn) {
  return function (...args) {
    console.log('调用', args);
    return fn.apply(this, args); // ✅
  };
}

// 或者用箭头函数时显式绑定(注意箭头函数本身没有 this)
class Store {
  handleClick = () => { ... }; // 类字段箭头函数,this 恒为实例
}

陷阱五:继承层次过深

“基类改一行,子类崩一片”是深度继承的典型症状。这是著名的脆弱基类问题:子类依赖了基类的实现细节,而基类的任何改动都可能破坏子类。

判断标准很简单:如果继承层次超过三层,或者你发现子类里有大量 super 调用只是为了“绕开”父类的行为,那就应该考虑改用组合。

// 继承:Flyable + Swimmable + Walkable → 3 层 6 个类
// 组合:能力就是函数,按需拼装

const canFly = (state) => ({
  fly() { return `${state.name} 起飞了`; },
});

const canSwim = (state) => ({
  swim() { return `${state.name} 下水了`; },
});

const createDuck = (name) => {
  const state = { name };
  return Object.assign({}, state, canFly(state), canSwim(state));
};

const duck = createDuck('唐老鸭');
duck.fly(); // "唐老鸭 起飞了"
duck.swim(); // "唐老鸭 下水了"

陷阱六:模式的“名义化”

最后一个是软性的,但危害不小:为了在代码评审、技术分享、简历里提到某个模式而使用它。这会导致代码里出现大量“名字很唬人但实际很浅”的抽象:AbstractFactoryProvider 里面其实就是一个 switch,ObserverManager 里面其实就是三个回调。

判断一个抽象是否值得存在,有一个朴素的标准:如果删掉它,代码会不会变长、变乱、变难改?如果答案是“不会”,那它就没有存在价值。

八、实战重构:从 if-else 地狱到策略表

下面是一段真实项目里常见的导出功能代码。它要支持 CSV、Excel、PDF、JSON 四种格式,每种格式的生成逻辑各不相同,还要处理权限、文件大小限制、失败重试。点击按钮对比改造前后的代码。

async function exportData(type, rows, options) {
  if (!hasPermission('export')) {
    throw new Error('无导出权限');
  }

  let blob;

  if (type === 'csv') {
    const header = Object.keys(rows[0]).join(',');
    const body = rows.map((r) => Object.values(r).join(',')).join('\n');
    blob = new Blob([header + '\n' + body], { type: 'text/csv' });
  } else if (type === 'json') {
    blob = new Blob([JSON.stringify(rows, null, 2)], { type: 'application/json' });
  } else if (type === 'excel') {
    const { utils, write } = await import('xlsx');
    const sheet = utils.json_to_sheet(rows);
    const book = utils.book_new();
    utils.book_append_sheet(book, sheet, 'Sheet1');
    const buf = write(book, { type: 'array' });
    blob = new Blob([buf], { type: 'application/vnd.ms-excel' });
  } else if (type === 'pdf') {
    const { jsPDF } = await import('jspdf');
    const doc = new jsPDF();
    // ... 几十行 PDF 排版逻辑
    blob = doc.output('blob');
  } else {
    throw new Error('不支持的导出格式');
  }

  if (blob.size > 50 * 1024 * 1024) {
    throw new Error('文件过大');
  }

  const url = URL.createObjectURL(blob);
  const a = document.createElement('a');
  a.href = url;
  a.download = `export-${Date.now()}.${type}`;
  a.click();
  URL.revokeObjectURL(url);
  return blob.size;
}

// 问题:一个函数 80 行,四种格式的代码彼此交错
// 新增一种格式要动主流程;PDF 逻辑无法单独测试

改造带来的收益

可测试 每个 exporter.build 都是纯函数,可以脱离 DOM 单独跑单元测试
可扩展 新增格式只需注册,不需要动主流程,符合开闭原则
可复用 权限校验、大小限制变成通用装饰器,其他导出入口也能用
按需加载 xlsx、jspdf 这类大依赖只在对应格式被选中时才下载
代码量 主流程从 80 行降到 15 行,分支复杂度从 O(n) 降到 O(1)

这个改造用到了三个模式:策略模式(导出器表)、装饰器模式(权限与大小限制)、外观模式(exportData 对外只暴露一个函数)。三个模式加起来不到 60 行代码,却解决了一个 80 行 if-else 的所有痛点——这才是模式应有的性价比。

九、模式速查表与使用清单

最后把全文浓缩成一份可以贴在工位上的速查表。左边是“你遇到什么问题”,右边是“可以先看看哪个模式”。

你遇到的问题 可以先看看 前端典型例子
一个 if-else 分支越来越长 策略模式 表单校验、计费规则、导出格式
两个模块互相引用,循环依赖 发布订阅 / 中介者 全局事件总线、表单字段联动
需要给一个函数加日志/缓存/重试 装饰器模式 请求拦截、记忆化、重试
老代码的接口和新代码对不上 适配器模式 接入第三方 SDK、旧 API 迁移
对象的行为随状态变化 状态模式 请求生命周期、播放器、向导
需要撤销/重做 命令模式 / 备忘录 编辑器、画板、表单草稿
想在读取属性时做点手脚 代理模式 响应式系统、懒加载、访问控制
多个环节依次处理同一个请求 职责链模式 中间件、拦截器、审批流
需要保证全局只有一个实例 单例 / 模块作用域 配置、日志、连接池
大量重复的对象占用内存 享元模式 图标池、虚拟列表、配置共享
创建对象的过程很复杂 工厂 / 建造者 插件系统、查询构造器
树形结构需要统一处理 组合模式 文件树、菜单、虚拟 DOM

落地清单

  • 先写三遍,再抽象。同一段逻辑复制粘贴到第三次时,才是抽象的最佳时机。
  • 能用一个函数解决的,不要用一个类。JavaScript 的函数是一等公民,别浪费这个优势。
  • 策略表优先于 switch。能用对象查表的地方,不要写分支。
  • 装饰器要保持接口不变。输入输出与原函数一致,才能任意组合、层层叠加。
  • 订阅了就要能取消。让 on 返回取消函数,或在 unmounted 里统一注销。
  • 单例要少用。能用依赖注入的地方,优先用参数传递。
  • 继承不超过三层。超过就考虑组合或 Mixin。
  • Proxy 不要滥用。它的开销是普通属性访问的数倍,别放进热点路径。
  • 模式名不是目的。代码里出现 Factory、Strategy 这些词,不代表设计得好。
  • 衡量标准只有一个:改需求时,需要动几处代码?答案越少,抽象越成功。
// 一份可以常备的"模式工具箱"骨架

// 1. 策略表 —— 消灭分支
const strategies = { a: (x) => ..., b: (x) => ... };
const run = (key, ...args) => strategies[key]?.(...args);

// 2. 装饰器 —— 横向增强
const withX = (fn) => function (...a) { ...; return fn.apply(this, a); };

// 3. 事件总线 —— 纵向解耦
const bus = new EventEmitter();
const off = bus.on('evt', handler);

// 4. 中间件 —— 流程编排
const app = compose([auth, log, retry, handler]);

// 5. 惰性 —— 按需初始化
let _x; const getX = () => (_x ??= createX());

// 6. 组合 —— 树形统一
const total = (node) =>
  node.children?.reduce((s, c) => s + total(c), 0) ?? node.size;

设计模式最反直觉的一点是:它最大的价值不在于“用”,而在于“不用”。当你理解了二十三种模式各自要解决什么问题、代价是什么,你才真正拥有了判断力——知道在这个场景下该抽象,在那个场景下该老老实实写三行 if-else。

就像那句流传很广的话:模式是给那些已经解决了问题的人,用来解释他们为什么这么做的语言。先解决问题,再谈模式。当你的代码第二次、第三次因为同样的原因变得难以维护时,那个合适的模式自然会浮现出来。