Skip to content

前端开发中的设计模式记录

这里把设计模式当作「被反复验证过的代码组织方式」来记。在前端里,有些模式已经被框架吸收;有些仍然会在业务代码、基础设施封装或性能治理里反复出现。

这篇笔记不按 GoF 的 23 种模式完整平铺,只记录实际写业务、读框架源码和排查问题时反复碰到的部分。

Module Pattern → ES Module:从 IIFE 到 Tree Shaking

早期没有模块系统的时候,全靠 IIFE 造私有作用域:

javascript
const UserModule = (function () {
  const _users = []
  function _validate(user) { return user.name && user.email }
  return {
    add(user) {
      if (_validate(user)) { _users.push(user); return true }
      return false
    },
    getAll() { return [..._users] }
  }
})()

这层闭包带来的私有性是早期 JavaScript 里少数可用的封装手段。但 IIFE 的局限也很明显:没法做静态分析,也不利于 Tree Shaking,打包工具无法准确判断哪些代码被引用。

ES Module 解决了这个:export 是显式的契约,构建工具可以通过 import 的静态语法树精确判断哪些 export 没被引用。Vite 在 dev 模式下甚至不需要 bundle,浏览器原生 ES Module 直接跑。

放到今天看,模块模式更多是一段理解演进路径时会反复遇到的背景知识,真正的实现基本已经交给框架和构建工具。

Singleton:一个例子 + 一个坑

单例的写法本身比较直接:

javascript
class StoreManager {
  static instance = null
  constructor() {
    if (StoreManager.instance) return StoreManager.instance
    this.state = {}
    StoreManager.instance = this
  }
}

Vuex/Pinia/Redux 的 Store 都带有明显的单例特征。

更需要记录的是 SSR 场景下的状态污染。Node.js 服务端一个进程处理多个请求,如果把 Store 挂成模块级别的单例,请求 A 的状态就可能泄漏给请求 B。这类问题在线上并不少见,例如电商场景里用户 A 看到用户 B 的购物车。

修复思路是用 WeakMap 做请求级隔离:

javascript
const stores = new WeakMap()
function getStore(request) {
  if (!stores.has(request)) stores.set(request, new StoreManager())
  return stores.get(request)
}

这里用 WeakMap 而不是 Map,主要是为了让 request 对象被 GC 回收时,对应的 store 也能自动释放,避免额外的手动清理逻辑。

另外,微前端(qiankun、Module Federation)场景下,每个子应用可能各自维护一个单例,全局单例的假设就破了。

Observer vs Pub/Sub:容易混在一起的两个模式

这两个模式很容易被混在一起,但耦合关系并不一样。

Observer:Subject 直接持有 Observer 引用,Subject 知道 Observer 的存在。

javascript
class Subject {
  constructor() { this.observers = [] }
  subscribe(o) { this.observers.push(o) }
  unsubscribe(o) { this.observers = this.observers.filter(x => x !== o) }
  notify(data) { this.observers.forEach(o => o.update(data)) }
}

Pub/Sub:多一个 EventBus 中间层,Publisher 和 Subscriber 完全不知道对方存在。

javascript
class EventBus {
  constructor() { this.events = {} }
  on(event, handler) { (this.events[event] ||= []).push(handler) }
  off(event, handler) {
    this.events[event] = this.events[event]?.filter(h => h !== handler)
  }
  emit(event, data) { this.events[event]?.forEach(h => h(data)) }
}

更值得关注的是两者在实际系统里的影响:

Observer 的耦合度更高,但调试相对直接,调用栈通常是清晰的。Pub/Sub 的解耦更彻底,但排查问题时链路会变长:一次 emit 到底触发了哪些 handler,执行顺序是什么,某个 handler 抛错会不会阻断后续处理,都需要额外确认。

真实例子:一个中后台项目用 EventBus 做跨组件通信,上线后发现「保存」按钮有时点一次触发了两次保存请求。排查了两个小时 原因是有个组件在 onMountedon('save', handler),但 onUnmounted 里忘了 off,导致同一个 handler 被注册了多次。

几个相对稳妥的处理方式:

  • AbortController 统一管理订阅生命周期
  • React 里 useEffect 的清理函数自动 unsubscribe
  • Vue 3 的 watch/watchEffect 在组件卸载时自动停止 这也是我倾向于用框架内置响应式而非手动搭 EventBus 的原因
  • 如果必须用 EventBus,加一个 dev 模式下的日志:每次 emit 时打印当前注册的 handler 数量和名称

Observer 在前端里的覆盖面很高:

DOM 事件模型就是 Observer。Vue 的响应式本质上也是 Observer track 收集依赖(谁在用这个数据),trigger 通知更新(数据变了,你们重新跑)。只不过 Subject 被替换成了响应式数据,Observer 被替换成了 effect 函数。RxJS 的 Observable 是 Observer 的推式(push-based)扩展,加了操作符链。Redux 的 store.subscribe 也是。

Factory:被低估的模式

Factory 在前端里很常见,常见到很多场景已经不太会单独把它当作“模式”来看。

React.createElement(type, props, children) 就是一个工厂函数 根据 type 的不同(字符串、函数组件、class 组件)创建不同类型的 VNode。Axios 的 axios.create(config) 也是工厂:创建一个携带默认配置的请求实例。

Vue 3 的 createAppcreateRoutercreateStore 全是工厂函数。为什么不用 new?因为工厂可以返回不同类型的对象,而 new 永远返回同一个类的实例。另外工厂可以在返回前做一些初始化工作,对调用方透明。

业务里一个很常见的工厂场景,是根据后端返回的字段类型动态渲染表单组件。

jsx
function FormFieldFactory({ type, ...props }) {
  const map = { text: <Input {...props} />, select: <Select {...props} />, date: <DatePicker {...props} /> }
  return map[type] || null
}

这个写法虽然简单,但有几个边界需要记一下:

  1. key 的稳定性:如果 type 切换了,React 会销毁旧组件创建新组件,内部状态会丢失
  2. Props 透传的边界:不要无脑 {...props},有些 prop 只对特定 type 有效,透传会导致 React 的 unknown prop warning
  3. 扩展性 vs Tree Shakingmap 对象一旦写死,所有组件都会被引入。如果按需加载,需要配合 React.lazy 或动态 import

Decorator 在前端的三种形态

HOC(React 早中期)

jsx
function withAuth(Component) {
  return function AuthComponent(props) {
    const { user } = useAuth()
    if (!user) return <LoginPage />
    return <Component {...props} user={user} />
  }
}

HOC 的问题是「嵌套地狱」 多个 HOC 包裹后会变成 withA(withB(withC(Component))),DevTools 里看组件树是一层又一层的匿名组件。而且 HOC 的 props 命名冲突也不好处理。

Render Props(过渡形态)

Render Props 解决了部分嵌套问题,但也引入了回调嵌套。后来的主流实践基本转向了 Custom Hooks,可以把它理解成 React 里更自然的 Decorator 变体。

Composables(Vue 3)

javascript
function usePagination(fetchFn) {
  const page = ref(1)
  const data = ref([])
  const loading = ref(false)
  async function loadPage(p) {
    loading.value = true
    data.value = await fetchFn(p)
    loading.value = false
  }
  return { page, data, loading, loadPage }
}

Vue 的 Composables 本质上和 React Hooks 一样,都是「函数组合代替嵌套装饰」。区别在于 Vue 的响应式系统让 Composables 不需要处理闭包陷阱(stale closure),这是 Proxy 响应式相比 Immutable + rerender 的一个优势。

演进路径:React HOC → Render Props → Custom Hooks;Vue Mixins → Composables。这两条路径的共同点都是从「嵌套结构」转向「函数组合」。Mixins 的消亡是有原因的:命名冲突、来源不透明、this 上的属性不知道是 data 还是 mixin 注入的。Composables 的显式 import + 解构解决了所有这些问题。

Strategy:消除 if/else 的武器

策略模式在前端最实用的落地场景是表单验证:

javascript
const validators = {
  required: v => !!v || '必填',
  email: v => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v) || '邮箱格式错误',
  minLength: len => v => v.length >= len || `最少${len}个字符`
}

function validate(value, rules) {
  for (const rule of rules) {
    const [name, ...args] = Array.isArray(rule) ? rule : [rule]
    const result = validators[name](...args)(value)
    if (result !== true) return result
  }
  return true
}

这个实现的好处是添加新规则时不需要改 validate 函数,符合开闭原则。但在实际项目里经常还要支持异步验证(如“用户名是否已存在”),这时候策略函数的签名通常要统一为 (value) => Promise<string | true>

策略模式不只是表单验证。React 的 Lane 优先级调度、Vue 的异步更新队列调度都是策略的体现 不同的更新任务采用不同的调度策略。支付场景(微信/支付宝/银行卡各自是不同策略)也是经典应用。

Proxy:JavaScript 的原生代理

ES6 的 Proxy 让代理模式从设计模式变成了语言特性。Vue 3 的响应式就是基于这个:

javascript
function reactive(target) {
  return new Proxy(target, {
    get(obj, key, receiver) {
      track(obj, key)    // 依赖收集
      const result = Reflect.get(obj, key, receiver)
      if (typeof result === 'object' && result !== null) return reactive(result)
      return result
    },
    set(obj, key, value, receiver) {
      const oldValue = obj[key]
      const result = Reflect.set(obj, key, value, receiver)
      if (oldValue !== value) trigger(obj, key)  // 派发更新
      return result
    }
  })
}

Vue 3 @vue/reactivity 的实际实现拆解

上面那个 reactive() 是概念验证。Vue 3 的 @vue/reactivity 包(源码在 packages/reactivity/src/)把这套机制拆成了几个核心模块:

ReactiveEffect 依赖收集的执行单元

typescript
// 简化自 @vue/reactivity/src/effect.ts
class ReactiveEffect<T = any> {
  active = true
  deps: Dep[] = []          // 这个 effect 订阅了哪些 dep
  parent: ReactiveEffect | undefined = undefined
  onStop?: () => void

  constructor(public fn: () => T, public scheduler?: () => void) {}

  run() {
    if (!this.active) return this.fn()
    // 关键:压入 effectStack,设置 activeEffect
    this.parent = activeEffect
    activeEffect = this
    // cleanup 上一次的依赖关系
    cleanupEffect(this)
    const result = this.fn()   // 执行期间,track 会被调用
    // 恢复
    activeEffect = this.parent
    this.parent = undefined
    return result
  }

  stop() {
    if (this.active) {
      cleanupEffect(this)
      this.onStop?.()
      this.active = false
    }
  }
}

这个类的设计有几个关键决策:

  1. effectStackactiveEffect 是全局变量,但 this.parent 保存了上一个 effect 的引用。当嵌套 effect 执行时(computed 内部依赖 computed),内部 effect 执行完毕后恢复外部 effect。这不是类继承,是栈式追踪。

  2. cleanupEffect:每次 run() 重新执行前,先把 this.deps 里的所有 dep 中的此 effect 移除。然后执行 fn()track 会重新建立依赖关系。不做 cleanup 的话,条件分支导致的依赖变化会让已经不依赖的数据仍然触发这个 effect 重新执行 浪费计算且可能引入 bug。

  3. scheduler:如果传了 scheduler,trigger 不会直接调用 effect.run(),而是调用 effect.scheduler()。Vue 的异步更新队列就是靠这个 scheduler 把 update 推入微任务队列,同一个 tick 内的多次数据变更被合并成一次组件更新。

track/trigger 依赖图的核心

typescript
// 简化自 @vue/reactivity/src/effect.ts
const targetMap = new WeakMap<any, KeyToDepMap>()
// targetMap: WeakMap<target, Map<key, Set<ReactiveEffect>>>

function track(target: object, key: string | symbol) {
  // shouldTrack 标志位:readonly、pauseTracking 场景跳过
  if (!shouldTrack || activeEffect === undefined) return

  let depsMap = targetMap.get(target)
  if (!depsMap) targetMap.set(target, (depsMap = new Map()))

  let dep = depsMap.get(key)
  if (!dep) depsMap.set(key, (dep = new Set()))  // Set<ReactiveEffect>

  if (!dep.has(activeEffect)) {
    dep.add(activeEffect)
    activeEffect.deps.push(dep)  // 双向引用
  }
}

function trigger(target: object, key: string | symbol) {
  const depsMap = targetMap.get(target)
  if (!depsMap) return
  const dep = depsMap.get(key)
  if (dep) {
    const effects = new Set(dep)  // 复制一份再遍历,防止 cleanup/add 导致无限循环
    effects.forEach(effect => {
      if (effect.scheduler) effect.scheduler()
      else effect.run()
    })
  }
}

这里的数据结构比较值得记:

  • targetMapWeakMap<target, Map<key, Set<effect>>>。外层 WeakMap 保证 target 被 GC 时不会因为依赖图而泄漏。内层 Map 以 key 为维度组织依赖,这样修改 obj.a 时不需要遍历整个 target 的所有 dep。
  • track 里建立的是双向引用:dep → effect(Set),effect → dep(Array)。单向只够收集,双向才能做 cleanup effect 知道自己订阅了哪些 dep,stop 时才能从中移除自己。
  • triggernew Set(dep) 复制了一份。这不是性能浪费。如果在遍历 dep 的过程中有 effect 执行导致新的 track/cleanup,直接操作原始 Set 会导致迭代器行为不可预测。

懒代理 vs 递归代理

Vue 3 的 reactive() 在 get 拦截里对嵌套对象做「用到才代理」,不是创建时就递归遍历整棵树:

typescript
// @vue/reactivity/src/reactive.ts
function createGetter(isReadonly = false) {
  return function get(target, key, receiver) {
    const res = Reflect.get(target, key, receiver)
    if (!isReadonly) track(target, key)
    if (isObject(res)) {
      // 懒代理:只有被访问到的嵌套对象才转为 reactive
      return isReadonly ? readonly(res) : reactive(res)
    }
    return res
  }
}

这比 Vue 2 的 Object.defineProperty 递归劫持所有属性在初始化阶段快得多 初始化 10,000 条数据的场景下,Vue 2 需要递归遍历所有属性,Vue 3 只在访问时按需转换。

Proxy 的其他落点

  • Immer:通过 Proxy 追踪 mutation,生成 immutable 快照。核心技巧是用 Proxy 记录「发生了什么修改」,然后在 produce 结束时将这些修改应用到原对象的副本上
  • new Proxy(fetch, { apply }):拦截全局 fetch 做请求去重、超时控制、mock 数据注入。注意 Proxy 只能代理对象,不能代理函数调用 这里代理的是 fetch 函数对象本身,apply 陷阱拦截的是函数调用
  • SolidJS 的 Signal:Proxy 驱动的细粒度响应式。跟 Vue 3 的差异在于 Solid 没有虚拟 DOM,Signal 变化直接更新对应的 DOM 节点,不经过 diff 阶段

Adapter:最后一道防线

适配器模式的核心价值不只是“统一接口”,更重要的是 把第三方变更拦在边界层

javascript
class MapAdapter {
  constructor(lib = 'amap') { this.lib = lib }
  async search(keyword) {
    const raw = this.lib === 'amap'
      ? await AMap.search({ keywords: keyword })
      : await BaiduMap.searchPoi(keyword)
    return raw.map(r => ({
      name: r.name || r.title,
      lng: r.location?.lng || r.point?.lng,
      lat: r.location?.lat || r.point?.lat
    }))
  }
}

SDK 封装、后端接口适配、多端 API 统一,基本都适合按这个思路处理。适配器相当于业务代码和外部依赖之间的隔离层。虽然多了一层代码,但第三方 SDK 升级改 API 时,修改范围通常能被限制在适配器内部。

另外,适配器也是测试的天然切入点 替换掉真正的 SDK 调用,注入 mock 数据,不需要 mock 整个第三方库。

State Machine:复杂交互里的稳定建模方式

useState 适合简单状态,useReducer 能覆盖更复杂的状态流,但有些交互再往上走就需要显式状态机。

javascript
class RequestState {
  constructor() {
    this.states = {
      idle:    { canStart: true },
      loading: { canCancel: true },
      success: { canRetry: true },
      error:   { canRetry: true }
    }
    this.current = 'idle'
  }
  transition(state, data) {
    if (!this.states[state]) throw new Error(`非法状态: ${state}`)
    this.current = state
    this.states[state].onEnter?.(data)
  }
}

这个实现的问题是状态转换规则是隐式的 transition 不检查当前状态是否允许转到目标状态。比如从 loading 直接跳到 success 是合理的,但从 idle 直接跳到 success 就不对。完整的做法是用 FSM(有限状态机),明确每个状态允许的转换路径。

XState 是这一类方案里比较完整的一种,但引入成本也高,团队需要接受状态图(statechart)这套建模方式。更轻一些的做法是用 useReducer 配合 TypeScript 的 discriminated union,先把类型层面的状态安全建起来:

typescript
type State =
  | { status: 'idle' }
  | { status: 'loading'; cancel: () => void }
  | { status: 'success'; data: Response }
  | { status: 'error'; error: Error }

function reducer(state: State, action: Action): State {
  switch (state.status) {
    case 'idle':
      if (action.type === 'FETCH') return { status: 'loading', cancel: action.cancel }
      break
    case 'loading':
      if (action.type === 'SUCCESS') return { status: 'success', data: action.data }
      if (action.type === 'ERROR') return { status: 'error', error: action.error }
      break
    // ...
  }
  return state
}

这样非法状态转换在类型层面就会被拦截。

Command + History:撤销/重做的标准解法

javascript
class CommandHistory {
  constructor() { this.history = []; this.index = -1 }

  execute(command) {
    command.execute()
    // 删除当前指针之后的 redo 历史
    this.history = this.history.slice(0, this.index + 1)
    this.history.push(command)
    this.index++
  }

  undo() {
    if (this.index < 0) return
    this.history[this.index].undo()
    this.index--
  }

  redo() {
    if (this.index >= this.history.length - 1) return
    this.index++
    this.history[this.index].execute()
  }
}

这里 slice(0, this.index + 1) 的逻辑是:如果在 undo 之后执行了新操作,之前那些被 undo 掉的操作就作废了,不能 redo 回来。这是大多数编辑器的标准行为。

实际项目中 Command 模式最密集的地方是富文本编辑器。Slate.js 的核心就是把每一次用户操作都编码为一个 Operation 对象(insert_textremove_nodeset_selection 等),所有的编辑行为都可以还原为 Operation 序列。这让协同编辑(OT/CRDT)的实现有了基础 协同的本质就是「同步 Operation 序列」。

另外,Command 模式天然适合做操作日志。每个 Command 都携带了足够的上下文,可以做审计、回放、自动化测试的录制。

Chain of Responsibility:Koa 洋葱模型

责任链在前端最经典的实现是中间件模式。Redux middleware、Vue Router 导航守卫、Axios 拦截器都是责任链。

Koa 的 compose 实现核心思路:

javascript
function compose(middlewares) {
  return function (ctx) {
    let index = -1
    function dispatch(i) {
      if (i <= index) return Promise.reject(new Error('next() called multiple times'))
      index = i
      const fn = middlewares[i]
      if (!fn) return Promise.resolve()
      try {
        return Promise.resolve(fn(ctx, () => dispatch(i + 1)))
      } catch (err) {
        return Promise.reject(err)
      }
    }
    return dispatch(0)
  }
}
  1. i <= index 检查:防止在同一个中间件里调用多次 next(),这是 Koa 的安全机制
  2. Promise.resolve 包裹:保证无论中间件返回什么(同步值、Promise、抛错),行为都是可预测的
  3. 洋葱模型dispatch(i) 返回的是一个 Promise,所以上游中间件 await next() 之后可以拿到下游的执行结果,实现「请求前 → next() → 响应后」的洋葱圈

Redux applyMiddleware 的同步责任链

Redux 中间件的核心在 applyMiddleware,源码在 redux/src/applyMiddleware.ts

javascript
// 简化自 redux/src/applyMiddleware.ts
function applyMiddleware(...middlewares) {
  return (createStore) => (reducer, preloadedState) => {
    const store = createStore(reducer, preloadedState)
    let dispatch = store.dispatch

    const middlewareAPI = {
      getState: store.getState,
      dispatch: (action) => dispatch(action)  // 闭包引用,后续被重写
    }

    // 第一步:所有 middleware 都拿到 middlewareAPI
    const chain = middlewares.map(m => m(middlewareAPI))

    // 第二步:compose 把 chain 串起来
    // compose(a, b, c) → (...args) => a(b(c(...args)))
    dispatch = compose(...chain)(store.dispatch)

    return { ...store, dispatch }
  }
}

这里 middlewareAPI.dispatch 用闭包引用了外层的 dispatch 变量,这是一个延迟绑定技巧 在所有 middleware 装配完成后,dispatch 被重写为增强版,此时 middlewareAPI.dispatch 自动指向增强后的版本。这样 middleware 内部可以通过 dispatch 重新发起经过完整链的 dispatch。

一个 Redux middleware 的结构:

javascript
// middleware = store => next => action => {}
const logger = (store) => (next) => (action) => {
  console.log('before', action)
  next(action)
  console.log('after', store.getState())
}

Redux 和 Koa 的关键差异

维度Koa composeRedux applyMiddleware
执行方向洋葱模型(in → next → out)管道模型(单向 A → B → C)
异步支持Promise 驱动,天然异步同步链。异步 action 需要 redux-thunk 或 redux-saga 在某个环节拦截
next 叫多次检测并抛错不检测,可以多次调用
返回值Promise<Response>无返回值(dispatch 返回 action 本身)

Redux 的管道模型意味着一个 middleware 不能「等待」下游 middleware 的执行结果然后再处理。thunk 和 saga 通过拦截特定类型的 action(函数和 generator)绕过了这个限制 本质上是在管道里插入了异步执行节点。

依赖注入:Angular 做对了什么

前端领域对 DI 的使用密度整体不算高,Angular 则是少数把 DI 作为一等公民来设计的框架。

typescript
class Container {
  private services = new Map()
  register<T>(key: string, factory: () => T) { this.services.set(key, factory) }
  resolve<T>(key: string): T {
    const factory = this.services.get(key)
    if (!factory) throw new Error(`Service ${key} not registered`)
    return factory()
  }
}

这个简化版缺了几个关键能力:

  • 作用域:Singleton vs Transient vs Request 级别的生命周期管理
  • 循环依赖检测:A 依赖 B,B 依赖 A,需要提前发现而不是运行时栈溢出
  • 懒加载:不是在注册时就创建,而是第一次 resolve 时才创建

在实际项目里,即使不用 Angular,DI 的思路依然很有价值。API 客户端、Logger、Analytics 这类基础设施作为依赖注入到业务模块里,测试时就比较容易替换成 mock 实现。Vue 3 的 provide/inject 和 React Context,本质上都可以看作组件树级别的 DI。

这里还有一个容易忽略的点:provide/inject 的默认行为是非响应式的。如果 inject 的是一个 ref,模板里仍然需要 .value,这和 data/computed 的使用体验并不一致。

MVC → MVVM → Flux:前端架构的演进

为什么 MVC 在前端失败

MVC 的原始定义中,Controller 处理用户输入,操作 Model,然后 Model 通知 View 更新。问题在于前端 View 和 Model 的关系是双向的:用户操作 View 改变 Model,Model 改变又需要更新 View。Controller 很快变成了一个臃肿的中转站,负责同步 View 和 Model 之间的状态。

MVVM 为什么在前端成功

Vue 是一个典型的 MVVM 实现:

  • Model:响应式数据(data()/ref()
  • View:模板
  • ViewModel:computed + methods + watch

这里最关键的是 数据绑定自动化。开发者不再需要手动写 document.getElementByIdaddEventListener,框架通过响应式系统自动完成 View ↔ ViewModel 的同步,这也是前端开发范式变化的一个关键节点。

Flux 的单向数据流

Redux 带来了一个新思路:放弃双向绑定,强制单向数据流

text
Action → Dispatcher → Store → View

好处是可预测性 任何时刻的 UI 状态都可以由「初始状态 + Action 序列」完整还原。Redux DevTools 的时间旅行调试就是基于这个原理。代价是模版代码量大增,每个状态变更都要走 Action → Reducer → Store → 重渲染的完整链路。

现在的趋势

  • Atomic state(Jotai/Recoil):原子化状态,按需订阅,不需要全局 Store
  • Signals(SolidJS/Preact Signals):细粒度响应式,不经过虚拟 DOM
  • 服务端/客户端状态分离(React Query/SWR):接口数据不需要进全局 Store,自带缓存和失效策略

这里更像是在提醒自己:状态管理不适合追求“大一统”,不同性质的状态往往需要不同的管理方式

模式组合:真实系统中的协同

单个模式的价值通常有限,实际系统里更常见的是多个模式同时协作。

案例:富文本编辑器

一个生产级富文本编辑器(如 Slate.js、Quill)同时运行着至少 6 种模式:

模式在编辑器中的角色
Command每个编辑操作编码为 Operation 对象,支持 undo/redo
Observer文档数据变更 → 视图更新、光标更新、协作通知
State编辑态 / 只读态 / 预览态的状态转换
Strategy不同节点类型(段落、标题、列表、表格)的渲染和序列化策略
Composite文档是嵌套的节点树,段落包含文本,列表包含列表项
Decorator插件系统通过装饰器扩展编辑器行为(如 @mention、#hashtag)

这些模式不是平铺的 它们有层次关系:

text
         [Strategy]──不同节点类型的序列化

[Composite] ← → [Observer]──文档变更通知
     ↑               ↑
[Command] ← → [State] ← → [Decorator/Plugin]

[Chain of Responsibility]──事件处理管线(键盘输入 → 快捷键 → 文本插入 → 序列化)

Command 和 Observer 的协作是核心。用户在键盘上的输入被编码为 insert_text Operation(Command),执行后修改文档数据,文档数据的变更通过 Observer 通知视图更新、协作层同步、历史栈记录。

State 和 Decorator 的交叠:编辑器状态变化(进入只读模式)时,装饰器插件需要响应 比如只读模式下禁用 @mention 的弹出框。这里需要 State 模式的「状态转换通知」机制来驱动 Decorator 的行为开关。

案例:低代码搭建引擎

低代码平台的搭建引擎是另一个模式密集区:

  • Factory:根据组件类型的 JSON Schema 创建不同类型的编辑器(文本编辑器、图片选择器、表格配置器)
  • Proxy:组件属性面板修改 props 时,通过 Proxy 拦截实现「实时预览」 属性一变,预览区的组件立即响应
  • Command:搭建操作(添加、删除、移动、修改)全部封装为 Command,支持 undo/redo
  • Chain of Responsibility:组件生命周期钩子(beforeAdd、afterDelete、onMove)形成责任链,插件可以拦截和修改行为
  • Adapter:不同端(PC、H5、小程序)的渲染器做适配,同一份页面 Schema 输出不同平台的代码

组合不是堆砌

多个模式协同的代价是执行流会变得不透明。Command 触发 Observer,Observer 触发 State 转换,State 转换又触发 Decorator 行为变化,这条链一长,理解和排查成本都会上升。

架构层面的对策

  1. 单向依赖:上层模式可以依赖下层,反向不行。Observer 不应该依赖 Command 的存在
  2. 事件溯源:所有状态变更必须来源于 Command。即使 Observer 可以直接修改数据,也必须走 Command 通道 保证 undo/redo 的完整性
  3. 生命周期钩子:在模式交叉点暴露生命周期事件(如 Command 执行前/后、State 转换前/后),让其他模式通过这些钩子介入,而不是直接调用

Composite & Flyweight:渲染层的模式

组合模式在前端里几乎无处不在,例如 DOM 树、React 组件树、Vue 的 Slot、AST。它已经融入框架底层,不太需要手动实现,但理解它有助于理解框架行为。

享元模式在渲染层的价值更大:

  • 虚拟列表:10 万条数据只渲染 20 个 DOM 节点,核心就是享元
  • 对象池:高频创建/销毁的对象(WebGL 粒子系统、Canvas 渲染),复用比 GC 快
  • CSS 类复用.text-red-500style="color: red" 内存占用小 浏览器可以缓存 CSS 规则,但 inline style 每个元素独立存储

虚拟列表的享元原理值得单独记一下。列表项的数据是“内部状态”,DOM 节点是“外部状态”。通过计算 scrollTopitemHeight 决定当前应该渲染哪些数据项,再把结果挂到复用的 DOM 节点上。

线上事故:当模式被用错时

事故一:EventBus 内存泄漏

症状:客服系统中后台运行超过 30 分钟后,页面操作明显卡顿,Chrome Task Manager 显示内存占用从初始的 80MB 涨至 400MB+,继续使用则 Tab 崩溃。

排查过程:用 Chrome DevTools Memory Profiler 做了 heap snapshot 对比(运行 5 分钟 vs 30 分钟),发现 Detached HTMLButtonElement 数量异常 这些 DOM 节点已经不在 DOM 树中,但因为被 JavaScript 引用而无法 GC。反向追踪引用链:Detached Button → EventBus.events['save'] → handler → closure → component instance → button ref

根因:列表页每行有一个「保存」按钮,点击时通过 EventBus 通知保存。但列表更新时,旧行的 DOM 被移除,off 却没有被调用 EventBus 的 events['save'] 数组里堆积了大量指向已卸载组件闭包的 handler。

修复

  1. 立即:在组件 onUnmounted/useEffect 清理函数中调用 off
  2. 长期:废弃全局 EventBus,所有跨组件通信走 Vuex Store(通过 action 触发,由 watcher 响应),利用框架的自动清理机制

记录:任何手动实现的 Observer/PubSub 模式,都要在组件生命周期终点做清理。框架内置的响应式系统(watch/watchEffect)会自动处理这件事,所以很多时候优先用框架能力并不是教条,而是为了减少清理遗漏。

事故二:SSR 单例状态泄漏

症状:电商活动页(Nuxt.js + Pinia),A 用户在商品详情页看到了 B 用户的收货地址。

排查:Node.js 服务端日志显示,同一个 Worker 进程在处理请求 A 之后紧接着处理了请求 B,且 Pinia Store 是模块级单例 defineStore('cart', () => { ... }) 在模块加载时执行一次,之后的每个请求复用同一个 store 实例。请求 A 修改了 cart.address,请求 B 在 cart.address 被重置前就读到了 A 的数据。

修复:使用 piniaPluginSSR 配合请求上下文,每次请求创建独立的 store 实例,响应发送后销毁。核心机制就是前面 Singleton 章节提到的 WeakMap<Request, Store> 隔离。

指标:修复前,压测 1000 QPS 下有 0.3% 的请求出现状态串扰。修复后降至 0%。这个问题在生产环境存在了 2 周才被发现 大部分用户不会注意到地址栏多了别人的地址,直到有人打电话投诉。

事故三:Proxy 响应式导致的渲染风暴

症状:数据看板页面,一次 Websocket 推送更新了 50 个数据点,页面卡顿了 3 秒才恢复响应。

排查:Vue DevTools 的 Performance 面板显示,一次数据推送触发了 200+ 次组件重渲染。原因是数据的响应式转换是递归的 一个包含 50 个指标的嵌套对象,每个指标变化都会独立触发 trigger,每个 trigger 又触发对应组件的 effect.run()

根因:Vue 3 虽然用 Proxy 替代了 Object.defineProperty,但没有做自动的 batch 合并 每次同步 trigger 默认立即执行 effect。只有 Vue 的组件更新 effect 通过 scheduler 推入微任务队列做了合并,但手动创建的 watch/watchEffect。如果加了 flush: 'sync',每次变更都会同步执行。

修复

  1. 将相关的多个数据更新包裹在 nextTick 或一个同步块中,让 scheduler 自动合并
  2. 对高频更新的数据使用 shallowRef 代替 refreactive,避免深层响应式转换
  3. 对大数据列表使用 shallowReactive,只监听顶层引用替换,不监听内部属性变更

记录:Proxy 响应式的便利性有明确的性能边界。当数据量大到一定程度(1 万+ 属性,50+ QPS 更新),通常需要手动控制响应式深度和 batch 频率。

权衡与边界

这些模式在 JS 里的真实处境

模式前端现状原因
Singleton框架内置(Store)全局状态本身就是单例需求
Observer框架内置(响应式系统)数据驱动视图是现代前端的基石
Proxy成为语言特性new Proxy() 已经是 JS 的一部分
Factory融入框架 APIcreateAppcreateElement
Strategy散落在业务代码框架不强制,场景太分散
Chain of Resp.融入中间件体系Redux/Vue Router/Axios 拦截器
Command只在编辑器/协作场景手动实现大多数应用不需要 undo/redo
DIAngular 内置,其他框架通过 Context/Provide 模拟前端组件树本身就是天然的层级 DI
DecoratorReact Hooks / Vue Composables 替代了传统实现函数组合比嵌套包裹更符合 JS 习惯
Adapter永远需要手动写第三方 SDK 封装、多端适配
StateuseReducer / XState复杂交互的刚需
Flyweight虚拟列表长列表渲染的标配方案

这里的结论:GoF 的 23 种模式里,跟渲染层和数据流相关的(Observer、Proxy、Composite、Flyweight)已经大多被框架吸收;跟业务组织相关的(Strategy、Adapter、Command、State)仍然需要在具体场景里自行判断和实现。

Tradeoff:引入模式的代价

每个模式都有隐性成本,不是“用了就一定更好”:

模式收益代价过度使用的信号
Observer解耦数据生产和消费调用栈断裂,调试困难一个 emit 触发 20+ 个 handler,不知道完整的执行链
Commandundo/redo 能力每个操作都需要序列化上下文,内存随操作数线性增长为了可能永远不会用的 undo 而把所有操作都写成 Command
Strategy消除 if/else策略类数量膨胀,运行时通过名称查找策略不如 switch 直接两个策略的差异只有一行代码
EventBus跨组件通信事件名是字符串,没有类型检查,重构时容易遗漏项目中出现全局 bus.emit('reload') 而不知道谁会响应
DI方便测试替换依赖关系从调用栈变为容器配置,追踪链路变长为了「可能替换」而注入所有依赖
HOC逻辑复用嵌套地狱,props 冲突,DevTools 不可读一个组件被 3+ 个 HOC 包裹

目前更关注的点

  • 识别框架做了什么:Vue 的响应式 = Observer + Proxy + Scheduler,React 的 Fiber = 策略 + 责任链。理解框架内部吸收了哪些模式,有助于判断某个需求应该优先走框架能力,还是自己补一层模式
  • 优先使用语言能力:JS 的高阶函数、闭包、Proxy 已经覆盖了很多设计模式的意图。很多时候一个 Map<string, Function> 就足够,不必为了形式再补一层 Strategy 接口
  • 把构建时能力也纳入设计判断:Tree Shaking、Code Splitting、静态分析是前端特有的优化维度,传统设计模式很少覆盖这些问题。架构决策里,“这个模式对 Tree Shaking 是否友好”是个合理问题
  • 警惕过早抽象:两段相似代码不等于值得立刻抽象。只有在确认它们会因为相同原因变化时,合并才有意义

备忘问题

下面这些问题先记下来,后面回看框架实现或排查复杂问题时可以对照检查自己理解得够不够实。

  1. Vue 3 的响应式系统中,tracktrigger 各在哪个 Proxy 陷阱中调用?targetMap 为什么外层用 WeakMap?
  2. EventBus 和 Vue 的 provide/inject 都能跨组件通信,在什么情况下必须用 EventBus 而不能用 provide/inject?
  3. Koa 的洋葱模型和 Redux 的管道模型,如果让你把 Redux 的同步管道改成类似 Koa 的异步洋葱模型,会破坏什么?哪些场景会出问题?
  4. 一个富文本编辑器同时用了 Command、Observer、Composite 三个模式。如果用户输入一个字符,画出从键盘事件到 DOM 更新的完整执行流,标出每个模式介入的位置。
  5. DI 容器的循环依赖检测怎么做?写出一个 O(V+E) 的检测算法。
  6. 虚拟列表(享元模式)中,scrollTop 变化导致的 DOM 复用和浏览器的 layout/paint 之间如何配合才不会出现白屏和抖动?
  7. HOC 和 Custom Hook 都能做逻辑复用,什么时候 HOC 是比 Hook 更好的选择?给出一个具体的生产场景。
  8. 一个 SSR 应用的单例 Store 泄漏了用户 A 的状态给用户 B,你会怎么在排查初期用 Chrome DevTools 和 Node.js 日志快速定位到是单例问题而不是 cookie/session 问题?