19 · 前端设计模式
本篇解决什么问题: 设计模式不是"背 23 种名字",而是一套解决 recurring 问题的通用词汇。 本篇只讲前端真正用得上的 8 个模式,每个都配真实场景与框架中的现成映射—— 你会发现你早就用过它们,只是没叫出名字。
📌 与 A18 的分工:A18 是代码级手写技巧,本篇是结构级组织方式。
开篇速览
| 模式 | 一句话 | 前端真实场景 |
|---|---|---|
| 单例 | 全局只有一个实例 | 全局 Toast、请求实例、缓存池 |
| 工厂 | 把创建逻辑收拢 | 按类型创建组件/表单控件 |
| 代理 | 包一层做增强 | ✅ Vue3 响应式就是 Proxy |
| 装饰器 | 不改原对象加能力 | 高阶组件 HOC、TS 装饰器 |
| 适配器 | 抹平接口差异 | 把后端脏数据适配成前端模型 |
| 观察者 / 发布订阅 | 一方变,多方知 | ✅ 响应式系统的核心、EventBus |
| 策略 | 一组可替换的算法 | 表单校验规则、支付方式、排序 |
| 责任链 / 中间件 | 链式处理,可插拔 | ✅ Koa/Redux/Axios 拦截器 |
一、创建型
1.1 单例模式(Singleton)
意图:保证一个类只有一个实例,并提供全局访问点。
// 场景:全局唯一的 Toast 管理器
class ToastManager {
private static instance: ToastManager
private queue: string[] = []
private constructor() {} // ✅ 私有构造,禁止 new
static getInstance() {
return (ToastManager.instance ??= new ToastManager())
}
show(msg: string) { this.queue.push(msg) }
}
export const toast = ToastManager.getInstance()💡 在 JS 里更简单:ES Module 本身就是单例(模块只求值一次), 直接
export const toast = new ToastManager()即可,不需要写getInstance。
⚠️ 单例是"有状态的全局":测试时难以重置,容易互相污染。 现代做法更推荐依赖注入或用一个可重置的容器。
1.2 工厂模式(Factory)
意图:把"创建什么"的决策收拢到一处,调用方不关心具体实现。
// 场景:按后端返回的 type 渲染不同表单控件
const controlMap = {
text: TextInput,
select: SelectInput,
date: DatePicker,
} as const
function createControl(type: keyof typeof controlMap, props: any) {
const Comp = controlMap[type]
if (!Comp) throw new Error(`未知控件类型: ${type}`)
return <Comp {...props} />
}收益:新增控件只改 map,不动调用处(符合开闭原则)。 对比:用 if/else 或 switch 也能做,但新增类型要改函数本身。
二、结构型
2.1 代理模式(Proxy)⭐
意图:给对象包一层,控制对它的访问(增强、懒加载、校验)。
// 场景:给所有 API 调用加日志与缓存
const apiProxy = new Proxy(api, {
get(target, prop) {
const original = target[prop]
if (typeof original !== 'function') return original
return async (...args: any[]) => {
console.time(String(prop))
try { return await original.apply(target, args) }
finally { console.timeEnd(String(prop)) }
}
},
})🎯 最重要的映射:Vue3 的响应式就是 Proxy 模式——
get里收集依赖,set里触发更新(详见 A20 · 框架底层原理)。
2.2 装饰器模式(Decorator)
意图:不改原对象,动态地给它加职责。
// 场景:高阶组件(HOC)—— React 里的装饰器
function withLoading<P extends object>(Comp: ComponentType<P>) {
return function Wrapped(props: P & { loading?: boolean }) {
return props.loading ? <Skeleton/> : <Comp {...props} />
}
}
export default withLoading(UserList)
// 场景:给方法加埋点(TS 装饰器)
function track(name: string) {
return (target: any, key: string, descriptor: PropertyDescriptor) => {
const original = descriptor.value
descriptor.value = function (...args: any[]) {
report(name, args)
return original.apply(this, args)
}
}
}对比代理:装饰器叠加能力(一层套一层),代理控制访问——实际很像,区别在意念。
2.3 适配器模式(Adapter)
意图:把一个接口转换成另一个接口,让不兼容的东西能一起工作。
// 场景:后端返回的 snake_case 适配成前端的 camelCase
interface User { userId: number; userName: string; createdAt: string }
function adaptUser(raw: any): User {
return {
userId: raw.user_id,
userName: raw.user_name,
createdAt: new Date(raw.created_at).toISOString(),
}
}🎯 这是前端最常用也最被低估的模式: 在 API 层做一次适配,后面的组件就再也不用处理脏数据; 后端改字段名时只改适配器一处。
三、行为型
3.1 观察者 / 发布订阅 ⭐
意图:一个对象变化时,所有依赖它的对象自动收到通知。
// 发布订阅(带事件总线,比观察者多一层"中间人")
const bus = {
map: new Map<string, Set<Function>>(),
on(e, fn) { /* 订阅 */ },
emit(e, data) { /* 通知 */ },
}| 观察者 | 发布订阅 | |
|---|---|---|
| 关系 | 直接(Subject 持有 Observer 列表) | 间接(通过 EventBus) |
| 耦合 | 较强 | 松耦合 |
🎯 框架映射:
- Vue 响应式 = 观察者(
dep就是 Subject,effect就是 Observer)- MobX / Signals = 观察者
- EventBus / mitt = 发布订阅
- Redux 的
subscribe= 观察者
实现细节见 A18 · 手写题与代码设计。
3.2 策略模式(Strategy)⭐
意图:定义一组可互换的算法,运行时选择。
// ❌ 不用策略:if/else 越堆越长
function validate(type, value) {
if (type === 'required') return !!value
if (type === 'email') return /^[\w.]+@\w+$/.test(value)
if (type === 'phone') return /^1\d{10}$/.test(value)
// 每加一种就要改这里
}
// ✅ 策略:规则表化
const validators = {
required: (v: string) => !!v || '必填',
email: (v: string) => /^[\w.]+@\w+$/.test(v) || '邮箱格式错误',
phone: (v: string) => /^1\d{10}$/.test(v) || '手机号格式错误',
} satisfies Record<string, (v: string) => true | string>
function validate(rules: (keyof typeof validators)[], value: string) {
return rules.map(r => validators[r](value)).find(r => r !== true) ?? true
}收益:新增规则只加一个键值对,不改 validate 函数。 典型场景:支付方式、排序、导出格式、权限判定、折扣计算。
💡 JS 里的对象字面量天然就是策略表——很多时候"策略模式"就是一个 map。
3.3 责任链 / 中间件(Chain of Responsibility)⭐
意图:多个处理器串成链,每个决定"处理还是交给下一个"。
// 场景:Koa / Redux / Axios 拦截器的洋葱模型
type Middleware = (ctx: any, next: () => Promise<void>) => Promise<void>
const logger: Middleware = async (ctx, next) => {
console.log('→', ctx.url); await next(); console.log('←', ctx.status)
}
const auth: Middleware = async (ctx, next) => {
if (!ctx.token) throw new Error('401'); await next()
}请求 → logger 前半 → auth 前半 → 业务 → auth 后半 → logger 后半 → 响应收益:能力可插拔、可排序、可测试,每个中间件职责单一。
实现见 A18 的 composeMiddleware。
四、模式在框架中的现成映射(面试加分)
| 你用的东西 | 背后的模式 |
|---|---|
Vue3 reactive / ref | 代理 + 观察者 |
Vue computed | 观察者(依赖追踪) |
| React Hooks | 策略(可替换的逻辑)+ 装饰器(useXxx 增强组件) |
| Redux | 观察者(subscribe)+ 责任链(middleware) |
Redux combineReducers | 组合模式 |
React Context.Provider | 依赖注入 |
HOC(withRouter) | 装饰器 |
| React Router | 组合 + 策略(匹配算法) |
| Axios 拦截器 | 责任链 |
| TanStack Query 的 cache | 代理 + 观察者 |
<slot> / children | 模板方法(把"变的部分"交给外部) |
🎯 面试时的正确说法:不要说"我用了单例模式", 而要说"这里用策略表替代了 if/else,新增规则不用改主流程"——讲解决的问题,不讲模式名字。
五、反模式提醒(别为了用而用)
| 反模式 | 说明 |
|---|---|
| 为了用模式而用 | 三个分支的 if/else 比硬套策略模式更清晰 |
| 过度抽象 | "以后可能会扩展" → 现在就写工厂+策略+适配器,结果永远只有一种实现 |
| 单例滥用 | 全局有状态 = 测试地狱 |
| 深层继承 | JS 里组合优于继承(Hooks 就是证据) |
| 过度设计 | 先写能跑的代码,第二次重复时再抽象(Rule of Three) |
📌 最重要的原则:模式是"发现"出来的,不是"套"上去的。 当你第二次遇到同一个问题时,再去套模式——这时才会有收益。
自检清单
- [ ] 能说出 8 个前端常用模式各自解决什么问题
- [ ] 知道 Vue3 响应式 = 代理 + 观察者,Redux middleware = 责任链
- [ ] 能用策略表替代 if/else,并说明收益
- [ ] 能说出"在 API 层做适配器"的价值
- [ ] 知道什么时候不该用模式(Rule of Three)
常见误区
| ❌ 误区 | ✅ 事实 |
|---|---|
| "设计模式是面试背的" | 它们是你已经在用的东西的名字 |
"单例必须写 getInstance" | ES Module 天然单例,直接 export 实例即可 |
| "策略和 if/else 一样" | 差别在扩展性:新增时改 map 还是改函数 |
| "架构要先把模式设计好" | 过度设计;重复三次再抽象 |
| "继承比组合好" | JS 里组合优于继承(Hooks 的胜利) |
动手练习
- 把项目里一段 5 个分支的
if/else校验重构成策略表,并新增一条规则验证"不用改主流程"。 - 用 Proxy 给项目的 API 层加统一耗时日志,不改动原来的调用代码。
- 找出项目里 3 处"后端字段直接用到组件里"的地方,加一层适配器,体会后端改字段时的影响面变化。
相关文档
- 手写实现(EventBus / compose / LRU)→ A18 · 手写题与代码设计
- 响应式与代理的底层 → A20 · 框架底层原理
- 状态管理中的观察者 → A07 · 状态管理演进与选型
- 模块边界与依赖方向 → A17 · 前端架构分层与模块边界