设计原则与模式总览
设计模式是针对特定场景下反复出现的问题的通用解决方案,不是必须套用的模板。判断一个模式该不该用,只有一个标准:它是否降低了当前这段代码的变更成本。
一、SOLID 原则
| 原则 | 含义 | 一句话判据 |
|---|---|---|
| SRP 单一职责 | 一个模块只有一个引起它变化的原因 | 描述这个函数要用"并且"吗? |
| OCP 开放封闭 | 对扩展开放,对修改封闭 | 加一个新类型,要不要改老代码? |
| LSP 里氏替换 | 子类必须能替换父类而不破坏程序正确性 | 子类有没有"抛异常"或"什么都不做"地覆盖父类方法? |
| ISP 接口隔离 | 不强迫使用者依赖它用不到的接口 | 实现这个接口时,有没有被迫写空方法? |
| DIP 依赖倒置 | 高层模块不依赖低层模块,两者都依赖抽象 | 业务代码里有没有直接 new 一个具体实现? |
单一职责(SRP)
// ❌ 一个函数做了取数、转换、渲染三件事,任何一处需求变动都要改它
async function renderUserList(el) {
const res = await fetch('/api/users');
const users = (await res.json()).data.map(u => ({ ...u, name: u.first + u.last }));
el.innerHTML = users.map(u => `<li>${u.name}</li>`).join('');
}
// ✅ 拆开后,换接口只改 fetchUsers,换 UI 只改 renderList
const fetchUsers = () => fetch('/api/users').then(r => r.json());
const toViewModel = data => data.map(u => ({ ...u, name: u.first + u.last }));
const renderList = (el, items) => { el.innerHTML = items.map(i => `<li>${i.name}</li>`).join(''); };
SRP 不是"函数越短越好"
把一个 30 行的连贯逻辑硬拆成 6 个只被调用一次的 5 行函数,只会让阅读时不停跳转。判断依据是"变化的原因",不是行数。
开放封闭(OCP)
这是绝大多数设计模式想解决的核心问题。识别信号:每加一个新类型,就要回去改一个 if / else 或 switch。
// ❌ 每增加一种支付方式都要改这个函数
function pay(type, amount) {
if (type === 'alipay') { /* ... */ }
else if (type === 'wechat') { /* ... */ }
else if (type === 'card') { /* ... */ }
}
// ✅ 新增支付方式只需注册,不动 pay 本身 —— 这就是策略模式
const payments = new Map();
const registerPayment = (type, handler) => payments.set(type, handler);
function pay(type, amount) {
const handler = payments.get(type);
if (!handler) throw new Error(`未知支付方式: ${type}`);
return handler(amount);
}
依赖倒置(DIP)
// ❌ 业务逻辑直接依赖具体实现,无法替换、无法测试
class OrderService {
constructor() { this.db = new MySQLClient(); }
}
// ✅ 依赖注入:依赖的是"能存储"这个抽象
class OrderService {
constructor(storage) { this.storage = storage; }
}
new OrderService(new MySQLClient()); // 生产
new OrderService(new InMemoryStorage()); // 测试
二、其他重要原则
- 迪米特法则(最少知识原则):一个对象应尽可能少地了解其他对象。信号是链式访问
a.b.c.d.doSomething()—— 中间任何一环变了你都得改。 - 组合优于继承:继承是编译期确定的强耦合,且会因为多层继承产生"脆弱基类"问题。JS 中优先用组合、mixin、高阶函数。
- YAGNI(You Aren't Gonna Need It):不要为想象中的需求提前抽象。过早抽象比重复代码更难拆解。
- KISS / DRY:DRY 消除的是"知识"的重复,不是"代码长得像"。两段碰巧相同但会朝不同方向演化的代码,强行合并反而制造耦合。
过度设计是比不设计更常见的问题
前端项目里出现"XxxFactoryBuilderStrategyManager"这类命名时,通常意味着抽象层数超过了问题本身的复杂度。先用最直白的写法写出来,等到第三次出现重复或第二次被迫改动同一处开关时,再考虑引入模式。
三、23 种模式分类
| 类型 | 模式 | 前端使用频度 |
|---|---|---|
| 创建型 怎么创建对象 | 单例 | ⭐⭐⭐ |
| 工厂方法 / 抽象工厂 / 简单工厂 | ⭐⭐⭐ | |
| 建造者 | ⭐⭐ | |
| 原型 | ⭐(JS 语言本身就是原型继承) | |
| 结构型 怎么组合对象 | 代理 | ⭐⭐⭐ |
| 装饰器 | ⭐⭐⭐ | |
| 适配器 | ⭐⭐⭐ | |
| 外观 | ⭐⭐⭐ | |
| 组合 | ⭐⭐ | |
| 享元 | ⭐⭐ | |
| 桥接 | ⭐ | |
| 行为型 怎么分配职责 | 观察者 / 发布订阅 | ⭐⭐⭐ |
| 策略 | ⭐⭐⭐ | |
| 迭代器 | ⭐⭐⭐ | |
| 命令 | ⭐⭐ | |
| 职责链 | ⭐⭐⭐ | |
| 中介者 | ⭐⭐ | |
| 模板方法 | ⭐⭐ | |
| 状态 | ⭐⭐ | |
| 备忘录 / 访问者 / 解释器 | ⭐ |
四、JavaScript 让很多模式"消失"了
GoF 的 23 种模式诞生于 C++/Java 语境。JS 有头等函数、闭包、原型、动态类型,因此不少模式退化成了语言特性:
| 模式 | 在 JS 中的形态 |
|---|---|
| 策略 | 一个存放函数的对象 / Map,不需要定义策略接口和一堆类 |
| 命令 | 一个函数(或闭包),不需要 Command 类 |
| 工厂 | 一个返回对象字面量的普通函数 |
| 单例 | 一个 ES Module —— 模块天然只求值一次 |
| 迭代器 | Symbol.iterator + for...of + 生成器 |
| 装饰器 | 高阶函数 |
| 模板方法 | 高阶函数 + 回调 |
| 原型 | 语言内置 |
学习模式的正确姿势
不要背 UML 类图,要记住每个模式解决的具体痛点和识别信号:
- 到处是
if/else分支 → 策略 / 状态 / 职责链 - 新增类型要改老代码 → 工厂 / 策略
- 对象之间引用成网 → 中介者 / 发布订阅
- 要在不改原对象的前提下加功能 → 装饰器 / 代理
- 要控制对某个对象的访问 → 代理
- 第三方接口和你要的形状不一致 → 适配器
- 调用方要记住一长串步骤 → 外观
五、高阶函数:JS 中最常用的"元模式"
上面大半的模式在 JS 里都是靠高阶函数实现的。批量生成方法就是一个例子:
const Type = {};
['String', 'Number', 'Array', 'Function', 'Date', 'RegExp'].forEach(type => {
Type[`is${type}`] = obj => Object.prototype.toString.call(obj) === `[object ${type}]`;
});
Type.isArray([]); // true
Type.isString('abc'); // true
原来常见的
for循环 + IIFE 写法是为了在var时代捕获循环变量。let有块级作用域,forEach的回调本身就是新作用域,都不再需要 IIFE。
下一步 👉 单例模式
