设计模式函数哲学
# 15.设计模式与函数式哲学
📍 上接第 14 篇《现代工程链三件套》。工具链已齐全。本文回答:同样的功能,为什么有人写 5 行、有人写 50 行——设计模式与函数式编程如何让代码更有「韧劲」。
# 目录介绍
- 1. 案例与疑问引入
- 2. 架构全景概览
- 3. 单例与工厂
- 4. 观察者 vs 发
- 5. 策略与命令模式
- 6. 适配器代理装饰
- 7. 纯函数与副作用隔离
- 8. 不可变数据详解
- 9. pipe与May
- 10. 综合案例串讲
# 1. 案例与疑问引入
# 1.1 「点击按钮→请求
一个产品经理走过来:"订单列表页加一个刷新按钮——点一下请求后端、更新页面、禁用按钮直到请求回来。"
你的第一个版本:
// 写法 A:功能能跑——但经不起"变化"
async function handleClick() {
const btn = document.querySelector('#refresh');
const list = document.querySelector('#list');
const errorDiv = document.querySelector('#error');
btn.disabled = true;
errorDiv.hidden = true;
const res = await fetch('/api/orders');
if (!res.ok) {
errorDiv.textContent = '加载失败';
errorDiv.hidden = false;
btn.disabled = false;
return;
}
const data = await res.json();
list.innerHTML = data.map(d => `<li>${d.name} - ¥${d.amount}</li>`).join('');
btn.disabled = false;
}
btn.addEventListener('click', handleClick);
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
周一上线。周三,产品经理又来:"需求改了——加上防抖(500ms 内连点只触发一次)、出错自动重试 2 次、加载中显示骨架屏。"
你看着那段代码沉默了——每个新增需求都意味着要在这 20 行里再塞 5 行,到第六次变更时它已经变成 50 行的泥潭。
现在看另一种写法——把"加载态、获取数据、渲染、错误处理"拆成独立模块:
// 写法 B:每个部分独立、可测试、可组合
// ① 加载态管理——纯函数组合(高阶函数)
const withLoading = (btn) => (fn) => async (...args) => {
btn.disabled = true;
try {
return await fn(...args);
} finally {
btn.disabled = false;
}
};
// ② 数据获取——分离业务逻辑
const fetchOrders = () =>
fetch('/api/orders').then(r => r.ok ? r.json() : Promise.reject(r));
// ③ 渲染——纯展示逻辑
const render = (container) => (data) => {
container.innerHTML = data.map(d => `<li>${d.name} - ¥${d.amount}</li>`).join('');
};
// ④ 错误处理——独立策略
const showError = (target) => (err) => {
target.hidden = false;
target.textContent = err.message || '加载失败';
};
// ⑤ 防抖——通用的工具函数(装饰器模式)
const debounce = (fn, delay) => {
let timer;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), delay);
};
};
// ⑥ 重试——通用策略(装饰器模式)
const withRetry = (fn, maxRetry = 2) => async (...args) => {
for (let i = 0; i <= maxRetry; i++) {
try { return await fn(...args); }
catch (e) { if (i === maxRetry) throw e; }
}
};
// ⑦ 组装——pipe 组合(函数式编程)
const pipe = (...fns) => (x) => fns.reduce((v, f) => f(v), x);
const handleRefresh = pipe(
debounce,
withLoading(btn),
() => withRetry(fetchOrders)().then(render(list))
)(() => {}); // 初始输入为空函数
btn.addEventListener('click', handleRefresh);
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
# 1.2 顺藤摸到根因
带着两个版本的差异往下挖:
假设 1:"A 和 B 功能一样,B 只是代码多一点"——错。A 的加载态、数据获取、渲染全部耦合在同一个函数里。B 的每个部分都可以独立修改、独立测试、独立复用。如果另一个页面也需要防抖 + 重试,A 要复制全部代码,B 只需要组合已有的工具函数。
假设 2:"设计模式就是语法改写,换种写法而已"——错。模式的核心不是"怎么写",而是"把什么和什么解耦"。单例模式把"对象创建时机"和"对象使用者"解耦。观察者模式把"数据变化"和"响应行为"解耦。策略模式把"条件分支"和"每种条件的具体处理"解耦。
假设 3:"函数式编程就是不用 class、用
map代替for"——错。FP 的本质是"数据流过函数"——数据是不可变的、函数是纯的、组合代替嵌套。React 的渲染是state → UI的纯函数映射,Redux 的 reducer 是(state, action) → newState的纯函数——它们背后的哲学是一致的。假设 4:"不可变数据就是
{...obj}浅拷贝"——不完全是。不可变的核心是结构共享(Structural Sharing)——不是每次都完整深拷贝,而是只复制"改变了的那条路径"上的节点,其余部分共享引用。Immer 正是通过 Proxy 实现了这个。假设 5:"pipe 就是一行语法糖"——错。pipe 的本质是把嵌套变成并列。
h(g(f(x)))和pipe(f, g, h)(x)做的事情完全一样,但前者让你从内向外反着读,后者让你从数据开始正着读——这是认知维度上的降级。
这一段重构里至少藏着 7 个模式/哲学点:
① 高阶层函数(withLoading)替代 try/finally 模板代码 → 第 7 章(纯函数/副作用隔离)
② 策略表(fetchOrders/render/showError 各自独立) → 第 5.1 节(策略模式)
③ 装饰器模式(debounce/withRetry 不改变原函数签名) → 第 6.1 节(装饰器 vs 代理 vs 适配器)
④ 命令模式(每次操作封装为对象,天然 undo/redo) → 第 5.2 节(命令模式)
⑤ 观察者模式(事件监听 btn.addEventListener) → 第 4 章(观察者 vs 发布订阅)
⑥ 不可变数据(每次返回新对象,原对象不受影响) → 第 8 章(不可变数据)
⑦ 组合(pipe 把分离的函数重新串起来) → 第 9 章(pipe/compose)
2
3
4
5
6
7
# 1.3 我们要回答什么
这个按钮场景就是本篇的主线案例。我们带着上面 7 个模式/哲学点往下走:
- ① 单例/工厂——JS 中如何创建和管理"唯一实例"?
- ② 观察者 vs 发布订阅——两者经常被混淆,本质区别在哪?
- ③ 策略/命令——策略表替代 if/else,命令栈支持 undo/redo
- ④ 适配器/代理/装饰器——三者接口不变但要区分核心目的
- ⑤ 纯函数与副作用——为什么 React 把副作用放到 useEffect?
- ⑥ 不可变数据——spread/structuredClone/Immer 的选择矩阵
- ⑦ pipe/Maybe——如何用组合替代嵌套、用 Maybe 消除 null 防御
本篇路线:
架构总图 (第 2 章) ─→ 三大家族 + 函数式范式的关系
↓
单例/工厂 (第 3 章) ─→ 创建对象的方式选择
↓
观察者/发布订阅 (第 4 章) ─→ 事件通信的两种模型
↓
策略/命令 (第 5 章) ─→ 行为型模式的生产落地
↓
适配器/代理/装饰器 (第 6 章) ─→ 结构型模式逐字辨析
↓
纯函数/副作用 (第 7 章) ─→ FP 的核心约束
↓
不可变数据 (第 8 章) ─→ 三种方案 + React 为什么需要它
↓
pipe/Maybe (第 9 章) ─→ 组合与安全链式调用
↓
综合案例 (第 10 章) ─→ 订单管线重构 + 设计哲学 + 速查表
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
📌 本篇定位:这是 JavaScript 专栏中「代码组织哲学」的总成篇。前 14 篇讲了引擎怎么执行、作用域怎么绑定、事件循环怎么调度、模块怎么组织——本篇回答:在这些基础之上,你的代码应该长成什么形状。读完本篇后,面对任何一段需要重构的旧代码,都能立刻回答:"用哪个模式重写、代价是什么"。
# 2. 架构全景概览
# 2.1 设计模式三大家族
先把面向对象的设计模式和函数式编程范式放在一张全景图里:
┌─────────────────────────────────────────────────────────────┐
│ 面向对象设计模式 (GoF 23) │
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ 创建型 │ │ 结构型 │ │ 行为型 │ │
│ │ (怎么造) │ │ (怎么拼) │ │ (怎么聊) │ │
│ ├─────────────┤ ├─────────────┤ ├─────────────┤ │
│ │ 单例 → 第3章 │ │ 适配器 │ │ 观察者 │ │
│ │ 工厂 → 第3章 │ │ 代理 │ │ 发布订阅 │ │
│ │ 建造者 │ │ 装饰器 → 第6章│ │ 策略 → 第5章 │ │
│ │ 原型 │ │ 外观 │ │ 命令 → 第5章 │ │
│ └──────┬──────┘ │ 桥接 │ │ 模板方法 │ │
│ │ │ 组合 │ │ 迭代器(第7篇)│ │
│ │ └──────┬──────┘ │ 状态 │ │
│ │ │ └──────┬──────┘ │
│ └─────────┬───────┴─────────┬───────┘ │
│ │ │ │
│ ▼ ▼ │
├─────────────────────────────────────────────────────────────┤
│ 函数式编程范式 (FP) │
│ │
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────┐ │
│ │ 纯函数 → 第7章 │ │ 不可变 → 第8章 │ │ 组合 → 第9章 │ │
│ │ 副作用隔离 │ │ 数据即快照 │ │ pipe/compose │ │
│ │ 引用透明 │ │ 结构共享 │ │ Maybe/Either │ │
│ └─────────────────┘ └─────────────────┘ └─────────────┘ │
│ │
│ 共同目标:让代码对"变化"更有韧劲 │
└─────────────────────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
两者的交集与互补关系:
| 维度 | OOP 设计模式 | 函数式编程 |
|---|---|---|
| 抽象单元 | 对象(状态 + 行为) | 函数(输入 → 输出) |
| 变化管理 | 面向接口编程,封装变化 | 纯函数 + 不可变数据,消除副作用 |
| 代码复用 | 继承 + 组合 | 高阶函数 + 函数组合 |
| 典型案例 | 策略模式(策略对象)、观察者模式(Subject/Observer) | pipe(fn1, fn2, fn3)(data)、Maybe 链式调用 |
| 在 JS 中 | 模式通过对象实现,但不需要 Java 式的类继承 | 函数是一等公民,天然支持高阶函数 |
# 2.2 JS 中「模式」
疑惑:"用了 Vue/React 还需要学设计模式吗?框架不是已经帮我做了?"
论证:框架确实内置了模式——但你不知道它在"替你用了哪个模式"时,调试和优化就无从下手:
Vue 3 内置模式:
reactive() → 代理模式(Proxy 拦截对象操作)
watch() → 观察者模式(响应式值变化 → 回调)
computed() → 策略模式(惰性求值的缓存策略)
provide/inject → 依赖注入
<slot> → 组合模式(内容分发)
Pinia store → 单例模式(全局唯一 store 实例)
React 内置模式:
useState/useReducer → 命令模式(action → state 的单向转变)
useEffect → 副作用隔离(纯渲染 vs 非纯操作)
useMemo/useCallback → 记忆化(Memoization Pattern)
Context → 发布订阅模式(Provider 发布,Consumer 订阅)
HOC (高阶组件) → 装饰器模式(增强组件不改变接口)
Children prop → 组合模式(组合优于继承)
Redux 内置模式:
Store → 单例模式
dispatch(action) → 命令模式
reducer(state, action) → 策略模式(action.type → 对应的处理函数)
subscribe(listener) → 观察者模式
Middleware → 装饰器模式(增强 dispatch 不改变签名)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
结论:框架是模式的应用,模式是框架的设计文档。当你在 React 中写出一个自定义 Hook 时,你可能无意中用了"工厂模式"(返回一组方法)或"策略模式"(根据参数选择行为)。知道这些,就能写出更符合框架哲学的自定义代码——而不是用 React 写出 Java 的风格。
# 3. 单例与工厂
# 3.1 IIFE 缓存
疑惑:Java 里写单例需要 private constructor + static getInstance() + 双重检查锁——JS 里为什么一行 export const db = connect() 就够了?
论证:因为 ES6 模块具有模块级缓存——一个模块无论被 import 多少次,它的顶层代码只执行一次。这个特性天然实现了单例:
// db.js —— 模块文件
import mysql from 'mysql2/promise';
// 这行代码在模块加载时执行一次,结果被缓存
const pool = mysql.createPool({
host: 'localhost',
user: 'root',
database: 'myapp'
});
// 每个 import 这个模块的文件拿到的都是同一个 pool
export default pool;
2
3
4
5
6
7
8
9
10
11
12
// userService.js // orderService.js
import db from './db.js'; import db from './db.js';
// db === 同一个 pool // db === 同一个 pool
2
3
三种单例实现对比:
| 方式 | 代码量 | 懒加载 | 线程安全 | 适用场景 |
|---|---|---|---|---|
| ES6 模块顶层导出 | 1 行 | ❌(加载时即创建) | ✅(模块单线程加载) | 服务初始化、连接池 |
| IIFE + 闭包 | 5 行 | ✅ | ✅ | 浏览器环境需要懒加载时 |
Proxy construct trap | 6 行 | ✅ | ✅ | 需要拦截 new 操作时 |
懒加载单例——IIFE + 闭包:
// 只有在第一次调用 getDB() 时才创建实例
const getDB = (() => {
let instance = null;
return () => {
if (!instance) {
console.log('Creating DB connection...');
instance = connectDB();
}
return instance;
};
})();
// 适用:初始化成本高、且不一定会用到的实例
const db = getDB(); // ← 第一次调用时才真正创建
2
3
4
5
6
7
8
9
10
11
12
13
14
用 Proxy construct trap 实现单例(参见第 07 篇第 4.2 节):
const SingletonDB = new Proxy(Database, {
construct(target, args, newTarget) {
if (!SingletonDB._instance) {
SingletonDB._instance = Reflect.construct(target, args, newTarget);
}
return SingletonDB._instance;
}
});
// new SingletonDB() → 永远返回同一个实例
2
3
4
5
6
7
8
9
# 3.2 工厂模式:一个函
疑惑:"工厂模式不就是把 new 包在一个函数里吗?值得一个模式?"
论证:工厂的真正价值不是"隐藏 new",而是把"创建什么"的决策权从调用方手里移走——让调用方不需要知道具体子类的名字:
// ❌ 调用方需要知道所有子类——每次新增图表类型都得改这里
function renderChart(type, data) {
let chart;
if (type === 'line') chart = new LineChart(data);
else if (type === 'bar') chart = new BarChart(data);
else if (type === 'pie') chart = new PieChart(data);
else if (type === 'radar')chart = new RadarChart(data);
// ... 新增一种类型 → 新增一行 if/else → 违反开闭原则
chart.render();
}
// ✅ 策略表——新增类型只加一行配置,不碰业务逻辑
const chartFactories = {
line: (data) => new LineChart(data),
bar: (data) => new BarChart(data),
pie: (data) => new PieChart(data),
radar: (data) => new RadarChart(data),
};
function renderChart(type, data) {
const factory = chartFactories[type];
if (!factory) throw new Error(`Unknown chart type: ${type}`);
factory(data).render();
}
// 新增类型 = 往对象里加一行
chartFactories.heatmap = (data) => new HeatmapChart(data);
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
工厂模式的四种 JS 落地方式:
// ① 简单工厂——函数直接返回对象(最常用)
function createUser(type, name) {
if (type === 'admin') return { name, role: 'admin', permissions: ['all'] };
if (type === 'guest') return { name, role: 'guest', permissions: ['read'] };
throw new Error(`Unknown user type: ${type}`);
}
// ② 注册式工厂——运行时动态注册(插件系统的基础)
class ServiceRegistry {
#services = new Map();
register(name, factory) { this.#services.set(name, factory); }
create(name, ...args) {
const factory = this.#services.get(name);
if (!factory) throw new Error(`Service '${name}' not registered`);
return factory(...args);
}
}
// 第三方插件可以在运行时 registry.register('payment', () => new StripePayment());
// ③ React 的 createElement——工厂模式的语言级实现
React.createElement('div', { className: 'box' }, 'Hello');
// createElement 根据第一个参数(字符串/组件类/函数)决定创建什么
// ④ 测试中的 Factory Function(Fixture Factory)
function createTestOrder(overrides = {}) {
return {
id: Math.random(),
items: [{ name: 'test', price: 100 }],
status: 'pending',
...overrides, // 允许测试按需覆盖
};
}
const order = createTestOrder({ status: 'paid' });
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
# 4. 观察者 vs 发
# 4.1 EventEmi
手写一个生产级的 EventEmitter——支持通配符、最大监听数限制、错误隔离:
class EventEmitter {
#listeners = new Map(); // event → Set<{ fn, once }>
#maxListeners = 10;
on(event, fn) {
if (!this.#listeners.has(event)) {
this.#listeners.set(event, new Set());
}
const listeners = this.#listeners.get(event);
// 最大监听数告警
if (listeners.size >= this.#maxListeners) {
console.warn(`MaxListenersExceeded: ${event} has ${listeners.size} listeners`);
}
listeners.add({ fn, once: false });
return this; // 支持链式调用
}
once(event, fn) {
if (!this.#listeners.has(event)) {
this.#listeners.set(event, new Set());
}
this.#listeners.get(event).add({ fn, once: true });
return this;
}
off(event, fn) {
const listeners = this.#listeners.get(event);
if (!listeners) return this;
// 找到并删除匹配的监听器
for (const item of listeners) {
if (item.fn === fn) {
listeners.delete(item);
break;
}
}
if (listeners.size === 0) this.#listeners.delete(event);
return this;
}
emit(event, ...args) {
const listeners = this.#listeners.get(event);
if (!listeners) return false;
const toRemove = []; // 收集需要删除的 once 监听器
for (const item of [...listeners]) { // 拷贝一份,防止回调中修改 listeners
try {
item.fn(...args);
} catch (err) {
// 错误隔离——一个监听器挂了不影响其他
console.error(`Error in listener for '${event}':`, err);
}
if (item.once) toRemove.push(item);
}
// 清理 once 监听器
for (const item of toRemove) listeners.delete(item);
return true;
}
// 移除所有监听器
removeAllListeners(event) {
if (event) this.#listeners.delete(event);
else this.#listeners.clear();
return this;
}
// 获取事件监听的函数数量
listenerCount(event) {
return this.#listeners.get(event)?.size ?? 0;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
# 4.2 观察者(Subj
疑惑:"观察者模式和发布订阅模式不就是同一个东西吗?"
论证:两者有本质的耦合度差异——这是面试中被问最多的辨析点:
观察者模式 (Observer Pattern):
┌──────────────┐ 直接调用 ┌──────────────┐
│ Subject │ ───────────────────────► │ Observer │
│ (被观察对象) │ │ (观察者) │
│ │ │ │
│ + addObs(o) │ Subject 知道 Observer │ + update() │
│ + removeObs()│ 的存在,维护 observerList │ │
│ + notify() │ │ │
└──────────────┘ └──────────────┘
耦合关系:Subject 直接持有 Observer 引用
发布订阅模式 (Pub/Sub Pattern):
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ Publisher │────►│ EventBus │────►│ Subscriber │
│ (发布者) │ │ (事件总线) │ │ (订阅者) │
│ │ │ │ │ │
│ publisher │ │ + on(event) │ │ subscriber │
│ 不知道谁接收 │ │ + emit(event)│ │ 不知道谁发布 │
└──────────────┘ └──────────────┘ └──────────────┘
耦合关系:Publisher 和 Subscriber 完全不知道对方存在——通过 EventBus 解耦
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
代码对比:
// 观察者模式:Subject 直接维护 Observer 列表
class Subject {
#observers = new Set();
addObserver(obs) { this.#observers.add(obs); }
notify(data) { this.#observers.forEach(obs => obs.update(data)); }
}
// 发布订阅:通过独立的 EventBus
const bus = new EventEmitter();
// publisher.js(不知道谁在听)
bus.emit('order:created', order);
// subscriber.js(不知道谁发的)
bus.on('order:created', (order) => sendEmail(order));
2
3
4
5
6
7
8
9
10
11
12
13
14
15
决策表:
| 维度 | 观察者 | 发布订阅 |
|---|---|---|
| 耦合度 | Subject 知道 Observer 接口 | 双方互不知道 |
| 通信方式 | Subject 直接调 Observer.update() | 通过 EventBus 间接通信 |
| 适用场景 | 单一数据源 → 多个视图(如 Vue watch) | 跨模块通信(如微前端子应用通信) |
| JS 原型 | addEventListener / Vue watch | EventEmitter / Redux subscribe |
| 优点 | 简单、高效、无中间层 | 完全解耦、支持多对多 |
| 缺点 | Subject 和 Observer 有接口依赖 | 多了中间层、事件名需全局管理 |
# 4.3 与 Vue 的
三者都是"观察变化→做出响应"——但抽象层级不同:
EventEmitter: 原始的事件广播——"有人喊了一声,(event, data)"
Vue.watch: 特定响应式值变化 → 回调——"这个值变了就做这件事"
RxJS.Observable: 事件流 + 操作符——"对事件流做 filter/map/debounce/throttle"
2
3
// EventEmitter——最底层
bus.on('click', handler);
// Vue watch——数据驱动的特定监听
watch(() => state.count, (newVal, oldVal) => { /* ... */ });
// RxJS——流式处理
fromEvent(btn, 'click')
.pipe(
debounceTime(300), // 防抖
map(e => e.target.value), // 转换
filter(v => v.length > 0), // 过滤
switchMap(v => fetch(`/api?q=${v}`)) // 异步请求
)
.subscribe(data => render(data));
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 5. 策略与命令模式
# 5.1 策略模式:表单校
把第 1 章的"错误处理"思想扩展到完整的表单校验场景:
// ================ 校验规则库(策略对象) ================
const validators = {
// 每种策略是一个函数:value → true(通过) | 错误信息(不通过)
required: (v) => (v !== '' && v !== null && v !== undefined) || '此项必填',
email: (v) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v) || '邮箱格式不正确',
phone: (v) => /^1[3-9]\d{9}$/.test(v) || '手机号格式不正确',
url: (v) => { try { new URL(v); return true; } catch { return 'URL格式不正确'; } },
// 高阶策略——策略工厂(返回策略函数)
minLength: (n) => (v) => v.length >= n || `至少 ${n} 个字符`,
maxLength: (n) => (v) => v.length <= n || `最多 ${n} 个字符`,
pattern: (regex, msg) => (v) => regex.test(v) || msg,
in: (...values) => (v) => values.includes(v) || `只允许: ${values.join(', ')}`,
// 组合策略——一个字段可以有多个规则
all: (...rules) => (v) => {
for (const rule of rules) {
const fn = typeof rule === 'function' ? rule : validators[rule];
const result = fn(v);
if (result !== true) return result;
}
return true;
}
};
// ================ 校验引擎(不变的逻辑) ================
function validate(formData, schema) {
const errors = {};
for (const [field, rules] of Object.entries(schema)) {
const value = formData[field];
const ruleFn = typeof rules === 'function' ? rules : validators.all(...rules);
const result = ruleFn(value);
if (result !== true) {
errors[field] = result;
}
}
return {
valid: Object.keys(errors).length === 0,
errors
};
}
// ================ 使用——声明式 ================
const userSchema = {
name: ['required', validators.minLength(2)],
email: ['required', 'email'],
phone: validators.all('required', 'phone'), // 组合策略
website: 'url',
bio: validators.maxLength(200),
};
const result = validate(
{ name: '', email: 'bad', phone: '123' },
userSchema
);
console.log(result.errors);
// → { name: '此项必填', email: '邮箱格式不正确', phone: '手机号格式不正确' }
// 新增规则 = 在 validators 里加一个函数
// 不改 validate 引擎、不改 schema 结构
// → 开闭原则(对扩展开放,对修改关闭)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
# 5.2 命令模式:撤销
命令模式的本质:把每个操作封装为对象,该对象暴露 execute() 和 undo()——命令栈天然支持撤销/重做:
// ================ 命令接口 ================
class CommandManager {
#undoStack = [];
#redoStack = [];
#maxHistory = 50;
execute(command) {
command.execute();
this.#undoStack.push(command);
this.#redoStack = []; // 新操作后清空 redo
// 限制历史记录数量
if (this.#undoStack.length > this.#maxHistory) {
this.#undoStack.shift();
}
}
undo() {
const cmd = this.#undoStack.pop();
if (!cmd) return;
cmd.undo();
this.#redoStack.push(cmd);
}
redo() {
const cmd = this.#redoStack.pop();
if (!cmd) return;
cmd.execute();
this.#undoStack.push(cmd);
}
get canUndo() { return this.#undoStack.length > 0; }
get canRedo() { return this.#redoStack.length > 0; }
}
// ================ 具体命令——文本编辑器 ================
class InsertTextCommand {
constructor(doc, pos, text) {
this.doc = doc;
this.pos = pos;
this.text = text;
this.oldText = doc.getText(); // 快照(为简单起见取全文)
}
execute() {
this.doc.insertAt(this.pos, this.text);
}
undo() {
// 恢复到操作前的状态
this.doc.restore(this.oldText);
}
}
class DeleteTextCommand {
constructor(doc, pos, length) {
this.doc = doc;
this.pos = pos;
this.length = length;
this.deletedText = doc.getTextAt(pos, length); // 保存被删内容
}
execute() {
this.doc.deleteAt(this.pos, this.length);
}
undo() {
this.doc.insertAt(this.pos, this.deletedText);
}
}
// ================ 使用 ================
const manager = new CommandManager();
const doc = new Document('Hello');
manager.execute(new InsertTextCommand(doc, 5, ' World')); // Hello → Hello World
manager.execute(new DeleteTextCommand(doc, 0, 6)); // Hello World → World
manager.undo(); // → Hello World
manager.undo(); // → Hello
manager.redo(); // → Hello World
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
命令模式的关键设计点:
- 每个命令必须自己保存足够多的状态以便 undo(不能依赖"反操作"就能还原——
insertAt(pos, text)的 undo 不是deleteAt(pos, text.length),两者接收的参数不同) - 命令栈天然支持宏命令——把多个命令打包成一个
MacroCommand,一次 undo 全部撤销 - 在 React 中
useReducer就是命令模式——dispatch(action)是命令,reducer(state, action)是命令执行器
# 6. 适配器代理装饰
# 6.1 三者长得像但有本
疑惑:三个模式都是"在对象外面包一层"——到底怎么区分?
论证:三者外观相似但目的完全不同:
适配器 (Adapter):
┌──────────┐ ┌──────────┐ ┌──────────┐
│ 调用方 │───►│ Adapter │───►│ 被适配者 │
│ expects │ │ (转换) │ │ (老接口) │
│ InterfaceA│ └──────────┘ └──────────┘
└──────────┘
目的:让 A 看起来像 B(接口变化)
例子:axios 统一浏览器(XHR)和 Node(http)的请求接口
代理 (Proxy):
┌──────────┐ ┌──────────┐ ┌──────────┐
│ 调用方 │───►│ Proxy │───►│ Target │
│ │ │ (控制) │ │ (真实对象)│
└──────────┘ └──────────┘ └──────────┘
目的:控制对对象的访问(接口不变)
例子:Vue 3 reactive() / 图片懒加载
装饰器 (Decorator):
┌──────────┐ ┌──────────┐ ┌──────────┐
│ 调用方 │───►│ Decorator│───►│ Target │
│ │ │ (增强) │ │ (原对象) │
└──────────┘ └──────────┘ └──────────┘
目的:动态给对象加行为(接口不变但增强)
例子:@log / React HOC
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
代码对比——同一个功能用三种模式实现:
// ===== 场景:第三方支付 SDK 返回的数据格式和你前端不兼容 =====
// 第三方返回:{ payment_id: 'xxx', amount_in_cents: 2500, status_code: 1 }
// 你的前端需要:{ id: 'xxx', amount: 25.00, status: 'paid' }
// ✅ 适配器——接口改变了(字段名和数据结构都不同)
function paymentAdapter(raw) {
return {
id: raw.payment_id,
amount: raw.amount_in_cents / 100,
status: { 0: 'pending', 1: 'paid', 2: 'failed' }[raw.status_code],
};
}
// ===== 场景:大列表中的图片,滚到可视区域才加载 =====
// ✅ 代理——接口不变,只是控制了访问时机
function createLazyImage(src) {
return new Proxy({}, {
get(_, key) {
if (key === 'src') {
// 滚动到可视区域时才返回真实 URL
return isInViewport() ? src : 'placeholder.png';
}
}
});
}
// ===== 场景:给所有 API 调用加日志 =====
// ✅ 装饰器——接口不变,在原功能上叠加新行为
function withLog(fn, name) {
return function(...args) {
console.log(`[${name}] called with`, args);
const start = performance.now();
const result = fn.apply(this, args);
console.log(`[${name}] returned in ${(performance.now()-start).toFixed(2)}ms`);
return result;
};
}
const loggedFetch = withLog(fetch, 'fetch'); // fetch 的接口完全不变
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
决策表:
| 模式 | 接口变化? | 核心问题 | 判断口诀 |
|---|---|---|---|
| 适配器 | 变 | "能不能用"(兼容) | A 接口→B 接口 |
| 代理 | 不变 | "让不让用"(控制) | 拦在中间 |
| 装饰器 | 不变 | "能不能更强"(增强) | 包在外面 |
# 6.2 TC39 Sta
疑惑:"JS 的装饰器不就是 Java 的 @Annotation 吗?"
论证:完全不同。Java 注解是元数据标记——不执行代码,只提供信息给反射、编译器、框架读取。JS 装饰器是运行时函数调用——在对象创建/方法定义时实际执行代码:
// TC39 Stage 3 装饰器提案语法(尚未标准化,这里是基于提案的示例)
function logged(method, context) {
const name = String(context.name);
// 装饰器返回一个新的方法——实际替换了原方法
return function(...args) {
console.log(`[${name}] called`);
const result = method.apply(this, args);
console.log(`[${name}] returned ${result}`);
return result;
};
}
class API {
@logged
fetchUser(id) {
return fetch(`/api/user/${id}`).then(r => r.json());
}
}
const api = new API();
api.fetchUser(123);
// → [fetchUser] called
// → [fetchUser] returned [object Promise]
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
JS 装饰器 vs Java 注解:
| 维度 | JS 装饰器 | Java 注解 |
|---|---|---|
| 执行时机 | 运行时(定义时立即执行) | 编译时(保留在字节码中,运行时通过反射读取) |
| 作用 | 修改被装饰的目标(替换/增强) | 标记目标(不修改行为) |
| 类型 | 函数 | 特殊接口 |
| 例子 | @logged → 实际替换了原方法 | @Override → 只告诉编译器"我覆盖了父类方法" |
# 7. 纯函数与副作用隔离
# 7.1 相同的输入 →
疑惑:"纯函数不就是没有副作用的函数吗?这有什么好讲的?"
论证:纯函数的核心价值不是"干净"——而是你可以不看它的实现,只看签名就知道它做什么。这不是道德选择,是工程需要:
// ❌ 有副作用——你不知道它改了外部什么
const cache = {};
function getResult(key) {
if (!cache[key]) {
cache[key] = heavyCompute(key); // 修改外部 cache
}
return cache[key];
}
// 问题:getResult('a') 第一次返回 42,第二次也返回 42——但两次的"系统状态"不同了
// 你无法通过"看签名"知道它会不会改 cache——必须读实现
// ✅ 纯函数——所有依赖和影响都在参数和返回值里
function compute(key) {
return key.length * 2;
}
// 签名就是承诺:给你 key → 还你 number。不会改任何东西、不依赖任何外部状态
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
纯函数的三大工程收益:
- 可测试:不需要 mock 全局变量、数据库、文件系统——给输入,断言输出
- 可缓存(memoize):相同输入保证相同输出 → 放心缓存结果
- 可并行:纯函数之间没有共享状态 → 可以放心放到 Worker 里并行执行
// memoize 的高阶函数实现——只对纯函数有效
function memoize(fn) {
const cache = new Map();
return (...args) => {
const key = JSON.stringify(args);
if (!cache.has(key)) {
cache.set(key, fn(...args));
}
return cache.get(key);
};
}
const memoizedCompute = memoize(compute);
memoizedCompute('hello'); // 42(计算)
memoizedCompute('hello'); // 42(缓存命中,不计算)
// ⚠ 如果 compute 不是纯函数——缓存会给你过期/错误的结果
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 7.2 为什么 useE
React 把副作用隔离到 useEffect 的设计不是偶然——渲染本身应该是纯函数:
React 的心智模型:
组件 = (props, state) → JSX (纯函数映射)
副作用 = useEffect(() => { ... }, [deps]) (集中隔离)
2
3
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
// 副作用隔离:网络请求不在渲染路径上
useEffect(() => {
let cancelled = false;
fetch(`/api/user/${userId}`)
.then(r => r.json())
.then(data => {
if (!cancelled) setUser(data); // 防止竞态
});
return () => { cancelled = true; }; // 清理函数
}, [userId]);
// 渲染路径:纯映射
return user
? <div>{user.name}</div>
: <div>Loading...</div>;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
为什么渲染要纯:React 在并发模式(Concurrent Mode)下可能多次调用你的渲染函数(discard 不用的结果)——如果渲染函数有副作用(如发网络请求),这些副作用会被重复执行。
# 7.3 副作用隔离的四种
| 策略 | 代表 | 核心思路 | 适用场景 |
|---|---|---|---|
| Monad | Haskell / fp-ts | 把副作用"包"在一个容器里,通过链式调用传递——副作用在容器内发生,不影响外部纯代码 | 强类型 FP 语言/库 |
| IoC/DI(控制反转/依赖注入) | Angular / NestJS | 把外部依赖从构造函数注入——组件不自己 new,由框架提供 | 企业级后端、测试 mock 方便 |
| Saga | Redux-Saga | 用 Generator 的 yield 声明式描述副作用——saga 是"副作用的声明",执行由 saga middleware 管理 | 复杂异步流(竞态、取消、重试) |
| Effect Hook | React | 副作用集中声明在 useEffect——框架决定何时执行、何时清理 | UI 渲染的副作用 |
// Saga 示例——"副作用的声明式描述"
function* fetchUserSaga(action) {
try {
yield put({ type: 'LOADING_START' });
const user = yield call(api.fetchUser, action.payload.id); // 声明式
yield put({ type: 'FETCH_SUCCESS', payload: user });
} catch (e) {
yield put({ type: 'FETCH_ERROR', payload: e.message });
} finally {
yield put({ type: 'LOADING_END' });
}
}
// 每一行 yield 都是"我要做这件事"的声明——saga middleware 去实际执行
2
3
4
5
6
7
8
9
10
11
12
13
# 8. 不可变数据详解
# 8.1 structur
疑惑:"不可变数据不就等于深拷贝吗?每次改一个字段就复制整个对象——性能得多差?"
论证:不可变 ≠ 完整深拷贝。真正的不可变数据框架(如 Immer)用的是结构共享(Structural Sharing)——只复制"改变了的那条路径"上的节点:
原始对象:
root
/ \
a b
/ \ / \
c d e f
修改 root.a.d = 新值:
root' (new)
/ \
a' b ───────── (共享!)
/ \
c d' (new)
只创建了 root'、a'、d' 三个新节点
b 和 c 直接复用原始对象的引用
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
三种方案的性能矩阵(10 万次深度嵌套对象修改 benchmark):
| 方案 | 写法 | 耗时 | 内存增量 | 优点 | 缺点 |
|---|---|---|---|---|---|
{ ...obj, a: { ...obj.a, b: newVal } } | 手动展开 | 最快 (~8ms) | 最小(只创建新路径上的节点) | 零依赖、TypeScript 类型安全 | 深层嵌套代码噩梦 (...obj.a.b.c.d) |
structuredClone(obj) 然后改 | 深度拷贝 | 中等 (~45ms) | 最大(每次全对象拷贝) | 原生 API、写法简单 | 不能拷贝函数/类/DOM 节点、每次都全量拷贝 |
Immer produce(obj, draft => { draft.a.b = newVal }) | 像可变一样写 | 较慢 (~30ms) | 中等(自动结构共享) | 写法最自然、自动冻结返回对象 | 额外 4KB 依赖、Proxy 开销 |
选择建议:
// 浅层对象 → spread(最快、最直接)
const newObj = { ...obj, name: 'new' };
// 深层对象 + 单次修改 → structuredClone(简单直接)
const newObj = structuredClone(obj);
newObj.deep.nested.field = 'new';
// 深层对象 + 频繁修改 → Immer(最自然)
import { produce } from 'immer';
const newObj = produce(obj, draft => {
draft.a.b.c.d.e = 'new'; // 像改可变对象一样写,但返回不可变快照
});
2
3
4
5
6
7
8
9
10
11
12
# 8.2 为什么 Reac
疑惑:"React 内部不能自己深比较吗?为什么非要开发者手动保持不可变?"
论证:React 的 PureComponent / React.memo / useMemo 依赖引用比较(===) 判断数据是否变化。这个设计是有意为之——深比较的代价太大:
// React.memo 的判断逻辑(简化):
function memo(Component) {
let prevProps = null;
return function MemoWrapper(props) {
// 只用 === 比较每个 prop(O(n) 浅比较,n = props 数量)
if (prevProps && Object.keys(props).every(k => props[k] === prevProps[k])) {
return prevResult; // 跳过重新渲染
}
prevProps = props;
prevResult = Component(props);
return prevResult;
};
}
// 如果 React 改成深比较:
// 每个组件每次渲染都要深比较所有 props → O(m) (m = props 总节点数)
// → 1000 个组件 × 100 个属性节点 = 10 万次比较/帧 → 掉帧
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
正确 vs 错误:
// ❌ 原地修改——React 检测不到变化
state.items.push(newItem);
setState(state); // state 引用没变 → React 认为"没变化" → 不重新渲染
// ✅ 创建新引用——React 检测到变化
setState({
...state,
items: [...state.items, newItem]
}); // state 引用变了 → React 重新渲染
2
3
4
5
6
7
8
9
# 9. pipe与May
# 9.1 把 10 层嵌套
疑惑:pipe(f, g, h)(x) 和 h(g(f(x))) 做的事情完全一样——为什么 pipe 就是"更好"?
论证:因为人的大脑读代码是从左到右、从上到下的。嵌套要求你从内向外反着读:
// ❌ 嵌套——从内向外读:先看最里面的 validate,再往外看 parse...
const result = truncate(normalize(parse(validate(input))));
// ✅ pipe——从左到右读:input → validate → parse → normalize → truncate
const pipe = (...fns) => (x) => fns.reduce((v, f) => f(v), x);
const process = pipe(validate, parse, normalize, truncate);
const result = process(input);
2
3
4
5
6
7
pipe vs compose:
// pipe: 从左到右(数据流的自然方向)
const pipe = (...fns) => (x) => fns.reduce((v, f) => f(v), x);
pipe(f, g, h)(x); // = h(g(f(x)))
// compose: 从右到左(数学函数组合的方向)
const compose = (...fns) => (x) => fns.reduceRight((v, f) => f(v), x);
compose(f, g, h)(x); // = f(g(h(x)))
// 等价于:compose(h, g, f)(x) = f(g(h(x))) ← 注意参数顺序是反的!
2
3
4
5
6
7
8
pipe 在异步场景下的威力:
// 异步 pipe——数据流过 Promise 链
const asyncPipe = (...fns) => (x) =>
fns.reduce((p, f) => p.then(f), Promise.resolve(x));
const getUserOrders = asyncPipe(
(id) => fetch(`/api/user/${id}`).then(r => r.json()),
(user) => fetch(`/api/orders?userId=${user.id}`).then(r => r.json()),
(orders) => orders.filter(o => o.status === 'paid'),
(paidOrders) => paidOrders.map(o => ({ id: o.id, total: o.amount }))
);
const result = await getUserOrders(42);
// 42 → 查用户 → 查订单 → 筛选已付款 → 字段映射 → result
2
3
4
5
6
7
8
9
10
11
12
13
# 9.2 Maybe
疑惑:"obj?.a?.b?.c 不就是 Maybe 模式吗?还需要自己写?"
论证:可选链 ?. 只能解决"取属性时遇到 null/undefined 短路返回 undefined"——但当你需要对中间值做转换、校验、错误追踪时,它就无能为力了:
// 场景:user.address.city 可能在任何层级为 null
const user = { address: null };
// 方案 A:可选链——只能取,不能转换
const city = user?.address?.city ?? 'Unknown';
// 方案 B:Maybe——可以在链上做任何操作
class Maybe {
#value;
constructor(v) { this.#value = v; }
static of(v) { return new Maybe(v); }
static nothing() { return new Maybe(null); }
map(fn) {
return this.#value == null ? Maybe.nothing() : Maybe.of(fn(this.#value));
}
// 用于副作用操作(如日志、DOM 更新)——不影响链
tap(fn) {
if (this.#value != null) fn(this.#value);
return this;
}
// flatMap(bind)——防止嵌套 Maybe<Maybe<T>>
flatMap(fn) {
return this.#value == null ? Maybe.nothing() : fn(this.#value);
}
// 链式短路检查
chain(...fns) {
return fns.reduce((m, fn) => m.map(fn), this);
}
unwrap(defaultVal) { return this.#value ?? defaultVal; }
}
// 使用示例
const cityName = Maybe.of(user)
.map(u => u.address) // null → 短路为 Maybe.nothing()
.map(a => a.city) // 不会因为 a 为 null 而崩
.map(c => c.toUpperCase())
.tap(name => console.log('City:', name)) // 只在有值时执行
.unwrap('Unknown');
console.log(cityName); // → 'Unknown'
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
Either 模式——左手错误、右手正确:
// Either: Left(错误) | Right(正确)
// 用于需要"知道哪里出错了"而不仅是"出错了"的场景
class Either {
#value; #isLeft;
constructor(v, isLeft) { this.#value = v; this.#isLeft = isLeft; }
static left(err) { return new Either(err, true); }
static right(val) { return new Either(val, false); }
map(fn) { return this.#isLeft ? this : Either.right(fn(this.#value)); }
chain(fn) { return this.#isLeft ? this : fn(this.#value); }
fold(leftFn, rightFn) {
return this.#isLeft ? leftFn(this.#value) : rightFn(this.#value);
}
}
function parseJSON(str) {
try {
return Either.right(JSON.parse(str));
} catch (e) {
return Either.left(`Parse error: ${e.message}`);
}
}
parseJSON('{"name":"Alice"}')
.map(data => data.name)
.fold(
err => console.error(err), // 错误分支
name => console.log(name) // 正确分支
);
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
# 9.3 TC39 Sta
// Stage 2 提案——pipe 的语言级支持(尚未标准化,仅供参考)
// 基于 Hack 风格(% 占位符):
const result = input
|> validate(%)
|> parse(%, options)
|> normalize(%)
|> truncate(%);
// 等价于:
const result = truncate(normalize(parse(validate(input), options)));
// 如果 Stage 2 最终落地(2025年后):
// 1. pipe 从库函数变成语法
// 2. 支持 async:const x = input |> await fetch(%) |> (await %).json()
// 3. 和 try/catch 配合:input |> validate(%) |> try parse(%) catch log(%)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 10. 综合案例串讲
# 10.1 案例真相揭晓
回到第 1 章的按钮重构场景,五个假设现在能逐条作答:
| 疑问 | 答案 |
|---|---|
| ① A 和 B 功能一样、写法不同而已? | 第 1.2:A 的加载态/数据获取/渲染完全耦合。B 通过高阶函数(withLoading/withRetry/debounce)和函数组合(pipe)把每个关注点拆成独立模块——新增需求不改原有模块,只加新模块再组合 |
| ② 设计模式就是换个写法? | 第 2 章:模式的核心不是"怎么写",而是"把什么和什么解耦"。单例解耦"何时创建"和"使用者";观察者解耦"数据变化"和"响应行为";策略解耦"条件分支"和"具体处理" |
| ③ 单例/策略/观察者/命令在 JS 里的核心落点 | 第 3-5 章:ES6 模块天然单例(顶层 export);策略表替代 if/else;EventEmitter 手写实现;命令栈支持 undo/redo |
| ④ 适配器/代理/装饰器的区别 | 第 6.1 节:适配器=改接口(兼容)、代理=控访问(透明拦截)、装饰器=加行为(增强不改变接口)——记住口诀:接口变没变? |
| ⑤ pipe/Maybe/不可变的选择矩阵 | 第 8.1 + 9.1~9.2:浅层→spread、深层一次性→structuredClone、深层频繁→Immer;pipe 消嵌套、Maybe 消 null 防御——共同哲学:把"怎么处理"从"怎么判断"中分离出来 |
# 10.2 一个「订单处理管
把第 1 章的思路用到完整业务场景——订单处理管线:
// ========== 重构前:30 行 if/else 混杂 ==========
function processOrder(order) {
if (!order.items || order.items.length === 0) {
throw new Error('订单没有商品');
}
let total = 0;
for (const item of order.items) {
if (item.price < 0) throw new Error('商品价格异常');
if (item.quantity <= 0) continue; // 跳过数量为 0 的商品
let lineTotal = item.price * item.quantity;
// 不同类型不同折扣
if (item.type === 'digital') {
lineTotal *= 0.9; // 数字商品 9 折
} else if (item.type === 'member') {
lineTotal *= 0.85; // 会员商品 85 折
}
// 新增类型 → 加一个 else if
total += lineTotal;
}
// 满减
if (total >= 500) total -= 50;
else if (total >= 200) total -= 10;
// 优惠券
if (order.coupon === 'SAVE20') total -= 20;
else if (order.coupon === 'SAVE50') total -= 50;
// 运费
if (total < 99) total += 10;
return { ...order, total: Math.max(0, total) };
}
// ========== 重构后:策略 + pipe ==========
// ① 校验管线——策略模式
const itemValidators = {
noItems: (items) => items?.length > 0 || '订单没有商品',
validPrice: (items) => items.every(i => i.price >= 0) || '商品价格异常',
};
const validateOrder = (order) => {
for (const [key, fn] of Object.entries(itemValidators)) {
const result = fn(order.items);
if (result !== true) throw new Error(result);
}
return order; // 通过校验,原样返回(保持 pipe 链)
};
// ② 折扣策略——策略表(新增折扣类型只加一行)
const discountStrategies = {
digital: (total) => total * 0.9,
member: (total) => total * 0.85,
default: (total) => total,
};
// ③ 价格计算——纯函数
const calcTotal = (order) => ({
...order,
total: order.items
.filter(i => i.quantity > 0)
.reduce((sum, item) => {
const lineTotal = item.price * item.quantity;
const discount = discountStrategies[item.type] || discountStrategies.default;
return sum + discount(lineTotal);
}, 0)
});
// ④ 满减策略——策略表
const tierDiscount = (total) => {
if (total >= 500) return total - 50;
if (total >= 200) return total - 10;
return total;
};
// ⑤ 优惠券策略
const couponDiscounts = {
SAVE20: (t) => t - 20,
SAVE50: (t) => t - 50,
};
const applyCoupon = (order) => ({
...order,
total: (couponDiscounts[order.coupon] || ((t) => t))(order.total)
});
// ⑥ 运费
const addShipping = (order) => ({
...order,
total: order.total >= 99 ? order.total : order.total + 10
});
// ⑦ 保证非负
const ensureNonNegative = (order) => ({
...order,
total: Math.max(0, order.total)
});
// ⑧ 组装——pipe
const processOrder = pipe(
validateOrder,
calcTotal,
(o) => ({ ...o, total: tierDiscount(o.total) }),
applyCoupon,
addShipping,
ensureNonNegative
);
// 新增需求 = 往 pipe 里加一步——不改任何已有函数
// 例如新增"黑名单用户加收 20% 服务费":
// const blacklistSurcharge = (order) =>
// blacklist.includes(order.userId)
// ? { ...order, total: order.total * 1.2 }
// : order;
// processOrder = pipe(validateOrder, ..., blacklistSurcharge);
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
重构效果对比:
| 维度 | 重构前 | 重构后 |
|---|---|---|
| 总行数 | 30 行 | 60 行(但每个函数 5~8 行、独立可读) |
| 新增折扣类型 | 加一个 else if,摸到 calcTotal 内部 | 在 discountStrategies 里加一行 |
| 新增校验规则 | 在 processOrder 开头加代码 | 在 itemValidators 里加一个函数 |
| 单元测试 | 只能测 processOrder 整体(mock 所有依赖) | 每个函数独立测试(给输入 → 断言输出) |
| 代码复用 | 0%(全耦合在一个函数里) | tierDiscount / couponDiscounts 可在其他页面复用 |
# 10.3 设计哲学回扣
整理本篇的三条跨篇适用的设计哲学:
哲学一·「模式不是教条——是对变化的防御」
设计模式不是"每段代码都要套一个模式"。而是——当你发现一处代码"每次需求变更都要改这里"时,模式给了你一个已验证的重构方向。单例解决"怎么创建唯一实例"——但如果你不确定未来会不会需要第二个实例,就不要急着上单例。模式的本质不是代码模板——是"把什么和什么解耦"的经验沉淀。 先感受到痛(需求一变就要改 3 个文件),再找对应的模式解耦——而不是反过来。
哲学二·「纯函数不是规则——是推理的边界」
纯函数的最大价值不是"没有副作用"——而是你可以不看它的实现,只看签名就知道它做什么。纯函数给了你一个"推理边界":输入和输出。你不需要追踪它的调用链、不需要检查全局变量、不需要考虑执行时机。这就是为什么 React 组件、Vue computed、Redux reducer 都倾向纯函数——不是道德选择,是工程需要:当你维护 10 万行代码时,你不可能记住每个函数的副作用清单。
哲学三·「组合是对嵌套的降维打击」
pipe(f, g, h)(x) 和 h(g(f(x))) 做的事情完全一样——但前者让你"从左到右读数据的流动",后者让你"从内到外跟踪嵌套"。组合把"嵌套的嵌套关系"变成了"同级的并列关系"——这是认知上的降维。你的大脑不再需要维护一个"从内向外"的栈,只需要一行一行往下扫。这就是为什么 Unix 管道的 | 操作符存活了 50 年——它让人类可以理解"数据是怎么一步步流过去的"。
# 10.4 速查表速览
速查表 A:七种设计模式 + 框架对应
| 模式 | 类别 | 一句话 | JS 原生/框架对应 |
|---|---|---|---|
| 单例 | 创建型 | 全局只有一份实例 | ES6 模块顶层 export(天然缓存) |
| 工厂 | 创建型 | 把"new 什么"的决定权移走 | React.createElement / 注册式工厂 |
| 适配器 | 结构型 | 让 A 看起来像 B(接口变了) | axios 统一浏览器和 Node 的 HTTP |
| 代理 | 结构型 | 控制对对象的访问(接口不变) | Vue 3 reactive() / ES6 Proxy |
| 装饰器 | 结构型 | 给对象加行为(不改变接口) | React HOC / TC39 @decorator |
| 观察者 | 行为型 | Subject 直接通知 Observer | Vue watch / DOM addEventListener |
| 策略 | 行为型 | 策略表替代 if/else | 表单校验规则对象 / Array.sort(compareFn) |
| 命令 | 行为型 | 操作封装为对象 → undo/redo | useReducer / 富文本编辑器命令栈 |
速查表 B:pipe vs compose
| pipe | compose | |
|---|---|---|
| 执行顺序 | 从左到右 | 从右到左 |
| 读法 | pipe(f, g, h) = 先 f 再 g 再 h | compose(f, g, h) = 先 h 再 g 再 f |
| 直觉 | 符合"数据流"思维 | 符合"数学函数组合"思维 |
| 适用 | 数据变换管线 | 高阶组件组合(React) |
速查表 C:不可变方案选择矩阵
| 场景 | 推荐方案 | 原因 |
|---|---|---|
| 浅层对象(≤3 层) | { ...obj, field: newVal } | 最快、零依赖、TS 类型安全 |
| 深层 + 单次修改 | structuredClone(obj) 再改 | 简单直接、原生 API |
| 深层 + 频繁修改 | Immer produce(obj, draft => { ... }) | 写法最自然、自动结构共享 |
| 大数组 push | [...arr, newItem] | 最快、最简洁 |
| 大数组 splice | Immer 或手动 [...arr.slice(0,i), ...arr.slice(i+1)] | Immer 更可读 |
60 秒诊断清单:
1. 这段代码"改了一个需求是否要动多个地方"?
→ 是 → 用策略模式(策略表)或工厂模式解耦
2. 两个对象需要通信但不想互相引用?
→ 观察者(Subject 知 Observer)vs 发布订阅(通过 EventBus 隔离)
3. 有 if/else 丛林吗?
→ 是 → 策略表(对象映射)替代
4. 需要 undo/redo 吗?
→ 是 → 命令模式(execute + undo + 命令栈)
5. 第三方 API 格式不兼容?
→ 适配器(写一个转换函数)
6. 渲染慢?浅比较失效?
→ 检查是否直接 mutate 了 state → 用不可变方式更新
7. 函数有副作用导致测试/调试困难?
→ 纯函数 + 副作用隔离(useEffect/Saga/DI)
8. 嵌套调用链太长?
→ pipe/compose → 把嵌套变成并列
9. 满屏的 if (x == null) return null?
→ Maybe 链式调用 / 可选链 ?.
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
认知模型——从 if/else 到策略+pipe 的思维转换:
写代码时的判断流:
看到 if/else → 问:"这些分支会新增吗?" → 会 → 策略对象
看到 try/finally → 问:"这个模板会重复吗?" → 会 → 高阶函数包装
看到嵌套调用 h(g(f(x))) → 问:"读起来累吗?" → 累 → pipe(f,g,h)(x)
看到 if (x == null) return → 问:"这种情况多吗?" → 多 → Maybe 模式
看到 setState(obj.x = v) → 问:"React 能检测到吗?" → 不能 → 不可变更新
2
3
4
5
6
下一步:代码优雅了。但用户不止在浏览器里——跨端才是终极战场。进入 16.跨端架构终局。