Skip to content

19 · 前端设计模式 ​

本篇解决什么问题: 设计模式不是"背 23 种名字",而是一套解决 recurring 问题的通用词汇。 本篇只讲前端真正用得上的 8 个模式,每个都配真实场景与框架中的现成映射—— 你会发现你早就用过它们,只是没叫出名字。

📌 与 A18 的分工:A18 是代码级手写技巧,本篇是结构级组织方式。


开篇速览 ​

模式一句话前端真实场景
单例全局只有一个实例全局 Toast、请求实例、缓存池
工厂把创建逻辑收拢按类型创建组件/表单控件
代理包一层做增强✅ Vue3 响应式就是 Proxy
装饰器不改原对象加能力高阶组件 HOC、TS 装饰器
适配器抹平接口差异把后端脏数据适配成前端模型
观察者 / 发布订阅一方变,多方知✅ 响应式系统的核心、EventBus
策略一组可替换的算法表单校验规则、支付方式、排序
责任链 / 中间件链式处理,可插拔✅ Koa/Redux/Axios 拦截器

一、创建型 ​

1.1 单例模式(Singleton) ​

意图:保证一个类只有一个实例,并提供全局访问点。

ts
// 场景:全局唯一的 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) ​

意图:把"创建什么"的决策收拢到一处,调用方不关心具体实现。

ts
// 场景:按后端返回的 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)⭐ ​

意图:给对象包一层,控制对它的访问(增强、懒加载、校验)。

ts
// 场景:给所有 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) ​

意图:不改原对象,动态地给它加职责。

ts
// 场景:高阶组件(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) ​

意图:把一个接口转换成另一个接口,让不兼容的东西能一起工作。

ts
// 场景:后端返回的 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 观察者 / 发布订阅 ⭐ ​

意图:一个对象变化时,所有依赖它的对象自动收到通知。

ts
// 发布订阅(带事件总线,比观察者多一层"中间人")
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)⭐ ​

意图:定义一组可互换的算法,运行时选择。

ts
// ❌ 不用策略: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)⭐ ​

意图:多个处理器串成链,每个决定"处理还是交给下一个"。

ts
// 场景: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 的胜利)

动手练习 ​

  1. 把项目里一段 5 个分支的 if/else 校验重构成策略表,并新增一条规则验证"不用改主流程"。
  2. 用 Proxy 给项目的 API 层加统一耗时日志,不改动原来的调用代码。
  3. 找出项目里 3 处"后端字段直接用到组件里"的地方,加一层适配器,体会后端改字段时的影响面变化。

相关文档 ​