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 提供 get、set、has、apply 等陷阱。它是语言能力,不等于设计模式本身,但很适合实现某些代理:
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' })
原文把 before 和 after 挂到 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 原生语法
原文中的 @isAnimal、target, name, descriptor 是早期装饰器提案或 Babel/TypeScript legacy decorators 的写法,不是 ES7 的标准特性;其中 discriptor 还是拼写错误。不同工具链的装饰器签名和初始化时机不同,不能直接混用。
当前标准轨道装饰器提案使用 value 和 context 等参数,仍应检查目标浏览器、编译器和提案版本。为避免把未普遍支持的 @ 语法误当作普通 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
十、如何在项目中使用
- 先确认变化点和依赖关系,再选择模式。
- 对事件订阅提供取消订阅能力,尤其是组件、Worker 和页面销毁时。
- 对代理和装饰器保留原调用约定,包括
this、参数、返回值和异常。 - 对单例记录生命周期和作用域,避免把测试依赖隐藏在全局变量中。
- 对装饰器语法标注工具链和标准状态,不把 Babel/TypeScript 语法写成原生 ES7。
- 用小型可运行示例说明模式,不要只给被复制按钮破坏的压缩代码。