1994 年,GoF 四人组在《设计模式:可复用面向对象软件的基础》里归纳了 23 种模式。三十多年过去,这本书依然是软件工程史上被引用最多的著作之一,而它描述的 23 个名字,也成了无数开发者技术面试里的必答题。但真正的问题是:当你把这 23 个名字背得滚瓜烂熟之后,你会在项目里用几个?答案是——大部分人一个都不用,或者用了却用错。原因很简单:这 23 种模式诞生于 C++ 与 Smalltalk 的世界,而 JavaScript 是一门函数是一等公民、没有真正意义上的类、天生事件驱动、异步无处不在的语言。很多模式在这门语言里已经被语言特性“吃掉”了,还有些模式需要彻底改写才能用得上。本篇 70 分钟长文,把这 23 个模式一个不落地拿出来,每一个都配上前端真实场景、可直接运行的代码、以及“这个模式在这里值不值得用”的判断。我们不背定义,只做实战:每一个模式都回答三个问题——它解决什么问题、在 JS 里怎么写、什么时候不该写。读完你未必会立刻用上 23 个模式,但你一定能建立起一套属于自己的抽象判断力。
一、先建立坐标系:23 个模式各自站在哪里
在动手之前,我们需要一张地图。GoF 按“模式的目的”把 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 | 解释器 | 为小型语言定义文法 | 表达式引擎、权限规则 |
三个必须先说清楚的前提
好,地图画完了,接下来我们逐个击破。每一节的结构都是一样的:先看它解决什么问题,再看 JavaScript 里怎么写,最后判断这个模式在你手上值不值。
模式 01 · 单例模式(Singleton)
单例保证一个类只有一个实例,并提供一个全局访问点。在前端,你其实一直在用它,只是从未意识到。
// 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);
}
// 单元测试里只能去操纵全局状态,
// 测试之间互相污染、执行顺序影响结果。
现代状态管理库(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('您的订单已发货');
这个“注册表 + 工厂函数”结构在前端非常常见:富文本编辑器的插件系统、图表库的图表类型、表单引擎的动态字段渲染、低代码平台的物料加载,底层都是同一套逻辑。
工厂方法的三种形态
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);
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)
如果说工厂方法解决的是“创建一个对象”,那么抽象工厂解决的是“创建一族相关对象”。它保证同一族的产品搭配在一起使用时不会出错。
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>:
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;
}
模式 04 · 建造者模式(Builder)
当一个对象的配置项超过四五个、且大部分是可选的,构造函数就会变成灾难。建造者用链式调用把“配置过程”和“最终产出”分开。
#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 个模式加起来都重要。
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 的实例,得到的是一个普通对象,方法全部丢失。这是很多人第一次用时会踩的坑。
原型模式在前端的三种实战形态
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 还是内存里:
// 适配器一: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),只需要写一个新的适配器,业务代码一行都不用动。
模式 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,你可以这样写:
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) 调用原函数,否则对象方法被装饰后会丢失 thisundefined),否则调用链会断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 之上的。
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)
外观模式为复杂的子系统提供一个简化的统一接口。它不是“增加功能”,而是“让已有功能更好用”。前端最典型的外观就是各种工具库。
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)
组合模式让“单个对象”和“对象的集合”拥有一致的接口。调用方不需要判断当前处理的是叶子还是树枝,直接递归调用即可。
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)
桥接模式把“抽象”与“实现”拆开,让它们可以各自独立变化。判断是否需要桥接有一个简单信号:类名里出现了多个维度。
// 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 层,几乎每一天都在和它打交道。
#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.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。
#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;
}
// 新增规则只需往数组里加一项,函数体完全不动
再看一个:表单校验
点击下面的按钮切换不同的校验策略,观察输出结果:
// 校验结果: 校验通过
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,失败返回错误消息字符串。这比返回布尔值实用得多——调用方可以直接把消息显示到界面上,不需要再维护一张错误码映射表。
策略模式在前端的其他落点
模式 15 · 状态模式(State)
状态模式与策略模式结构相似,但意图不同:策略是外部选择算法,状态是内部根据条件自动迁移。它最大的收益是把散落在各处的 if (state === 'xxx') 收敛成一张状态表。
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)
命令模式把“一次操作”封装成对象,从而支持排队、记录、撤销、重放。富文本编辑器、画板工具、表单撤销栈,都靠它。
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),让每次修改天然产生新版本,撤销就是切回旧版本。
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
生成器:迭代器的语法糖
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 就能拿到全部数据,完全不需要关心分页逻辑:
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)
职责链把处理请求的多个环节串成一条链,每个环节决定“处理”还是“交给下一个”。前端的中间件就是它的标准实现。
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)
中介者用一个中心对象协调多个同事对象之间的通信,避免它们互相引用。当对象之间的通信形成网状结构时,中介者把它变成星形结构。
// 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(),
}));
}
}
备忘录用得最多的场景:自动草稿
这是一个几乎所有富文本编辑器都会实现的功能。关键在于节流——不可能每次按键都存一份快照:
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());
模式 21 · 模板方法模式(Template Method)
模板方法在父类中定义算法的骨架,把可变步骤留给子类实现。在 JavaScript 里,更常见的写法是“传入钩子函数的通用流程”——不需要继承,只需要传参。
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(),
});
模板方法在前端的常见落点
需要注意的一点:模板方法把控制权交给了“骨架”,子类/钩子只能填空。这在流程稳定的场景下是优点(保证顺序正确),在流程多变的场景下就会变成束缚。如果流程本身经常变,那它就不该是模板。
模式 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 规则、自定义代码转换工具,全都建立在访问者之上:
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';
}
},
},
};
}
模式 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 遍历、文档导出 |
| 需要用户自定义规则 | 解释器 | 权限表达式、搜索语法 |
十条落地清单
- 先写三遍,再抽象。同一段逻辑复制粘贴到第三次时,才是抽象的最佳时机。
- 能用一个函数解决的,不要用一个类。JavaScript 的函数是一等公民,别浪费这个优势。
- 策略表优先于 switch。能用对象查表的地方,不要写分支。
- 装饰器要保持接口不变。输入输出与原函数一致,才能任意组合、层层叠加。
- 订阅了就要能取消。让
on返回取消函数,或用AbortController统一注销。 - 单例要少用。能用依赖注入的地方,优先用参数传递。
- 继承不超过三层。超过就考虑组合或 Mixin。
- Proxy 不要放进热点路径。它的开销是普通属性访问的数倍。
- 缓存一定要有上限。无论是 memoize 还是草稿历史,都要限制长度。
- 衡量标准只有一个:改需求时,需要动几处代码?答案越少,抽象越成功。
// 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 个名字之外,找到属于自己的那三五个。