技术知识文章集合TECHNICAL ARCHIVE · 457 DOCUMENTS

显示模式

登录
ARCHIVE DOCUMENTJS

JavaScript 常见设计模式解析

所属馆藏
JavaScript
文件格式
Markdown
原始路径
JavaScript/24-JavaScript 常见设计模式解析
本文目录11 个章节
  1. 一、设计模式的作用和边界
  2. 二、观察者模式 Observer Pattern
  3. 三、观察者与发布-订阅的区别
  4. 四、中介者模式 Mediator Pattern
  5. 五、代理模式 Proxy Pattern
  6. 六、单例模式 Singleton Pattern
  7. 七、工厂模式 Factory Pattern
  8. 八、装饰者模式 Decorator Pattern
  9. 九、其他模式的简要定位
  10. 十、如何在项目中使用
  11. 参考资料与原文

JavaScript 常见设计模式解析

Category(分类): JavaScript Status: 已整理(2026)

原文以观察者、中介者、代理、单例、工厂和装饰者为主线。本文保留这些历史示例和叙述方式,修复被复制按钮压扁的代码,并补充 GoF 分类、现代 JavaScript API 与提案状态。设计模式是设计经验,不是 ECMAScript 的强制规则。

一、设计模式的作用和边界

设计模式是对重复设计问题的命名和总结,目标通常是提高代码的可理解性、复用性和可维护性。但模式也会引入间接层,不能因为“看起来高级”就把简单函数拆成复杂对象。

本文涉及的模式可以大致分为:

  • 行为型:观察者、责任链、策略、中介者、命令、状态;
  • 结构型:代理、装饰者、适配器、组合;
  • 创建型:单例、工厂。

“发布-订阅”是常见消息架构风格,与观察者相似但不完全相同;它不是 GoF 23 种模式中的独立条目。

二、观察者模式 Observer Pattern

观察者模式的核心是:主题维护观察者集合,当自身发生事件或状态变化时通知它们。主题通常知道观察者,观察者只需要实现约定的回调。

DOM 事件是一个很直观的类比:

const button = document.querySelector('button')

function onClick(event) {
  console.log('点击了', event.currentTarget)
}

button.addEventListener('click', onClick)
button.removeEventListener('click', onClick)

下面是一个可以在浏览器和 Node.js 中理解的最小播放器事件模型:

class Player {
  constructor() {
    this.watchers = new Map()
  }

  subscribe(event, callback) {
    if (typeof callback !== 'function') {
      throw new TypeError('callback 必须是函数')
    }

    let callbacks = this.watchers.get(event)
    if (!callbacks) {
      callbacks = new Set()
      this.watchers.set(event, callbacks)
    }
    callbacks.add(callback)
    return () => this.unsubscribe(event, callback)
  }

  unsubscribe(event, callback) {
    const callbacks = this.watchers.get(event)
    if (!callbacks) return

    if (callback) callbacks.delete(callback)
    else callbacks.clear()
    if (callbacks.size === 0) this.watchers.delete(event)
  }

  publish(event, data) {
    const callbacks = this.watchers.get(event)
    if (!callbacks) return

    for (const callback of [...callbacks]) {
      callback.call(this, data)
    }
  }
}

const player = new Player()
const onPlay = function (title) {
  console.log('播放:', title, 'this 是 Player:', this === player)
}
const onPause = title => console.log('暂停:', title)

const stopListening = player.subscribe('play', onPlay)
player.subscribe('pause', onPause)
player.publish('play', 'demo.mp3')
player.publish('pause', 'demo.mp3')
stopListening()

实际项目还要考虑监听器异常是否应该中断通知、通知是否同步、是否允许重复订阅、组件销毁时是否取消订阅,以及事件名称是否容易冲突。

Vue 和响应式系统的版本边界

Vue 2 的响应式对象主要通过 Object.defineProperty() 拦截属性;Vue 3 的响应式对象主要通过 Proxy 拦截操作。组件 $emit 是组件事件分发,响应式依赖收集又是另一套机制。它们都可以帮助理解“变化后通知相关代码”的思想,但不能简单地说某个框架 API 完全等同于 GoF 观察者模式。

三、观察者与发布-订阅的区别

两者都可以维护回调列表,但耦合关系不同:

对比项观察者发布-订阅
通知关系主题通常直接持有观察者发布者和订阅者通过事件总线/代理连接
依赖方向主题知道观察者接口发布者通常只知道主题名或消息格式
适用场景对象状态变化、组件生命周期跨模块、跨进程或跨页面消息
风险观察者太多会造成通知链复杂全局事件名、事件顺序和调试成本复杂

事件总线不应成为所有模块通信的默认方式。能通过函数参数、返回值或明确的依赖注入表达的关系,通常比全局事件更容易追踪。

四、中介者模式 Mediator Pattern

中介者把多个通信者之间的协作集中到一个协调对象中。通信者不直接依赖彼此,而是依赖中介者约定的消息或操作。

下面保留原文“汽车和乘客”的例子,并修复原代码中 bus 先使用后声明、删除乘客时遍历集合以及目标广播参数混乱的问题:

class Bus {
  constructor() {
    this.passengers = new Map()
  }

  aboard(passenger) {
    this.passengers.set(passenger.id, passenger)
    passenger.bus = this
  }

  debus(passenger) {
    this.passengers.delete(passenger.id)
    passenger.bus = null
    console.log(`乘客 ${passenger.id} 下车`)
  }

  broadcast(message, targetId) {
    const passengers = targetId === undefined
      ? [...this.passengers.values()]
      : [this.passengers.get(targetId)].filter(Boolean)

    for (const passenger of passengers) {
      passenger.listen(message)
    }
  }

  start() {
    this.broadcast({ type: 'start', content: '前方无障碍,开车!' })
  }

  stop() {
    this.broadcast({ type: 'stop', content: '到站停车!' })
  }
}

class Passenger {
  constructor(id) {
    this.id = id
    this.bus = null
  }

  listen(message) {
    console.log(`乘客 ${this.id} 收到消息:`, message.content)
    if (message.type === 'stop') this.debus()
  }

  debus() {
    if (this.bus) this.bus.debus(this)
  }
}

const bus = new Bus()
const passenger1 = new Passenger(1)
const passenger2 = new Passenger(2)
bus.aboard(passenger1)
bus.aboard(passenger2)

bus.start()
bus.broadcast({ type: 'notice', content: '2 号乘客请下车' }, 2)
bus.debus(passenger2)
bus.stop()

Bus 是中介者,Passenger 是通信者。这个例子不是说所有事件总线都是中介者,而是展示了由一个对象协调多个对象的结构。中介者过度膨胀时,会变成“上帝对象”,需要继续拆分职责。

五、代理模式 Proxy Pattern

代理模式为目标对象提供替代访问入口,可以做权限检查、缓存、延迟加载、日志或参数校验。代理不一定要继承目标类,也可以通过组合持有目标。

5.1 普通对象代理

class Factory {
  constructor(count = 1000) {
    this.productions = count
  }

  produce(count) {
    if (count < 5) throw new RangeError('少于 5 件不接单')
    this.productions += count
  }

  wholesale(count) {
    if (count < 10) throw new RangeError('少于 10 件不批发')
    if (count > this.productions) throw new RangeError('库存不足')
    this.productions -= count
  }
}

class ProxyFactory {
  constructor(factory) {
    this.factory = factory
  }

  produce(count) {
    console.log('代理检查生产订单')
    return this.factory.produce(count)
  }

  wholesale(count) {
    console.log('代理检查批发订单')
    return this.factory.wholesale(count)
  }
}

const factory = new Factory()
const proxyFactory = new ProxyFactory(factory)
proxyFactory.produce(20)
proxyFactory.wholesale(10)
console.log(factory.productions) // 1010

5.2 ECMAScript Proxy 对象

ECMAScript 的 Proxy 提供 getsethasapply 等陷阱。它是语言能力,不等于设计模式本身,但很适合实现某些代理:

const target = { name: 'Ada' }
const proxy = new Proxy(target, {
  get(object, property, receiver) {
    console.log('读取:', String(property))
    return Reflect.get(object, property, receiver)
  },

  set(object, property, value, receiver) {
    if (property === 'name' && typeof value !== 'string') {
      throw new TypeError('name 必须是字符串')
    }
    return Reflect.set(object, property, value, receiver)
  }
})

console.log(proxy.name)
proxy.name = 'Grace'

陷阱应尽量调用 Reflect 保持默认语义,并遵守代理不变式。代理可能影响调试和性能,也不能绕过跨域、权限或服务器端安全检查。

5.3 代理一个服务客户端

function httpGet(url) {
  return fetch(url).then(response => {
    if (!response.ok) throw new Error(`HTTP ${response.status}`)
    return response.json()
  })
}

function createWebService(baseUrl) {
  return new Proxy({}, {
    get(_target, property) {
      if (typeof property !== 'string') return undefined
      return () => httpGet(`${baseUrl}/${encodeURIComponent(property)}`)
    }
  })
}

const service = createWebService('https://example.com/api')
service.employees().then(console.log).catch(console.error)

示例需要浏览器或支持 fetch 的运行环境,并且还受到服务器 CORS 配置影响。动态把任意属性转换成网络请求时,要限制允许的属性名,避免产生不可控的 URL。

六、单例模式 Singleton Pattern

单例模式的意图是在一个明确作用域内只创建和复用一个实例。它不是“调用构造函数永远返回同一对象”的语法保证;如果直接暴露类构造函数,调用者仍可能绕过工厂创建多个实例。

原文的类写法可改成下面这样,但它仍允许 new Singleton()

class Singleton {
  constructor(name) {
    this.name = name
  }

  static getInstance(name) {
    if (!Singleton.instance) {
      Singleton.instance = new Singleton(name)
    }
    return Singleton.instance
  }
}

const instanceA = Singleton.getInstance('seven1')
const instanceB = Singleton.getInstance('seven2')
console.log(instanceA === instanceB) // true
console.log(instanceB.name) // seven1

更常见的做法是模块级工厂或直接导出对象。ES Module 在同一模块图中会缓存模块执行结果,但不同 Realm、不同 Worker、重复打包副本仍可能有不同实例:

// config.js
const config = { apiBase: '/api' }
export default config

闭包工厂可以限制创建入口:

const createSingleton = create => {
  let instance
  return (...args) => {
    if (!instance) instance = create(...args)
    return instance
  }
}

const getCache = createSingleton(() => new Map())
const cacheA = getCache()
const cacheB = getCache()
console.log(cacheA === cacheB) // true

单例会增加全局状态和测试隔离成本。依赖注入一个普通对象,往往比在业务代码中到处调用单例更容易替换。

七、工厂模式 Factory Pattern

工厂把对象创建细节集中起来,让调用方只选择类型或提供必要参数。下面用 DOM 节点保留原文“文本、链接、图片工厂”的场景,并避免使用会遮蔽浏览器全局 Image 的类名:

class TextElement {
  constructor(text) {
    this.text = text
  }

  insert(parent) {
    parent.append(document.createTextNode(this.text))
  }
}

class LinkElement {
  constructor(url) {
    this.url = url
  }

  insert(parent) {
    const link = document.createElement('a')
    link.href = this.url
    link.textContent = this.url
    parent.append(link)
  }
}

class ImageElement {
  constructor(url) {
    this.url = url
  }

  insert(parent) {
    const image = document.createElement('img')
    image.src = this.url
    image.alt = ''
    parent.append(image)
  }
}

const elementTypes = {
  text: TextElement,
  link: LinkElement,
  image: ImageElement
}

function createElement(type, value) {
  const ElementClass = elementTypes[type]
  if (!ElementClass) throw new RangeError(`未知元素类型:${type}`)
  return new ElementClass(value)
}

createElement('text', 'Hello').insert(document.body)
createElement('link', 'https://example.com').insert(document.body)

工厂方法、抽象工厂和简单工厂的术语在不同文章中经常混用。重点应放在“创建变化是否被隔离”,而不是争论一个函数必须属于哪一种工厂。

八、装饰者模式 Decorator Pattern

装饰者在不改变原对象主要接口的情况下叠加职责。它与继承不同:装饰者可以在运行时包裹某个具体对象;它与代理也相似,但典型目的不同:代理强调控制访问,装饰者强调增加行为。

8.1 包装函数

function before(fn, beforeFn) {
  return function (...args) {
    beforeFn.apply(this, args)
    return fn.apply(this, args)
  }
}

function after(fn, afterFn) {
  return function (...args) {
    const result = fn.apply(this, args)
    afterFn.apply(this, args)
    return result
  }
}

function save(user) {
  console.log('保存用户:', user.name)
  return user
}

const validate = user => {
  if (!user.name) throw new Error('缺少姓名')
}
const report = () => console.log('保存完成')
const decoratedSave = after(before(save, validate), report)
decoratedSave({ name: 'Ada' })

原文把 beforeafter 挂到 Function.prototype,这种方式会污染全局原型,可能与其他库冲突;使用普通包装函数更容易限定影响范围。

8.2 window.onload 的历史写法与现代事件 API

如果维护旧代码,包装已有 onload 时应保留事件参数和调用者:

const previousOnload = window.onload
window.onload = function (event) {
  if (typeof previousOnload === 'function') {
    previousOnload.call(this, event)
  }
  console.log('自己的处理函数')
}

新代码更推荐分别注册监听器,避免互相覆盖:

window.addEventListener('load', () => {
  console.log('自己的处理函数')
})

8.3 对象装饰

class Plane {
  constructor(name) {
    this.name = name
  }

  fire() {
    return '发射普通子弹'
  }
}

class MissileDecorator {
  constructor(plane) {
    this.plane = plane
  }

  fire() {
    return `${this.plane.fire()};发射导弹`
  }
}

const plane = new MissileDecorator(new Plane('普通飞机'))
console.log(plane.fire()) // 发射普通子弹;发射导弹

8.4 不要把旧装饰器示例称为 ES7 原生语法

原文中的 @isAnimaltarget, name, descriptor 是早期装饰器提案或 Babel/TypeScript legacy decorators 的写法,不是 ES7 的标准特性;其中 discriptor 还是拼写错误。不同工具链的装饰器签名和初始化时机不同,不能直接混用。

当前标准轨道装饰器提案使用 valuecontext 等参数,仍应检查目标浏览器、编译器和提案版本。为避免把未普遍支持的 @ 语法误当作普通 JavaScript,下面只作概念示意,不作为无构建环境的可运行示例:

function logged(value, context) {
  if (context.kind !== 'method') return

  return function (...args) {
    console.log(`进入 ${String(context.name)}`)
    const result = value.call(this, ...args)
    console.log(`离开 ${String(context.name)}`)
    return result
  }
}

@logged
class Service {
  run() {
    return 'ok'
  }
}

如果项目使用 TypeScript 或 Babel,应明确选择标准装饰器配置或 legacy 配置,并以工具链文档为准。设计模式中的装饰者则不依赖 @ 语法,普通函数和对象包装已经足够。

九、其他模式的简要定位

策略模式

根据参数选择算法。JavaScript 中常用对象映射函数,而不是大量 if 或类:

const strategies = {
  add: (a, b) => a + b,
  multiply: (a, b) => a * b
}

function calculate(type, a, b) {
  const strategy = strategies[type]
  if (!strategy) throw new RangeError('未知策略')
  return strategy(a, b)
}

责任链模式

请求可以被当前处理器处理、拒绝或继续传递;不是所有处理器都必须执行:

function createHandler(check, next) {
  return context => {
    if (check(context)) return true
    return next ? next(context) : false
  }
}

const hasToken = createHandler(
  context => Boolean(context.token),
  context => Boolean(context.session)
)

console.log(hasToken({ token: 'token' })) // true

适配器模式

适配器转换接口,而不是只能转换“数据结构”:

function legacyLogger(message, level) {
  console.log(`[${level}] ${message}`)
}

const logger = {
  info(message) {
    legacyLogger(message, 'INFO')
  }
}

logger.info('适配后的调用')

状态模式与模板方法

状态模式可以用状态对象、函数表或类实现,并不要求“每个状态一定是一个类”。模板方法固定总体步骤,把具体步骤交给子类或回调:

function runTemplate(input, steps) {
  const normalized = steps.normalize(input)
  const checked = steps.validate(normalized)
  return steps.save(checked)
}

const result = runTemplate(' ada ', {
  normalize: value => value.trim(),
  validate: value => {
    if (!value) throw new Error('空值')
    return value
  },
  save: value => value.toUpperCase()
})

console.log(result) // ADA

十、如何在项目中使用

  1. 先确认变化点和依赖关系,再选择模式。
  2. 对事件订阅提供取消订阅能力,尤其是组件、Worker 和页面销毁时。
  3. 对代理和装饰器保留原调用约定,包括 this、参数、返回值和异常。
  4. 对单例记录生命周期和作用域,避免把测试依赖隐藏在全局变量中。
  5. 对装饰器语法标注工具链和标准状态,不把 Babel/TypeScript 语法写成原生 ES7。
  6. 用小型可运行示例说明模式,不要只给被复制按钮破坏的压缩代码。

参考资料与原文

457 DOCUMENTS · 10 COLLECTIONS
ARCHIVE SEARCH457 篇文章

SEARCH GUIDE

输入关键词开始搜索

支持搜索文章标题、所属分类和原始文档路径。

按分类浏览

10 COLLECTIONS