23个JavaScript设计模式实战

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

1994 年,GoF 四人组在《设计模式:可复用面向对象软件的基础》里归纳了 23 种模式。三十多年过去,这本书依然是软件工程史上被引用最多的著作之一,而它描述的 23 个名字,也成了无数开发者技术面试里的必答题。但真正的问题是:当你把这 23 个名字背得滚瓜烂熟之后,你会在项目里用几个?答案是——大部分人一个都不用,或者用了却用错。原因很简单:这 23 种模式诞生于 C++ 与 Smalltalk 的世界,而 JavaScript 是一门函数是一等公民、没有真正意义上的类、天生事件驱动、异步无处不在的语言。很多模式在这门语言里已经被语言特性“吃掉”了,还有些模式需要彻底改写才能用得上。本篇 70 分钟长文,把这 23 个模式一个不落地拿出来,每一个都配上前端真实场景、可直接运行的代码、以及“这个模式在这里值不值得用”的判断。我们不背定义,只做实战:每一个模式都回答三个问题——它解决什么问题、在 JS 里怎么写、什么时候不该写。读完你未必会立刻用上 23 个模式,但你一定能建立起一套属于自己的抽象判断力。

一、先建立坐标系:23 个模式各自站在哪里

在动手之前,我们需要一张地图。GoF 按“模式的目的”把 23 种模式分成三组,这个分类方式至今依然是最清晰的入口。点击下面的卡片,展开每一类的说明:

5
创建型
Creational
关注对象怎么来。把“new 什么、怎么 new、什么时候 new”从业务逻辑里抽离。包含:单例、工厂方法、抽象工厂、建造者、原型。
7
结构型
Structural
关注对象怎么组合。在不改动已有类的前提下,重新组装出更大的结构。包含:适配器、装饰器、代理、外观、组合、享元、桥接。
11
行为型
Behavioral
关注对象怎么通信。把“谁通知谁、谁决定谁、谁记住谁”安排清楚。包含:观察者、策略、状态、命令、迭代器、职责链、中介者、备忘录、模板方法、访问者、解释器。
23
全景
Total
5 + 7 + 11 = 23。本文接下来会按这三条主线,把 23 个模式逐个拆开,每个都配前端实战代码与取舍判断。

下面是全文的目录地图。你可以把它当成阅读索引,也可以当成日后查阅的速查表:

编号 模式 一句话概括 前端高频场景
01单例全局唯一实例配置中心、日志器
02工厂方法把创建延迟到子类/注册表通知系统、插件注册
03抽象工厂一整个产品族的创建多主题组件库、多端适配
04建造者分步组装复杂对象请求配置、查询构造器
05原型以对象为模板克隆默认配置、深拷贝
06适配器让不兼容的接口对上话第三方 SDK、旧 API 迁移
07装饰器不改原函数,动态加料日志、缓存、重试、鉴权
08代理控制对对象的访问响应式、懒加载、缓存代理
09外观给复杂系统一张简单脸请求库、工具库、兼容层
10组合树形结构统一处理文件树、菜单、虚拟 DOM
11享元共享重复的内部状态图标池、虚拟列表
12桥接拆分多个独立变化的维度通知×渠道、主题×组件
13观察者状态变化通知订阅者DOM 事件、响应式依赖
14策略可互换算法的封装校验、计费、导出格式
15状态行为随内部状态改变请求生命周期、播放器
16命令把操作封装成对象撤销重做、批量任务
17迭代器统一遍历接口分页、流式数据
18职责链多环节依次处理请求中间件、拦截器
19中介者用中心协调多对象通信表单联动、组件通信
20备忘录状态可保存可回滚草稿、快照、历史版本
21模板方法流程固定,步骤可变请求生命周期、构建流程
22访问者把操作从结构中分离AST 遍历、数据导出
23解释器为小型语言定义文法表达式引擎、权限规则

三个必须先说清楚的前提

前提一 模式不是库,也不是算法,它只是一段“结构描述”。同一个策略模式,你可以用类写,也可以用一张对象表写。
前提二 不要把 GoF 的 Java 实现照搬进 JavaScript。函数是一等公民、闭包、原型链、模块系统,都在替我们承担工作。
前提三 模式从来不是免费的。每一个模式都带来一层间接,间接就是阅读成本。用之前先问:删掉它,代码会变乱吗?

好,地图画完了,接下来我们逐个击破。每一节的结构都是一样的:先看它解决什么问题,再看 JavaScript 里怎么写,最后判断这个模式在你手上值不值。

模式 01 · 单例模式(Singleton)

单例保证一个类只有一个实例,并提供一个全局访问点。在前端,你其实一直在用它,只是从未意识到。

问题某些资源全局只应该有一份,多次创建会导致状态不一致或资源浪费
方案把实例缓存起来,后续访问返回同一个引用
代价依赖被隐藏,单元测试无法注入替身
// 方式一:ES Module 天然就是单例
// config.js —— 无论被 import 多少次,拿到的都是同一个引用
export const config = {
  apiBase: '/api/v2',
  timeout: 8000,
  retry: 2,
};

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

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

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

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

  static get instance() {
    // 只有左侧为 null / undefined 时才赋值
    return (Database.#instance ??= new Database());
  }

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

单例真正的代价:把依赖藏起来了

单例的问题不在于“只有一个”,而在于它让依赖关系从函数签名里消失了。看下面这段代码:

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

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

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

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

推荐写法
function sendEmail(to, mailer = defaultMailer) { ... }
默认参数保留了便利性,同时允许测试注入替身。
问题写法
function sendEmail(to) { return Mailer.getInstance().send(to); }
依赖被藏进函数体,测试无从下手。

模式 02 · 工厂方法模式(Factory Method)

工厂方法把“创建哪种对象”的决策从调用方手里拿走,交给一个专门的函数或子类。它最朴素的价值是:调用方不需要知道有哪些类型存在。

// 朴素写法:每加一种类型都要改这个函数
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',
  send: (msg) => mailApi.send(o.to, msg),
}));

register('sms', (o) => ({
  channel: 'sms',
  send: (msg) => smsApi.send(o.phone, msg),
}));

const notifier = create('email', { to: 'a@b.com' });
notifier.send('您的订单已发货');

这个“注册表 + 工厂函数”结构在前端非常常见:富文本编辑器的插件系统、图表库的图表类型、表单引擎的动态字段渲染、低代码平台的物料加载,底层都是同一套逻辑。

工厂方法的三种形态

// 形态一:简单工厂(一个函数 + switch)
const create1 = (type) => {
  switch (type) {
    case 'a': return new A();
    case 'b': return new B();
  }
};
// 形态二:注册表工厂(推荐)
const map = new Map();
const define = (t, f) => map.set(t, f);
const create2 = (t, o) => map.get(t)?.(o);
// 形态三:类继承式工厂方法(GoF 原味)
class Dialog {
  // 子类必须实现这个"工厂方法"
  createButton() { throw new Error('必须由子类实现'); }

  render() {
    const button = this.createButton(); // 调用工厂方法
    button.mount(this.el);
  }
}

class IOSDialog extends Dialog {
  createButton() { return new IOSButton(); }
}

class AndroidDialog extends Dialog {
  createButton() { return new AndroidButton(); }
}

在 JavaScript 里,形态二(注册表)几乎总是优于形态三(继承)。因为继承式工厂方法需要你先有一个类层次,而注册表只需要一个 Map,而且天然支持运行时动态注册——这正是插件系统需要的能力。

模式 03 · 抽象工厂模式(Abstract Factory)

如果说工厂方法解决的是“创建一个对象”,那么抽象工厂解决的是“创建一族相关对象”。它保证同一族的产品搭配在一起使用时不会出错。

问题多主题/多端场景下,按钮、输入框、弹窗必须成套切换,不能混搭
方案把一组创建方法收进同一个工厂对象,切换工厂即切换整套外观
代价产品族增加时,每个工厂都要同步实现新方法
// 定义"产品族":一套 UI 至少包含按钮和输入框
const lightTheme = {
  name: 'light',
  createButton: (text) => ({
    render: () => `<button class="btn btn-light">${text}</button>`,
  }),
  createInput: (placeholder) => ({
    render: () => `<input class="input input-light" placeholder="${placeholder}">`,
  }),
  createModal: (title) => ({
    render: () => `<div class="modal modal-light">${title}</div>`,
  }),
};

const darkTheme = {
  name: 'dark',
  createButton: (text) => ({
    render: () => `<button class="btn btn-dark">${text}</button>`,
  }),
  createInput: (placeholder) => ({
    render: () => `<input class="input input-dark" placeholder="${placeholder}">`,
  }),
  createModal: (title) => ({
    render: () => `<div class="modal modal-dark">${title}</div>`,
  }),
};

// 抽象工厂的"工厂注册表"
const themes = { light: lightTheme, dark: darkTheme };

function createUI(themeName) {
  const theme = themes[themeName];
  if (!theme) throw new Error(`未知主题:${themeName}`);
  return theme;
}

// 使用:整套 UI 一起切换,绝不会出现"深色按钮配浅色输入框"
const ui = createUI('dark');
ui.createButton('提交').render();
ui.createInput('请输入').render();

前端真实场景:一套代码适配多端

抽象工厂最典型的应用,是同一套业务逻辑跑在不同平台上。比如一个表单引擎,在 Web 上渲染成 <input>,在小程序上渲染成 <van-field>,在 React Native 上渲染成 <TextInput>:

const webFactory = {
  createForm: (schema) => new WebForm(schema),
  createField: (field) => new WebField(field),
  createValidator: () => new WebValidator(),
};

const miniFactory = {
  createForm: (schema) => new MiniForm(schema),
  createField: (field) => new MiniField(field),
  createValidator: () => new MiniValidator(),
};

// 业务层只依赖抽象工厂接口
function buildForm(schema, factory) {
  const form = factory.createForm(schema);
  schema.fields.forEach((f) => {
    form.add(factory.createField(f));
  });
  return form;
}
值得用
产品确实存在“必须成套出现”的约束,且确实有 2 个以上平台/主题需要切换。
不值得用
只有一个主题、一个平台,却提前建了抽象工厂——这层间接永远不会被用到。

模式 04 · 建造者模式(Builder)

当一个对象的配置项超过四五个、且大部分是可选的,构造函数就会变成灾难。建造者用链式调用把“配置过程”和“最终产出”分开。

class RequestBuilder {
  #config = {
    url: '',
    method: 'GET',
    headers: {},
    params: {},
    body: null,
    timeout: 10000,
    retry: 0,
    credentials: 'same-origin',
  };

  url(u) { this.#config.url = u; return this; }
  method(m) { this.#config.method = m.toUpperCase(); return this; }
  header(k, v) { this.#config.headers[k] = v; return this; }
  param(k, v) { this.#config.params[k] = v; return this; }
  json(data) {
    this.#config.body = JSON.stringify(data);
    this.#config.headers['Content-Type'] = 'application/json';
    return this;
  }
  timeout(ms) { this.#config.timeout = ms; return this; }
  retry(n) { this.#config.retry = n; return this; }
  auth(token) {
    this.#config.headers.Authorization = `Bearer ${token}`;
    return this;
  }

  // 产出:在这里做一次完整校验
  build() {
    const c = this.#config;
    if (!c.url) throw new Error('url 是必填项');
    if (c.method === 'GET' && c.body) {
      throw new Error('GET 请求不能携带 body');
    }
    const query = new URLSearchParams(c.params).toString();
    return {
      url: query ? `${c.url}?${query}` : c.url,
      init: {
        method: c.method,
        headers: c.headers,
        body: c.body,
        credentials: c.credentials,
        signal: AbortSignal.timeout(c.timeout),
      },
      retry: c.retry,
    };
  }
}

// 使用:读起来像一句话
const req = new RequestBuilder()
  .url('/api/orders')
  .method('post')
  .auth(token)
  .param('page', 1)
  .json({ items: ['a', 'b'] })
  .timeout(5000)
  .retry(2)
  .build();

建造者的精髓与陷阱

  • 精髓是每一步都返回 this,让调用链形成一句可读的“配置语句”。
  • 陷阱是过程非原子:调用方漏掉必填步骤时不会立即报错,只有在 build() 时才暴露。所以 build() 里必须做完整校验。
  • 另一个陷阱是复用:如果建造者实例被缓存并重复 build(),内部状态会互相污染。要么每次 build() 后重置,要么干脆不缓存。

前端里建造者的典型应用:请求配置构造、SQL/查询表达式构造、Canvas 绘图指令链、复杂动画时间线配置、测试数据的 fixture 构造。

模式 05 · 原型模式(Prototype)

在 JavaScript 里,原型模式不是“一种可选方案”,而是语言本身的继承机制。理解它,比理解其他 22 个模式加起来都重要。

// 用 Object.create 直接建立委托关系
const baseUser = {
  role: 'guest',
  greet() { return `你好,我是 ${this.name}`; },
};

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

// 区分自有属性与继承属性
Object.hasOwn(alice, 'name'); // true
Object.hasOwn(alice, 'role'); // false
'role' in alice;            // true(含原型链)

// 浅拷贝:只复制自有可枚举属性
const copy1 = { ...alice };
const copy2 = Object.assign({}, alice);

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

为什么 structuredClone 是一个里程碑

在它出现之前,大家用 JSON.parse(JSON.stringify(obj)) 做深拷贝,这个方法有一堆坑:

数据类型 JSON.parse(JSON.stringify()) structuredClone()
undefined丢失保留
Date变字符串保留 Date
Map / Set变空对象保留
RegExp变空对象保留
循环引用直接报错正确处理
函数 / DOM 节点丢失抛错
自定义类实例降级为普通对象降级为普通对象

注意最后一行:structuredClone 不会保留原型链。如果你克隆一个 class User 的实例,得到的是一个普通对象,方法全部丢失。这是很多人第一次用时会踩的坑。

原型模式在前端的三种实战形态

默认配置以一份 baseConfig 为原型,创建出各个环境的具体配置,未覆盖的字段自动继承
对象池克隆一个模板对象再修改少量字段,比重新构造快得多(大量重复元素渲染时有效)
状态快照用深拷贝保存不可变快照,配合备忘录模式实现撤销重做
// 实战:多环境配置的原型继承
const baseConfig = {
  timeout: 10000,
  retry: 2,
  cache: true,
  logLevel: 'warn',
  endpoints: { user: '/api/user', order: '/api/order' },
};

const devConfig = Object.create(baseConfig);
devConfig.logLevel = 'debug';
devConfig.endpoints = { ...baseConfig.endpoints, mock: '/mock' };

// devConfig.timeout 通过原型链读到 10000
// devConfig.logLevel 是自有属性 'debug'

// 转成普通对象(扁平化,避免后续误改原型)
const flat = { ...devConfig };
const deep = structuredClone(flat);

模式 06 · 适配器模式(Adapter)

适配器让两个本来无法协作的接口能够一起工作。它是“接老代码、接第三方 SDK、做渐进式迁移”时最实用的模式。

// 历史遗留代码,不能改也不想改
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('服务已启动');

实战:一个可替换的存储适配器

这是一个在真实项目里反复出现的需求——把存储层抽象出来,让业务代码不关心数据到底存在 localStorage、IndexedDB 还是内存里:

// 统一的存储接口:get / set / remove / clear
// 适配器一:localStorage(同步,容量小)
const localStorageAdapter = {
  async get(key) {
    const raw = localStorage.getItem(key);
    return raw ? JSON.parse(raw) : null;
  },
  async set(key, value) {
    localStorage.setItem(key, JSON.stringify(value));
  },
  async remove(key) { localStorage.removeItem(key); },
  async clear() { localStorage.clear(); },
};

// 适配器二:内存(用于 SSR / 单元测试)
function createMemoryAdapter() {
  const map = new Map();
  return {
    async get(key) { return map.has(key) ? map.get(key) : null; },
    async set(key, value) { map.set(key, structuredClone(value)); },
    async remove(key) { map.delete(key); },
    async clear() { map.clear(); },
  };
}

// 业务代码只依赖统一接口
function createUserRepository(storage) {
  return {
    async save(user) {
      await storage.set(`user:${user.id}`, user);
    },
    async find(id) {
      return storage.get(`user:${id}`);
    },
  };
}

// 生产环境用 localStorage,测试环境注入内存适配器
const repo = createUserRepository(
  isTest ? createMemoryAdapter() : localStorageAdapter
);

适配器的价值在于把丑陋隔离在一处。将来如果换掉底层实现(比如从 localStorage 换成 IndexedDB),只需要写一个新的适配器,业务代码一行都不用动。

适配器适合
接口不兼容、第三方 SDK 封装、旧系统迁移、多实现可切换。
适配器不适合
只是想把一个函数换个名字——那就直接重命名,不需要多一层。

模式 07 · 装饰器模式(Decorator)

装饰器在不修改原函数的前提下,给它增加额外行为。它的核心约定是接口不变——输入输出与原函数一致,只是额外做了点什么。正因为如此,装饰器才能任意组合、层层包裹。

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

// 装饰器二:结果缓存(记忆化)
function memoize(fn, keyFn = (...args) => JSON.stringify(args)) {
  const cache = new Map();
  return function (...args) {
    const key = keyFn(...args);
    if (cache.has(key)) return cache.get(key);
    const result = fn.apply(this, args);
    cache.set(key, result);
    return result;
  };
}

// 装饰器三:失败重试(带指数退避)
function withRetry(fn, times = 3, baseDelay = 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;
        // 指数退避:300ms → 600ms → 1200ms
        await sleep(baseDelay * 2 ** i);
      }
    }
    throw lastError;
  };
}

// 装饰器四:超时控制
function withTimeout(fn, ms) {
  return async function (...args) {
    const controller = new AbortController();
    const timer = setTimeout(() => controller.abort(), ms);
    try {
      return await fn.apply(this, [...args, controller.signal]);
    } finally {
      clearTimeout(timer);
    }
  };
}

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

TC39 装饰器语法:语言层面的支持

装饰器已经从“民间技巧”升级为语言特性(TC39 Stage 3)。配合 TypeScript 或 Babel,你可以这样写:

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

function logged(original, context) {
  return function (...args) {
    console.log(`→ ${context.name}`, args);
    const out = original.apply(this, args);
    console.log(`← ${context.name}`, out);
    return out;
  };
}

class Cart {
  @logged
  @readonly
  checkout() { /* ... */ }
}

// 注意顺序:离方法越近的先执行

写装饰器时的两个铁律

铁律一必须用 fn.apply(this, args) 调用原函数,否则对象方法被装饰后会丢失 this
铁律二必须原样返回原函数的返回值(包括 undefined),否则调用链会断
加分项用 Object.defineProperty 把原函数的 name 和 length 复制到新函数上,方便调试
// 一个"完整"的装饰器工厂模板
function decorate(fn, wrapper) {
  const wrapped = wrapper(fn);
  // 保留原函数的元信息,调试栈里能看到真实名字
  Object.defineProperty(wrapped, 'name', { value: fn.name });
  Object.defineProperty(wrapped, 'length', { value: fn.length });
  return wrapped;
}

模式 08 · 代理模式(Proxy)

代理模式为其他对象提供一个“替身”,以控制对这个对象的访问。ES6 的 Proxy 让这个模式从“手动写一层包装”升级为“语言级拦截”。Vue 3 的响应式系统就是建立在 Proxy 之上的。

// 一个极简的响应式实现(Vue 3 的核心思想)
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;
    },
    deleteProperty(target, key) {
      const ok = Reflect.deleteProperty(target, key);
      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"

代理的四种经典变体

// ① 虚拟代理:懒加载
function lazyImage(src) {
  let real = null;
  return new Proxy({}, {
    get(_, prop) {
      if (!real) real = createImage(src);
      return real[prop];
    },
  });
}
// ② 缓存代理:计算结果复用
function cached(fn) {
  const cache = new Map();
  return new Proxy(fn, {
    apply(target, thisArg, args) {
      const key = JSON.stringify(args);
      if (cache.has(key)) return cache.get(key);
      const r = Reflect.apply(target, thisArg, args);
      cache.set(key, r);
      return r;
    },
  });
}
// ③ 保护代理:访问控制
function guarded(obj, allowed) {
  return new Proxy(obj, {
    get(target, key) {
      if (!allowed.includes(key)) {
        throw new Error(`无权访问 ${String(key)}`);
      }
      return target[key];
    },
    set(target, key, value) {
      if (!allowed.includes(key)) {
        throw new Error(`无权修改 ${String(key)}`);
      }
      target[key] = value;
      return true;
    },
  });
}
// ④ 默认值代理:消灭判空代码
function withDefault(obj, fallback) {
  return new Proxy(obj, {
    get(target, key) {
      if (key in target) return target[key];
      return typeof fallback === 'function'
        ? fallback(key)
        : fallback;
    },
  });
}

const config = withDefault(userConfig, '');
config.theme; // 未配置时返回 '' 而不是 undefined

代理的性能警告

Proxy 的开销明显高于普通属性访问,而且它对整个对象生效,而不是某个属性。所以:

  • 不要为了“方便”把整个大型数据结构都代理掉——只代理真正需要拦截的部分。
  • 不要在每帧执行几千次的渲染循环里穿透多层 Proxy。
  • 对于确定不会变化的对象,用 Object.freeze() 而不是代理。
  • 如果只是需要一个只读视图,考虑用 Object.freeze 或直接返回副本。

模式 09 · 外观模式(Facade)

外观模式为复杂的子系统提供一个简化的统一接口。它不是“增加功能”,而是“让已有功能更好用”。前端最典型的外观就是各种工具库。

// 把一堆底层 API 包成一个简单对象
const dom = {
  on(el, type, handler, options) {
    el.addEventListener(type, handler, options);
    // 返回取消函数,调用方不必保存 handler 引用
    return () => el.removeEventListener(type, handler, options);
  },

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

  removeClass(el, ...classes) {
    el.classList.remove(...classes);
  },

  // 把 DOMContentLoaded / 已完成两种时机统一
  ready(fn) {
    if (document.readyState !== 'loading') fn();
    else document.addEventListener('DOMContentLoaded', fn, { once: true });
  },

  // 把 fetch 的三步样板(配置、判状态、解析)压成一步
  async request(url, options = {}) {
    const res = await fetch(url, {
      headers: { 'Content-Type': 'application/json' },
      ...options,
    });
    if (!res.ok) {
      const text = await res.text().catch(() => '');
      throw new Error(`HTTP ${res.status}: ${text || res.statusText}`);
    }
    return res.json();
  },

  // 滚动的各种兼容写法统一
  scrollToTop(smooth = true) {
    window.scrollTo({ top: 0, behavior: smooth ? 'smooth' : 'auto' });
  },
};

外观 vs 适配器:很多人会搞混

维度 外观模式 适配器模式
意图 简化接口,让复杂系统更好用 转换接口,让不兼容的双方能协作
对象数量 一个外观包装多个子系统 一个适配器包装一个被适配者
接口变化 接口是新的、简化的 接口是既定的、目标端要求的
前端例子 axios、dayjs、jQuery 把 legacyLogger 包成 logger

一个实用的判断方法:如果你写这层包装是为了“让调用方少写几行”,那是外观;如果是为了“让两个对不上的接口能接上”,那是适配器。

模式 10 · 组合模式(Composite)

组合模式让“单个对象”和“对象的集合”拥有一致的接口。调用方不需要判断当前处理的是叶子还是树枝,直接递归调用即可。

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;
  }

  remove(child) {
    const i = this.children.indexOf(child);
    if (i > -1) this.children.splice(i, 1);
    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();

前端实战:权限菜单树

组合模式在权限系统里几乎无处不在。下面这个例子,权限节点和权限分组拥有同一套 check() 接口,调用方只需要在根节点上调用一次:

// 叶子:具体权限点
function createPermission(code) {
  return {
    code,
    check: (owned) => owned.includes(code),
    collect: () => [code],
  };
}

// 树枝:权限分组(requireAll 表示"与"还是"或")
function createGroup(children, requireAll = true) {
  return {
    check(owned) {
      return requireAll
        ? children.every((c) => c.check(owned))
        : children.some((c) => c.check(owned));
    },
    collect: () => children.flatMap((c) => c.collect()),
  };
}

const adminPanel = createGroup([
  createPermission('user:read'),
  createPermission('user:write'),
  createGroup([
    createPermission('role:read'),
    createPermission('role:write'),
  ], false),
]);

adminPanel.check(['user:read', 'user:write', 'role:read']);
// true —— 外层需要全部满足,内层只需要满足一个

adminPanel.collect();
// ['user:read', 'user:write', 'role:read', 'role:write']

组合模式的威力在于递归的优雅。调用方只需要写一次逻辑,树有多深都不用管。虚拟 DOM 的 diff、React 的组件树、CSS 选择器的匹配算法,底层都是组合模式的思想。

模式 11 · 享元模式(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), // 共享引用
}));

// 十个标记共用同一个 icon 对象,内存占用从 O(n) 降到 O(类型数)

享元的另一个名字:虚拟列表

虚拟列表是享元思想在 DOM 层面的极致应用。它不共享对象,而是共享 DOM 节点——无论列表有多少项,页面上始终只有十几个节点在循环复用:

// 极简虚拟列表的核心思路
function createVirtualList(container, items, rowHeight) {
  const viewportHeight = container.clientHeight;
  const visibleCount = Math.ceil(viewportHeight / rowHeight) + 2;

  // 只创建"够用"的那几个节点
  const pool = [];
  for (let i = 0; i < visibleCount; i++) {
    const el = document.createElement('div');
    el.className = 'row';
    el.style.position = 'absolute';
    el.style.height = rowHeight + 'px';
    container.appendChild(el);
    pool.push(el);
  }

  // 占位容器撑起真实高度,保证滚动条正确
  container.style.position = 'relative';
  container.style.height = items.length * rowHeight + 'px';

  function render() {
    const start = Math.floor(container.scrollTop / rowHeight);
    pool.forEach((el, i) => {
      const data = items[start + i];
      if (!data) { el.style.display = 'none'; return; }
      el.style.display = '';
      el.style.transform = `translateY(${(start + i) * rowHeight}px)`;
      el.textContent = data.text;
    });
  }

  container.addEventListener('scroll', render, { passive: true });
  render();
}

注意这里用 transform: translateY() 而不是 top 来定位行。因为 transform 由合成器处理,不触发重排;而修改 top 会触发 Layout。这一点在长列表快速滚动时差异非常明显。

适合地图标记、图标库、长列表、大量重复的配置对象
注意享元的内部状态必须是不可变的,否则一个地方改了会影响所有使用者
替代如果对象数量不大(几百个以内),共享带来的复杂度可能不划算

模式 12 · 桥接模式(Bridge)

桥接模式把“抽象”与“实现”拆开,让它们可以各自独立变化。判断是否需要桥接有一个简单信号:类名里出现了多个维度。

// 反例:通知类型 × 发送渠道 = 2 × 3 = 6 个类
// EmailNormalNotification
// EmailUrgentNotification
// SmsNormalNotification
// SmsUrgentNotification
// ... 新增一个渠道,类数量翻倍

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

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

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

class DigestNotification extends Notification {
  constructor(channel, maxItems = 5) {
    super(channel);
    this.maxItems = maxItems;
  }
  send(to, messages) {
    const body = messages.slice(0, this.maxItems).join('\n');
    return super.send(to, body);
  }
}

// 组合:2 个通知类型 + 3 个渠道 = 2 + 3 = 5 个单元
new UrgentNotification(channels.sms).send('138****', '服务异常');
new DigestNotification(channels.email).send('a@b.com', ['A', 'B', 'C']);

前端实战:主题 × 组件

组件库里的“主题”和“组件”就是两个天然独立的维度。用桥接的方式组织,可以避免“深色按钮”“深色输入框”“浅色按钮”“浅色输入框”这种笛卡尔积式的类名爆炸:

// 维度一:主题(实现)
const themes = {
  light: { bg: '#fff', fg: '#0f172a', border: '#cbd5e1' },
  dark:  { bg: '#0f172a', fg: '#e2e8f0', border: '#334155' },
};

// 维度二:组件(抽象)
function createButton(theme) {
  return {
    render(text) {
      return `<button style="background:${theme.bg};color:${theme.fg};border:1px solid ${theme.border}">${text}</button>`;
    },
  };
}

function createInput(theme) {
  return {
    render(placeholder) {
      return `<input placeholder="${placeholder}" style="background:${theme.bg};color:${theme.fg}">`;
    },
  };
}

// 任意组合:新增主题不影响组件,新增组件不影响主题
createButton(themes.dark).render('提交');
createInput(themes.light).render('请输入');

桥接与策略的区别在于:策略是“运行时选一个算法”,桥接是“两个维度长期共存、各自演化”。策略通常是单向选择,桥接是双向的组合关系。

模式 13 · 观察者模式(Observer)

观察者模式定义了对象间的一对多依赖:当一个对象状态改变时,所有依赖它的对象都会收到通知。前端作为事件驱动的 UI 层,几乎每一天都在和它打交道。

class Subject {
  #observers = new Set();

  attach(observer) {
    this.#observers.add(observer);
    return () => this.#observers.delete(observer);
  }

  notify(data) {
    // 复制一份再遍历,防止回调中修改集合导致跳过
    [...this.#observers].forEach((o) => {
      try { o.update(data); }
      catch (err) { console.error('观察者出错:', err); }
    });
  }
}

// 使用
const store = new Subject();
const off = store.attach({
  update(state) { console.log('视图更新', state); },
});

store.notify({ count: 1 });
off(); // 取消订阅

观察者 vs 发布订阅:一字之差,结构完全不同

// 观察者:Subject 直接持有 Observer
// 双方互相知道对方存在
subject.attach(observerA);
subject.attach(observerB);
subject.notify(data);
// 发布订阅:中间隔着事件总线
双方只认识事件名,互不认识
bus.on('order:paid', handlerA);
bus.on('order:paid', handlerB);
bus.emit('order:paid', data);

下面是一个生产可用的 EventEmitter 实现。注意三个细节:用 Set 而不是数组可以自动去重;on 返回取消函数比手动记录 off 更不容易出错;单个 handler 抛错不应该中断其他 handler。

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();
  }
}

内存泄漏:观察者模式的头号杀手

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

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

// 正解一:利用 on 返回的取消函数
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 });
observer.observe(el, { signal: controller.signal });

// 一行代码注销全部,不需要逐个记录
controller.abort();

AbortController 最初是为 fetch 设计的,现在已经被 addEventListener、IntersectionObserver、ResizeObserver 等一大批 API 支持,是当前最优雅的“批量注销”方案。

模式 14 · 策略模式(Strategy)

策略模式把一组可互换的算法各自封装,让它们可以在运行时切换。这是前端重构中最常用、见效最快的一个模式——它几乎总是能把一坨 if-else 变成一张清爽的查表。

// 改造前:每加一种规则就改一次函数
function getPrice(user, price) {
  if (user.level === 'svip') return price * 0.6;
  else if (user.level === 'vip') return price * 0.8;
  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 位字母、数字或下划线',
};

// 约定:成功返回 true,失败返回错误消息字符串
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,失败返回错误消息字符串。这比返回布尔值实用得多——调用方可以直接把消息显示到界面上,不需要再维护一张错误码映射表。

策略模式在前端的其他落点

导出CSV / Excel / PDF / JSON,每种格式一个 build 函数
排序按价格、按销量、按上架时间,比较函数放在一张表里
计费按次、按量、包月,不同的计价算法独立封装
埋点不同渠道的上报格式不同,用策略表统一调度

模式 15 · 状态模式(State)

状态模式与策略模式结构相似,但意图不同:策略是外部选择算法,状态是内部根据条件自动迁移。它最大的收益是把散落在各处的 if (state === 'xxx') 收敛成一张状态表。

const states = {
  idle: {
    submit(ctx) {
      ctx.transition('loading');
      ctx.request();
    },
    label: '提交',
    disabled: false,
  },

  loading: {
    submit() { /* 加载中忽略点击 */ },
    label: '提交中…',
    disabled: true,
  },

  success: {
    submit(ctx) { ctx.transition('idle'); },
    label: '已完成',
    disabled: false,
  },

  error: {
    submit(ctx) {
      ctx.transition('loading');
      ctx.request();
    },
    label: '重试',
    disabled: false,
  },
};

class SubmitMachine {
  #state = states.idle;
  #listeners = new Set();

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

  submit() {
    this.#state.submit(this);
  }

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

  subscribe(fn) {
    this.#listeners.add(fn);
    return () => this.#listeners.delete(fn);
  }

  #notify() {
    this.#listeners.forEach((fn) => fn(this));
  }

  async request() {
    try {
      await api.submit();
      this.transition('success');
    } catch {
      this.transition('error');
    }
  }
}

什么时候该从 if-else 升级到状态机

信号 说明 建议
状态数量 ≤ 3 分支很少,一眼能看完 if-else 足够
状态数量 4 ~ 6 分支开始难以追踪 状态表
状态数量 > 6 迁移关系复杂,容易漏改 状态机 / XState
存在非法迁移 比如 loading 状态下不该允许再次提交 状态机
需要可持久化 / 可重放 比如流程编排、审批流 状态机

状态模式还有一个容易忽略的好处:它让非法状态转换变得显式。在状态表里,只有被明确定义的行为才会被执行,未定义的行为直接消失。这比在一堆 if 里小心地加判断可靠得多。

模式 16 · 命令模式(Command)

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

// 每个命令必须同时实现 execute 和 undo
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 ChangeColorCommand {
  constructor(editor, color) {
    this.editor = editor;
    this.color = color;
    this.previous = null;
  }
  execute() {
    this.previous = this.editor.color; // 记录旧值
    this.editor.setColor(this.color);
  }
  undo() {
    this.editor.setColor(this.previous);
  }
}

class CommandHistory {
  #undoStack = [];
  #redoStack = [];
  #maxSize = 100;

  run(cmd) {
    cmd.execute();
    this.#undoStack.push(cmd);
    // 新操作会清空重做栈
    this.#redoStack.length = 0;
    // 限制历史长度,防止内存无限增长
    if (this.#undoStack.length > this.#maxSize) {
      this.#undoStack.shift();
    }
  }

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

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

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

命令模式的两种实现路线

反向操作式
每个命令自己实现 undo(),精确抵消 execute() 的效果。
优点:内存占用小。
缺点:涉及异步或外部系统时,undo 很难做到幂等。
快照式
每次操作前保存一份全量状态快照,撤销时整体回滚。
优点:实现简单、绝对可靠。
缺点:内存占用随历史长度线性增长。

实践中常见的折中方案是“快照 + 增量”:大部分操作用反向操作,只有少数难以逆转的操作(比如批量删除)才保存快照。或者干脆用不可变数据结构(Immutable.js、Immer),让每次修改天然产生新版本,撤销就是切回旧版本。

// 用 Immer 实现快照式撤销,代码量极少
import { produce } from 'immer';

class SnapshotHistory {
  #snapshots = [];
  #cursor = -1;

  constructor(initialState) {
    this.#snapshots.push(initialState);
    this.#cursor = 0;
  }

  get current() { return this.#snapshots[this.#cursor]; }

  apply(recipe) {
    const next = produce(this.current, recipe);
    // 丢弃当前位置之后的所有快照
    this.#snapshots = this.#snapshots.slice(0, this.#cursor + 1);
    this.#snapshots.push(next);
    this.#cursor++;
  }

  undo() {
    if (this.#cursor > 0) this.#cursor--;
    return this.current;
  }

  redo() {
    if (this.#cursor < this.#snapshots.length - 1) this.#cursor++;
    return this.current;
  }
}

模式 17 · 迭代器模式(Iterator)

迭代器提供统一的遍历接口,让调用方不关心底层数据结构。ES6 之后,它已经成为语言的一部分——for...of、展开运算符、解构赋值都依赖它。

// 手写一个可迭代对象
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]
for (const n of new Range(0, 10, 2)) console.log(n);
// 0 2 4 6 8 10

生成器:迭代器的语法糖

// 同样的 Range,用生成器写只要三行
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 }
it.next(); // { value: 2, done: false }

// 取前 5 个自然数
const first5 = [];
for (const n of naturals()) {
  first5.push(n);
  if (first5.length === 5) break;
}

实战:分页数据的统一迭代

这是一个非常实用的模式——把“一页一页拉取”封装成一个异步迭代器,调用方只需要 for await 就能拿到全部数据,完全不需要关心分页逻辑:

async function* paginate(fetchPage, pageSize = 20) {
  let page = 1;
  let hasMore = true;

  while (hasMore) {
    const { items, total } = await fetchPage({ page, pageSize });
    for (const item of items) {
      yield item; // 逐个吐出,而不是等全部加载完
    }
    hasMore = page * pageSize < total;
    page++;
  }
}

// 调用方:流式处理,可以随时 break
let count = 0;
for await (const user of paginate(fetchUsers, 50)) {
  if (user.status === 'active') count++;
  if (count > 100) break; // 不需要的数据根本不请求
}

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

模式 18 · 职责链模式(Chain of Responsibility)

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

// 极简版的 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() 前后都能写代码

这是职责链最有价值的地方——它让你可以在“请求发出前”和“响应返回后”分别插入逻辑,而且这些逻辑的顺序是自动管理的:

// 一个完整的请求处理链
const middlewares = [
  // ① 请求前:注入追踪 ID
  async (ctx, next) => {
    ctx.headers['X-Trace-Id'] = crypto.randomUUID();
    await next();
  },

  // ② 请求前:鉴权
  async (ctx, next) => {
    if (!ctx.token) throw new Error('未登录');
    ctx.headers.Authorization = `Bearer ${ctx.token}`;
    await next();
  },

  // ③ 请求前:加载中状态
  async (ctx, next) => {
    loading.show();
    try {
      await next();
    } finally {
      loading.hide(); // 请求后:无论如何都关闭
    }
  },

  // ④ 请求后:统一错误处理与埋点
  async (ctx, next) => {
    const start = performance.now();
    try {
      ctx.response = await fetch(ctx.url, ctx);
      return await next();
    } catch (err) {
      analytics.track('api_error', { url: ctx.url, err });
      throw err;
    } finally {
      analytics.track('api_timing', {
        url: ctx.url,
        ms: performance.now() - start,
      });
    }
  },
];

Express、Koa、Redux、Axios 的拦截器,本质都是职责链。它们唯一的区别是:有的允许在 next() 前后都写代码(Koa 洋葱模型),有的只允许在请求前处理(Axios 请求拦截器)。

模式 19 · 中介者模式(Mediator)

中介者用一个中心对象协调多个同事对象之间的通信,避免它们互相引用。当对象之间的通信形成网状结构时,中介者把它变成星形结构。

// 反例:字段之间两两通信,新增一个字段要改 N 处
// country.onChange(() => province.reload());
// country.onChange(() => city.clear());
// province.onChange(() => city.reload());
// province.onChange(() => district.clear());
// city.onChange(() => district.reload());
// ... N × N 的连线

// 中介者:所有字段只和 FormMediator 通信
class FormMediator {
  #fields = new Map();
  #rules = new Map();

  register(name, field) {
    this.#fields.set(name, field);
    field.onChange((value) => this.#onFieldChange(name, value));
  }

  // 声明联动规则:当 source 变化时,重置 targets 并重新加载
  when(source, ...targets) {
    this.#rules.set(source, targets);
    return this;
  }

  #onFieldChange(name, value) {
    const targets = this.#rules.get(name) ?? [];
    targets.forEach((targetName) => {
      const field = this.#fields.get(targetName);
      field.clear();
      field.loadOptions({ parent: value });
    });
  }
}

// 使用:规则集中声明,字段之间零耦合
const mediator = new FormMediator();
mediator.when('country', 'province');
mediator.when('province', 'city');
mediator.when('city', 'district');

mediator.register('country', countryField);
mediator.register('province', provinceField);
mediator.register('city', cityField);
mediator.register('district', districtField);

中介者 vs 发布订阅

维度 中介者 发布订阅
通信方式 点对点,中介者知道所有同事 广播式,发布者不知道订阅者
业务逻辑 中介者里通常有业务规则 事件总线本身没有业务规则
适用规模 一组密切相关对象的协调 跨模块、跨层级的全局通信
前端例子 表单联动、复杂组件通信 全局事件总线、微前端通信

一个实用的判断:如果你的“事件总线”里开始出现业务判断(比如“收到 A 事件时检查 B 状态再决定是否触发 C”),那它其实已经是中介者了,只是名字叫错了。这时应该把它显式地命名成 Mediator,并给它一个清晰的位置,而不是让它继续伪装成一个通用的总线。

模式 20 · 备忘录模式(Memento)

备忘录在不破坏封装的前提下,捕获并恢复对象的内部状态。它和命令模式经常一起使用,但关注点不同:命令关心“怎么撤销”,备忘录关心“状态怎么保存”。

// 一个表单编辑器的草稿管理
class FormEditor {
  #content = '';
  #drafts = [];

  type(text) {
    this.#content += text;
  }

  // 创建备忘录:只暴露一个不透明的令牌
  save() {
    const memento = {
      content: this.#content,
      timestamp: Date.now(),
      preview: this.#content.slice(-20),
    };
    this.#drafts.push(memento);
    return memento;
  }

  // 恢复:从备忘录中读回状态
  restore(memento) {
    this.#content = memento.content;
  }

  get history() {
    return this.#drafts.map((d) => ({
      preview: d.preview,
      time: new Date(d.timestamp).toLocaleTimeString(),
    }));
  }
}

备忘录用得最多的场景:自动草稿

这是一个几乎所有富文本编辑器都会实现的功能。关键在于节流——不可能每次按键都存一份快照:

function createAutoDraft(key, { interval = 3000, maxDrafts = 20 } = {}) {
  let timer = null;
  let pending = null;

  function flush() {
    if (pending === null) return;
    const drafts = JSON.parse(localStorage.getItem(key) ?? '[]');
    drafts.push({ content: pending, at: Date.now() });
    // 只保留最近 N 份
    if (drafts.length > maxDrafts) drafts.splice(0, drafts.length - maxDrafts);
    localStorage.setItem(key, JSON.stringify(drafts));
    pending = null;
  }

  return {
    // 每次输入只更新 pending,不立刻写盘
    update(content) {
      pending = content;
      clearTimeout(timer);
      timer = setTimeout(flush, interval);
    },

    // 离开页面前强制落盘
    flush,

    list() {
      return JSON.parse(localStorage.getItem(key) ?? '[]');
    },

    clear() {
      clearTimeout(timer);
      localStorage.removeItem(key);
    },
  };
}

// 使用
const draft = createAutoDraft('article:123');
editor.onInput((text) => draft.update(text));
window.addEventListener('beforeunload', () => draft.flush());
要点一备忘录应该是不可变的,保存后不允许再修改
要点二备忘录应该是不透明的,外部只能传递它,不能读取它
要点三必须限制历史长度,否则内存会随使用时间无限增长
要点四大对象优先存到 localStorage / IndexedDB,内存里只留引用

模式 21 · 模板方法模式(Template Method)

模板方法在父类中定义算法的骨架,把可变步骤留给子类实现。在 JavaScript 里,更常见的写法是“传入钩子函数的通用流程”——不需要继承,只需要传参。

// OO 版:定义一个抽象基类
class DataImporter {
  async run(source) {
    const raw = await this.fetch(source);  // 步骤一:子类实现
    const parsed = this.parse(raw);       // 步骤二:子类实现
    const valid = this.validate(parsed);   // 步骤三:子类实现
    await this.save(valid);               // 步骤四:子类实现
    this.report(valid.length);        // 步骤五:可选钩子
  }

  // 钩子:默认空实现,子类按需覆盖
  report() {}
}

class CsvImporter extends DataImporter {
  async fetch(source) { return (await fetch(source)).text(); }
  parse(raw) { return raw.split('\n').map((l) => l.split(',')); }
  validate(rows) { return rows.filter((r) => r.length === 3); }
  async save(rows) { await api.bulkInsert(rows); }
}

函数式写法:传钩子,不传继承

同样的需求,用钩子函数写出来更短、更灵活,而且可以自由组合:

// 通用的"请求生命周期"流程
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?.();
  }
}

// 使用:每个钩子都是可选的
const users = await withRequestLifecycle({
  beforeRequest: () => loading.show(),
  request: () => api.get('/users'),
  onSuccess: (data) => cache.set('users', data),
  onError: (err) => toast.error(err.message),
  afterAll: () => loading.hide(),
});

模板方法在前端的常见落点

构建流程resolve → load → transform → parse → generate → write,每一步都可插钩子
表单流程收集 → 校验 → 转换 → 提交 → 反馈,不同的表单复用同一套骨架
测试框架beforeEach → test → afterEach,天然就是模板方法
脚手架询问 → 下载模板 → 替换变量 → 安装依赖 → 初始化 Git

需要注意的一点:模板方法把控制权交给了“骨架”,子类/钩子只能填空。这在流程稳定的场景下是优点(保证顺序正确),在流程多变的场景下就会变成束缚。如果流程本身经常变,那它就不该是模板。

模式 22 · 访问者模式(Visitor)

访问者模式把“对一组对象的操作”从对象结构中分离出来。它的价值在于:当你想给一个稳定的数据结构增加新操作时,不需要修改数据结构的代码。

// 一组节点类型(结构稳定,几乎不会新增)
const nodes = [
  { type: 'heading', level: 1, text: '章节标题' },
  { type: 'paragraph', text: '这是一段正文。' },
  { type: 'code', lang: 'js', code: 'const a = 1;' },
  { type: 'image', src: '/a.png', alt: '示意图' },
];

// 访问者一:渲染成 HTML
const htmlVisitor = {
  heading: (n) => `<h${n.level}>${n.text}</h${n.level}>`,
  paragraph: (n) => `<p>${n.text}</p>`,
  code: (n) => `<pre data-lang="${n.lang}"><code>${escapeHtml(n.code)}</code></pre>`,
  image: (n) => `<img src="${n.src}" alt="${n.alt}">`,
};

// 访问者二:统计字数
const countVisitor = {
  heading: (n) => n.text.length,
  paragraph: (n) => n.text.length,
  code: (n) => n.code.length,
  image: () => 0,
};

// 访问者三:提取所有图片地址
const imageVisitor = {
  heading: () => [],
  paragraph: () => [],
  code: () => [],
  image: (n) => [n.src],
};

// 统一的访问入口
function visit(nodes, visitor) {
  return nodes.flatMap((node) => {
    const handler = visitor[node.type];
    if (!handler) throw new Error(`访问者未实现 ${node.type}`);
    return handler(node);
  });
}

visit(nodes, htmlVisitor).join('\n');
visit(nodes, countVisitor).reduce((a, b) => a + b, 0);
visit(nodes, imageVisitor); // ['/a.png']

前端真实场景:AST 遍历

访问者模式最著名的应用,是编译器与 AST(抽象语法树)处理。Babel 插件、ESLint 规则、自定义代码转换工具,全都建立在访问者之上:

// Babel 插件的标准形态就是一个访问者对象
export default function myPlugin({ types: t }) {
  return {
    name: 'remove-console',
    visitor: {
      // 遇到 CallExpression 节点时触发
      CallExpression(path) {
        const callee = path.node.callee;
        const isConsole =
          t.isMemberExpression(callee) &&
          t.isIdentifier(callee.object, { name: 'console' });

        if (isConsole) path.remove(); // 直接删掉这个节点
      },

      // 遇到 ImportDeclaration 节点时触发
      ImportDeclaration(path) {
        if (path.node.source.value === 'lodash') {
          // 把 lodash 改写成 lodash-es
          path.node.source.value = 'lodash-es';
        }
      },
    },
  };
}
访问者模式适合
数据结构稳定,但需要在其上做多种不同操作。比如 AST、文档节点、图形元素。
访问者模式不适合
数据结构经常新增类型。每加一种类型,所有访问者都要补一个方法,维护成本会急剧上升。

模式 23 · 解释器模式(Interpreter)

解释器模式为一个小型语言定义文法,并提供一个解释器来执行它。它是最少被使用、也最容易被滥用的模式之一。判断标准很简单:你需要处理的是一个真正的“表达式”,而不是几行 if-else。

// 需求:给权限系统写一个表达式引擎
// 支持:user.role == 'admin' AND user.level > 3
// 支持:user.tags CONTAINS 'vip' OR isOwner

// ① 词法分析:把字符串切成 token
function tokenize(input) {
  const pattern = /\s*(==|!=|>=|<=|>|<|AND|OR|CONTAINS|\(|\)|[A-Za-z_][\w.]*|'[^']*'|\d+)\s*/g;
  const tokens = [];
  let match;
  while ((match = pattern.exec(input)) !== null) {
    tokens.push(match[1]);
  }
  return tokens;
}

// ② 语法分析:把 token 转成 AST
function parse(tokens) {
  let pos = 0;

  function peek() { return tokens[pos]; }
  function consume() { return tokens[pos++]; }

  // 优先级最低的 OR
  function parseOr() {
    let left = parseAnd();
    while (peek() === 'OR') {
      consume();
      left = { type: 'or', left, right: parseAnd() };
    }
    return left;
  }

  function parseAnd() {
    let left = parsePrimary();
    while (peek() === 'AND') {
      consume();
      left = { type: 'and', left, right: parsePrimary() };
    }
    return left;
  }

  function parsePrimary() {
    if (peek() === '(') {
      consume();
      const node = parseOr();
      consume(); // ')'
      return node;
    }
    const left = consume();
    const op = consume();
    if (op === 'CONTAINS') {
      return { type: 'contains', left, right: consume() };
    }
    return { type: 'compare', op, left, right: consume() };
  }

  return parseOr();
}

// ③ 解释执行:遍历 AST 求值
function evaluate(node, context) {
  switch (node.type) {
    case 'or':
      return evaluate(node.left, context) || evaluate(node.right, context);
    case 'and':
      return evaluate(node.left, context) && evaluate(node.right, context);
    case 'contains': {
      const arr = resolve(node.left, context);
      const val = literal(node.right);
      return Array.isArray(arr) && arr.includes(val);
    }
    case 'compare': {
      const l = resolve(node.left, context);
      const r = literal(node.right);
      switch (node.op) {
        case '==': return l === r;
        case '!=': return l !== r;
        case '>':  return l > r;
        case '<':  return l < r;
        case '>=': return l >= r;
        case '<=': return l <= r;
      }
      return false;
    }
  }
}

// 把字面量和变量取值分开处理
function literal(token) {
  if (token.startsWith("'")) return token.slice(1, -1);
  if (/^\d+$/.test(token)) return Number(token);
  return token;
}

function resolve(token, ctx) {
  if (token.startsWith("'") || /^\d+$/.test(token)) return literal(token);
  return token.split('.').reduce((o, k) => o?.[k], ctx);
}

// 使用:表达式可以被缓存、被序列化、被后台配置
const canEdit = evaluate(
  parse(tokenize("user.role == 'admin' OR user.level > 3")),
  { user: { role: 'editor', level: 5 } }
); // true

解释器模式的三个使用前提

前提一你的用户/运营确实需要自定义规则,而不是开发者写死的判断
前提二文法确实简单,简单到你能用几百行实现完整的词法+语法+求值
前提三如果文法会不断扩张,尽早换用成熟的解析器组合库(如 peggy、chevrotain)

解释器模式在其他前端场景里的身影:Cron 表达式解析、路由路径匹配、搜索查询语法(比如 GitHub 的 is:open label:bug)、CSS 选择器引擎、模板引擎的表达式求值、低代码平台的公式字段。

值得自己写
文法封闭、表达式数量可控、需要在前端实时求值、体积敏感。
不值得自己写
文法复杂(支持变量作用域、函数调用、类型系统)。这种时候应该直接引入成熟的解析库。

结语:23 个模式之后的判断力

23 个模式讲完了。现在回到最开始的那个问题:你会在项目里用几个?

我的答案是:大概率不超过 8 个,而且其中一半会被你改造得面目全非。这不是坏事——恰恰相反,这说明你理解了模式的本质。模式是“问题的形状”,不是“代码的形状”。同一个问题在不同语言、不同项目、不同团队里,最优解法可能完全不同。

哪些模式在前端最常用

高频观察者、发布订阅、策略、装饰器、适配器、外观——这六个覆盖了前端 80% 的实际场景
中频工厂、单例、代理、组合、状态、职责链、迭代器——在特定领域非常好用
低频抽象工厂、建造者、桥接、享元、命令、中介者、备忘录、模板方法——遇到合适场景再上
罕见访问者、解释器——除非你在写编译器、规则引擎或低代码平台

一张可以贴在工位上的速查表

你遇到的问题 先看哪个模式 前端典型例子
if-else 分支越来越长策略 / 状态校验、计费、导出格式
两个模块循环依赖发布订阅 / 中介者事件总线、表单联动
给函数加日志/缓存/重试装饰器请求拦截、记忆化
老接口和新代码对不上适配器第三方 SDK、旧 API 迁移
想把复杂度藏起来外观请求库、工具库
读取属性时想动手脚代理响应式、懒加载、访问控制
树形结构要统一处理组合文件树、菜单、虚拟 DOM
需要撤销/重做命令 / 备忘录编辑器、画板、草稿
多环节依次处理请求职责链中间件、拦截器
大量重复对象占内存享元图标池、虚拟列表
创建对象过程复杂工厂 / 建造者插件系统、请求构造
两个维度各自独立变化桥接主题 × 组件、通知 × 渠道
遍历方式需要统一迭代器分页、流式数据
流程固定但步骤可变模板方法请求生命周期、脚手架
给稳定结构加新操作访问者AST 遍历、文档导出
需要用户自定义规则解释器权限表达式、搜索语法

十条落地清单

  1. 先写三遍,再抽象。同一段逻辑复制粘贴到第三次时,才是抽象的最佳时机。
  2. 能用一个函数解决的,不要用一个类。JavaScript 的函数是一等公民,别浪费这个优势。
  3. 策略表优先于 switch。能用对象查表的地方,不要写分支。
  4. 装饰器要保持接口不变。输入输出与原函数一致,才能任意组合、层层叠加。
  5. 订阅了就要能取消。让 on 返回取消函数,或用 AbortController 统一注销。
  6. 单例要少用。能用依赖注入的地方,优先用参数传递。
  7. 继承不超过三层。超过就考虑组合或 Mixin。
  8. Proxy 不要放进热点路径。它的开销是普通属性访问的数倍。
  9. 缓存一定要有上限。无论是 memoize 还是草稿历史,都要限制长度。
  10. 衡量标准只有一个:改需求时,需要动几处代码?答案越少,抽象越成功。
// 一份可以常备的"模式工具箱"骨架

// 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;

// 7. 注册表 —— 可扩展的创建
const registry = new Map();
const define = (t, f) => registry.set(t, f);
const create = (t, o) => registry.get(t)?.(o);

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

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

愿你在 23 个名字之外,找到属于自己的那三五个。