“设计模式”这四个字,在前端圈的口碑相当分裂。一方面,面试八股里它是常客,人人都会背“二十三种设计模式”;另一方面,真实项目里它常常被当成过度设计的代名词——为了用而用,凭空多出十几层抽象,最后谁也看不懂。真相是:设计模式不是让你多写代码的,而是让你在正确的时机少写代码的。它描述的是一类反复出现的问题,以及一种已经被无数人验证过的解法骨架。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 种模式分成三组,这个分类方式至今仍然好用。点击下面的卡片展开说明:
为什么 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 里,你其实一直在用它,只是没意识到:
// 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,
// 只能去操纵全局状态,测试之间互相污染。
现代前端框架里的状态管理库(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 建造者:分步组装复杂对象
当一个对象的构造参数超过四五个,且很多是可选的,构造函数就会变成一团糟。建造者模式用链式调用把“配置”和“构造”分开:
#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 直接建立委托关系:
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 可以写成:
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)就是“封装复杂度”。一个典型例子是浏览器兼容层:
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 树、组织架构、菜单树,都是它的天然应用场景:
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 发布订阅:一字之差,天壤之别
这是被混淆最多的两个概念。它们长得像,但结构完全不同:
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));
}
}
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);
});
emit、Node 的 EventEmitter 都是观察者
下面是一个生产可用的 EventEmitter 实现,注意 on 返回取消函数这个设计——它比手动记录 off 更不容易出错:
#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;
}
// 新增规则只需往数组里加一项,函数体不动
再来看看表单校验的经典案例。点击下面的按钮,切换不同的校验策略,观察输出:
// 校验结果: 校验通过
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 状态:让对象的行为随状态改变
状态模式与策略模式结构相似,但意图不同:策略是外部选择算法,状态是内部根据条件自动迁移。一个典型场景是异步请求的生命周期:
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 命令:把操作变成对象
命令模式把“一次操作”封装成对象,从而支持排队、记录、撤销、重放。富文本编辑器、画板工具、表单撤销栈,都靠它:
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 职责链:每个环节只做一件事
职责链把处理请求的多个环节串成一条链,每个环节决定“处理”还是“交给下一个”。前端的中间件就是它的标准实现:
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 是唯一的模块化手段。它利用函数作用域创建私有空间,避免污染全局:
// 私有变量,外部无法访问
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)则是它的一种更清晰的变体——先定义所有函数,最后统一决定暴露哪些:
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 出现之前):
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 支持多范式,很多在面向对象语境下需要一整个类层次才能表达的模式,在函数式视角下可以退化成一两行。这不是说函数式“更高级”,而是说同一个问题在不同的抽象工具下,成本完全不同。
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 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 会丢失。这在装饰器模式里尤其危险,因为装饰器返回的是一个新函数:
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 调用只是为了“绕开”父类的行为,那就应该考虑改用组合。
// 组合:能力就是函数,按需拼装
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 四种格式,每种格式的生成逻辑各不相同,还要处理权限、文件大小限制、失败重试。点击按钮对比改造前后的代码。
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 逻辑无法单独测试
改造带来的收益
这个改造用到了三个模式:策略模式(导出器表)、装饰器模式(权限与大小限制)、外观模式(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。
就像那句流传很广的话:模式是给那些已经解决了问题的人,用来解释他们为什么这么做的语言。先解决问题,再谈模式。当你的代码第二次、第三次因为同样的原因变得难以维护时,那个合适的模式自然会浮现出来。