主题
前端程序设计模式 · 导览
这一页回答三件事:模式在前端语境下长什么样、什么时候根本不需要模式、以及这个板块的每一页该怎么读。
设计模式的书大多用静态类型语言(Java / C++)举例:AbstractFactory、Visitor、Singleton 都要写好几个类。但前端的运行环境和那些例子差得太远:我们有 ES 模块、有函数是一等公民、有闭包、有 Promise、有组件与 hooks。同一批模式在前端往往只要一个函数就能表达,而有些「模式」已经被语言或框架内建了 —— 照着 Java 例子硬抄,只会得到一堆没必要的类和文件。
1. 三大分类:模式在关心什么
分类只是索引,不是重要性排序。真正决定用不用某个模式的,永远是「它解决的那个问题现在存不存在」。
| 分类 | 关心什么 | 本板块涉及的 | 前端最常见的落点 |
|---|---|---|---|
| 创建型 | 对象怎么被造出来、造几个、由谁决定造哪一个 | 工厂与注册表、单例、建造者、原型、对象池、依赖注入 | 图表/渲染器实例、请求客户端、配置对象、频繁创建的小对象 |
| 结构型 | 对象之间怎么拼装、怎么在不改原对象的前提下加能力 | 适配器、装饰器、代理、外观、享元、桥接 | 第三方 API 适配层、请求拦截、缓存代理、浏览器 API 封装 |
| 行为型 | 对象之间怎么通信、算法怎么替换、操作怎么记录 | 策略、模板方法、命令、迭代器 | 校验规则、排序比较器、撤销/重做、分页与流式读取 |
后续几页还会展开观察者与发布订阅、状态机与单向数据流、组件模式、架构模式、反模式与重构——它们更贴近框架与工程结构,和这三类是同一个坐标系里的东西。
2. 前端特有的语境
同一句「用组合代替继承」,在 Java 里靠接口 + 委托,在前端直接就是「把函数传进去」。下面这些语言与平台能力,是前端模式与经典例子最大的差别:
| 前端能力 | 它内建替代了什么模式 | 为什么 |
|---|---|---|
| ES 模块(一个文件一个模块,只求值一次) | 单例 | 模块缓存本身就是「全局唯一实例」,不需要 getInstance() |
| 函数是一等公民 | 策略、命令、工厂、装饰器、模板方法 | 这些模式的本质是「把行为当参数传」,函数天然就是行为 |
| 闭包 | 私有字段、工厂产出的状态容器 | 作用域直接提供封装,不必为每个字段写类 |
Promise / async | 异步回调编排、部分代理与外观 | 装饰异步函数就是包装 Promise |
生成器与 Symbol.iterator | 迭代器 | for...of 与 function* 已内建迭代协议 |
| 组件与 hooks | 组合式复用、状态局部化 | 复用单位从「类层级」变成「函数调用」 |
| 响应式系统 | 观察者与发布订阅的常见实现 | 框架已经把依赖收集做掉了,手写发布订阅多半是重复建设 |
先问「语言里有没有」
在动手写模式之前,先花两分钟查一遍语言内建能力:as const 对象可以替代枚举,模块可以替代单例,高阶函数可以替代装饰器,生成器可以替代手写迭代器。能内建就不要自建 —— 这是本板块反复强调的立场。
3. 模式不是目标
模式是对反复出现的问题的命名,它的价值在于沟通与约束,而不是代码本身。下面这些「想用模式」的理由,通常都能用更简单的东西替代:
| 你想要的 | 先试这个 | 什么时候才值得升级成模式 |
|---|---|---|
| 一个全局唯一的配置/客户端 | 直接 export const client = ... | 需要多实例、需要在测试里替换实现时改成依赖注入 |
| 一个「以后可能会变」的算法 | 先写一个函数,需要时再传进来 | 真的出现第二个实现,且选择发生在运行期 |
| 给某个函数加点日志/缓存 | 在调用处包一层 | 同一种包装要在几十处复用、且需要任意组合 |
| 复用一段 UI 逻辑 | 抽成函数或组合式函数 | 逻辑需要在多个组件子树间共享状态时才上状态管理 |
| 「看起来更专业」的类层级 | 组合函数 + 朴素对象 | 基本不会到这一步:类层级越深,改一处越难 |
没有第二个实现时,抽象是负债
策略模式只有一个策略、工厂只造一种对象、适配器只适配一个已经稳定的 API —— 这些都是典型的过度设计。抽象的成本是立即可见的(多一层间接、多一个文件、改代码要跳三处),收益却要等到变化真的发生。所以判断标准很简单:现在就存在(或极可能马上出现)第二种实现吗?
4. 本板块地图
前五页是「模式的基础设施」,后五页是「框架与工程结构」,建议按顺序读:
| 页面 | 主题 | 你会带走什么 |
|---|---|---|
| 模式板块导览(本页) | 语境、分类、取舍 | 判断「要不要上模式」的标准 |
| 模块与组合 | 模块、显式依赖、组合、柯里化、pipe/compose、闭包 | 用函数拼装能力,而不是用继承堆层级 |
| 创建型模式 | 工厂注册表、单例、建造者、原型、对象池、依赖注入 | 对象从哪来、由谁决定造什么 |
| 结构型模式 | 适配器、装饰器、代理、外观、享元、桥接 | 不改原对象地加能力、隔离外部世界 |
| 策略、模板方法与命令 | 策略、模板方法、命令、迭代器 | 可替换算法、固定骨架、可撤销操作 |
| 观察者与发布订阅(同板块后续页) | 事件解耦的两种经典写法与其代价 | 什么时候该用响应式系统代替手写订阅 |
| 状态机与单向数据流(同板块后续页) | 有限状态、动作、reducer、数据流方向 | 把「散落的布尔量」换成显式状态 |
| 组件模式(同板块后续页) | 复合组件、受控/非受控、插槽与渲染属性 | 组件 API 的设计取舍 |
| 架构模式(同板块后续页) | 分层、依赖方向、边界与模块划分 | 让业务逻辑不依赖框架细节 |
| 反模式与重构(同板块后续页) | 常见坏味道与安全的改法 | 识别并渐进式重构 |
5. 每一页怎么读
每篇都按同一套顺序展开,你可以按需跳读:
| 顺序 | 小节 | 带着什么问题读 |
|---|---|---|
| ① | 解决什么问题 | 这个模式的名字对应的是哪种真实痛点? |
| ② | TypeScript 骨架 | 最小可用的形状是什么?类型怎么写才既准确又不啰嗦? |
| ③ | 前端真实场景 | 它出现在我每天写的哪一行代码里? |
| ④ | 代价与替代方案 | 用了它会多付出什么?语言/框架里有没有内建替代? |
| ⑤ | 什么时候不该用这个模式 | 反例长什么样,什么时候该退回去? |
示例一律用 TypeScript 编写,遵守 TypeScript 示例约定:可擦除语法、类型导入用 import type、默认导出 (container) => cleanup,并且会被 tsc 真正检查。
6. 先看一个策略模式
策略模式是最好入门的例子:把「算法」和「用算法的流程」分开。下面的示例把表单校验规则做成可插拔的策略对象 —— 勾选即增删规则,校验流程一行都不用改。
可插拔的表单校验策略勾选增删规则、改输入值看实时结果;「遇错即停」对比短路与全量两种组合方式
examples/patterns/strategy-validation.jsts
/**
* 【前端程序设计模式 · 策略模式】可插拔的表单校验
* ------------------------------------------------------------------
* 把「校验规则」抽成一个个独立、可互换的策略对象,校验流程只负责按顺序
* 调用它们:
* · 规则是数据(对象),不是散落一地的 if / else 分支;
* · 规则之间互不引用,增删规则不需要改校验流程的代码;
* · 用 `satisfies Record<string, ValidationRule>` 保留每个键的字面量类型,
* 再用 `keyof typeof RULES` 反推联合类型,改名时 tsc 直接报错;
* · 只用可擦除语法:没有 enum / namespace / 参数属性 / 装饰器。
*/
import { createPanel } from '../shared/ui.js'
/** 一条规则能拿到的全部输入:当前字段值 + 同表单的其他字段(跨字段规则要用) */
interface FieldContext {
readonly value: string
readonly others: Readonly<Record<string, string>>
}
/** 策略对象:描述「怎么判断」以及「不通过时说什么」 */
interface ValidationRule {
readonly label: string
/** 通过返回 null;不通过返回展示给用户的错误信息 */
validate(context: FieldContext): string | null
}
const MIN_LENGTH = 8
const WEAK_PASSWORDS = ['12345678', 'password', 'qwertyui', 'admin888']
/**
* 规则注册表。对象键就是规则的 id,值本身是策略对象,
* 于是「新增一条规则」= 往这里加一项,没有任何 switch 需要同步修改。
*/
const RULES = {
required: {
label: '非空',
validate: ({ value }: FieldContext) => (value.length > 0 ? null : '不能为空')
},
minLength: {
label: `至少 ${MIN_LENGTH} 个字符`,
validate: ({ value }: FieldContext) =>
value.length >= MIN_LENGTH ? null : `还差 ${MIN_LENGTH - value.length} 个字符`
},
hasDigit: {
label: '含数字',
validate: ({ value }: FieldContext) => (/\d/.test(value) ? null : '至少要有一个数字')
},
hasUpper: {
label: '含大写字母',
validate: ({ value }: FieldContext) => (/[A-Z]/.test(value) ? null : '至少要有一个大写字母')
},
hasSymbol: {
label: '含符号',
validate: ({ value }: FieldContext) => (/[^A-Za-z0-9]/.test(value) ? null : '至少要有一个符号')
},
noWhitespace: {
label: '不含空白',
validate: ({ value }: FieldContext) => (/\s/.test(value) ? '不能包含空格或制表符' : null)
},
notCommon: {
label: '不是常见密码',
validate: ({ value }: FieldContext) =>
WEAK_PASSWORDS.includes(value.toLowerCase()) ? '这是最常见的弱密码之一' : null
},
matchesConfirm: {
label: '与确认框一致',
validate: ({ value, others }: FieldContext) =>
value === others.confirm ? null : '两次输入不一致'
}
} satisfies Record<string, ValidationRule>
/** 从对象反推联合类型:'required' | 'minLength' | ... */
type RuleId = keyof typeof RULES
/** Object.keys 只能给出 string[],这里用一次断言换回 RuleId[] */
const ALL_RULES = Object.keys(RULES) as RuleId[]
/** 单条规则的执行结果:message 为 null 表示通过,skipped 表示被短路跳过 */
interface RuleOutcome {
readonly id: RuleId
readonly label: string
readonly message: string | null
readonly skipped: boolean
}
/**
* 校验器本身也是「策略」:它只依赖传入的策略列表,
* 换一批规则、换一种组合方式(短路 / 全部执行)都不用它改一行。
*/
function createValidator(
selected: readonly RuleId[],
shortCircuit: boolean
): (context: FieldContext) => RuleOutcome[] {
// 这一步就是「策略选择」:把 id 解析成真正的策略对象,顺序即执行顺序
const strategies = selected.map((id) => ({ id, rule: RULES[id] }))
return (context: FieldContext) => {
const outcomes: RuleOutcome[] = []
let stopped = false
for (const { id, rule } of strategies) {
if (stopped) {
outcomes.push({ id, label: rule.label, message: null, skipped: true })
continue
}
const message = rule.validate(context)
if (message !== null && shortCircuit) stopped = true
outcomes.push({ id, label: rule.label, message, skipped: false })
}
return outcomes
}
}
export default function (container: HTMLElement): () => void {
let password = 'Pattern2024'
let confirm = 'Pattern2024'
let shortCircuit = false
const active = new Set<RuleId>(['required', 'minLength', 'hasDigit', 'hasUpper'])
const wrap = document.createElement('div')
wrap.className = 'demo-dom'
wrap.style.cssText =
'display:grid;gap:12px;grid-template-columns:repeat(auto-fit,minmax(250px,1fr))'
container.appendChild(wrap)
function card(title: string): HTMLElement {
const box = document.createElement('div')
box.className = 'demo-card'
const head = document.createElement('p')
head.className = 'demo-panel__title'
head.textContent = title
box.appendChild(head)
return box
}
const resultCard = card('校验结果')
const detailCard = card('策略执行明细')
wrap.append(resultCard, detailCard)
const verdict = document.createElement('p')
verdict.className = 'demo-badge'
const valueLine = document.createElement('p')
valueLine.className = 'demo-mono'
valueLine.style.cssText = 'margin:8px 0;color:var(--vp-c-text-2);word-break:break-all'
const failList = document.createElement('ul')
failList.style.cssText = 'margin:0;padding-left:18px'
resultCard.append(verdict, valueLine, failList)
const detailList = document.createElement('div')
detailList.style.cssText = 'display:flex;flex-direction:column;gap:4px'
const detailFoot = document.createElement('p')
detailFoot.className = 'demo-panel__note'
detailFoot.style.marginTop = '8px'
detailCard.append(detailList, detailFoot)
const panel = createPanel(container, { title: '可插拔的校验策略', position: 'below' })
panel.text({
label: '密码',
value: password,
onInput: (value) => {
password = value
render()
}
})
panel.text({
label: '确认密码(跨字段规则用)',
value: confirm,
onInput: (value) => {
confirm = value
render()
}
})
panel.note('勾选即增删策略;校验流程、错误收集方式都不需要改。')
for (const id of ALL_RULES) {
panel.checkbox({
label: RULES[id].label,
value: active.has(id),
onChange: (checked) => {
if (checked) active.add(id)
else active.delete(id)
render()
}
})
}
panel.checkbox({
label: '遇错即停(短路求值)',
value: shortCircuit,
onChange: (checked) => {
shortCircuit = checked
render()
}
})
panel.buttons([
{
label: '全部启用',
group: false,
onClick: () => {
for (const id of ALL_RULES) active.add(id)
render()
}
},
{
label: '清空规则',
group: false,
onClick: () => {
active.clear()
render()
}
}
])
const readout = panel.readout()
panel.note('提示:一条规则都没启用时校验永远通过 —— 这是策略模式最容易踩的坑。')
function render(): void {
const selected = ALL_RULES.filter((id) => active.has(id))
const validate = createValidator(selected, shortCircuit)
const context: FieldContext = { value: password, others: { confirm } }
const outcomes = validate(context)
const failures = outcomes.filter((outcome) => outcome.message !== null)
verdict.textContent =
failures.length === 0
? `通过 ✓(执行 ${outcomes.filter((o) => !o.skipped).length} 条策略)`
: `未通过 ✗(${failures.length} 条规则不满足)`
verdict.style.background =
failures.length === 0 ? 'var(--vp-c-success-soft)' : 'var(--vp-c-danger-soft)'
verdict.style.color = failures.length === 0 ? 'var(--vp-c-success-1)' : 'var(--vp-c-danger-1)'
valueLine.textContent = `context.value = "${password}" others.confirm = "${confirm}"`
failList.replaceChildren()
for (const outcome of failures) {
const item = document.createElement('li')
item.className = 'demo-mono'
item.textContent = `${outcome.label} → ${outcome.message ?? ''}`
item.style.color = 'var(--vp-c-danger-1)'
failList.appendChild(item)
}
if (failures.length === 0) {
const item = document.createElement('li')
item.className = 'demo-mono'
item.style.color = 'var(--vp-c-text-3)'
item.textContent = '(没有错误信息 —— 所有启用的策略都返回了 null)'
failList.appendChild(item)
}
detailList.replaceChildren()
for (const outcome of outcomes) {
const row = document.createElement('div')
row.className = 'demo-mono'
row.style.cssText = 'display:flex;gap:8px;align-items:baseline'
const mark = document.createElement('span')
mark.textContent = outcome.skipped ? '·' : outcome.message === null ? '✓' : '✗'
mark.style.color = outcome.skipped
? 'var(--vp-c-text-3)'
: outcome.message === null
? 'var(--vp-c-success-1)'
: 'var(--vp-c-danger-1)'
const text = document.createElement('span')
text.textContent = outcome.skipped
? `${outcome.id}(被短路跳过)`
: `${outcome.id} → ${outcome.message ?? 'null'}`
text.style.color = outcome.skipped ? 'var(--vp-c-text-3)' : 'var(--vp-c-text-2)'
row.append(mark, text)
detailList.appendChild(row)
}
detailFoot.textContent = shortCircuit
? '短路模式:第一条错误之后的策略不再执行,只回报最先出现的问题。'
: '全量模式:所有策略都会执行,一次拿到全部错误 —— 表单逐字段提示通常要这个。'
readout.set(
`启用 ${selected.length}/${ALL_RULES.length} 条策略 · 本轮执行 ${outcomes.filter((o) => !o.skipped).length} 条 · 失败 ${failures.length} 条`
)
}
render()
return () => {
wrap.remove()
panel.dispose()
}
}1
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
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
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
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
什么时候不该用这个模式
「模式」这个词本身就容易让人上头,所以这一节在导览页就要说清楚:大多数情况下,你不需要先选模式,再去找问题。
- 只有一个实现时不抽象。 策略模式只有一个策略、工厂只有一种产品、适配器只有一个已经稳定的 API:这些都是纯负债。等第二个实现出现(最好是在同一个迭代里出现)再抽。
- 不要为了套模式而拆文件。 「一个类一个文件」是 Java 的产物。前端一个模块里放 3 个相关的纯函数通常比 3 个文件加一层 re-export 更好读。
- 框架已经做掉的事不要重做。 手写发布订阅去驱动 UI、手写依赖注入容器去管理组件依赖、手写观察者去追踪数据变化 —— 这些在前端框架里通常已有更可靠的实现。
- 不要用模式来兜住「不确定的需求」。 抽象不会让需求变清楚,只会让改动变贵。先用最直白的代码把需求跑通,再在第二次修改时观察变化的方向。
- 类层级不是能力的度量。 继承层级一旦超过两层,理解成本就会急剧上升;组合函数、参数注入、显式包装几乎总是更好的选择(见模块与组合)。
一句判断标准
模式是给「已经发生的变化」准备的,不是给「可能发生的变化」准备的。 如果一段代码今天只有一个实现、只有一个调用点、只在一处变化,那就让它保持简单。
小结
- 三大分类(创建 / 结构 / 行为)只是索引,用来找「同类问题还有哪些解法」。
- 前端语境下,ES 模块、一等函数、闭包、Promise、生成器、组件与响应式系统内建了很多经典模式;能内建就不要自建。
- 模式的价值在于命名与约束,代价是间接层;判断标准是「现在就存在第二种实现吗」。
- 每页按「问题 → 结构 → 代码 → 代价 → 什么时候不该用」展开,示例都能在页面上直接跑。