JavaScript 中常见设计模式整理
Category(分类): JavaScript Status: 已整理(2026)
本文保留原文“按关键词认识常见设计模式”的结构,并补充可运行的 JavaScript 示例。设计模式不是必须套用的模板,也不是 JavaScript 的语法特性;它们只是解决一类重复设计问题的词汇。下面的“单例、发布-订阅、装饰器”等说法,会与 ECMAScript 内置能力区分开。

一、先建立正确的预期
设计模式通常被定义为可复用的设计经验。它描述的是对象之间的职责和协作方式,而不是一段必须逐字复制的代码。使用模式的目的可以是:
- 降低模块之间的耦合;
- 把容易变化的部分隔离出来;
- 让团队可以用统一术语讨论设计;
- 在可读性、可测试性和扩展性之间取得平衡。
但模式也有成本:额外的抽象、对象和间接调用可能让简单代码变复杂。优先写清晰的直接代码,只有在变化点和协作关系确实重复时再引入模式。
GoF 23 种模式与本文范围
GoF(《设计模式:可复用面向对象软件的基础》)的 23 种模式分为三类。原文列出的模式是其中一部分,另外“发布-订阅”是常见架构习惯,并不是 GoF 23 种中的独立模式。
| 类别 | GoF 模式 | 本文涉及 |
|---|---|---|
| 创建型 | 抽象工厂、建造者、工厂方法、原型、单例 | 单例、工厂、原型相关实现 |
| 结构型 | 适配器、桥接、组合、装饰器、外观、享元、代理 | 适配器、装饰器、享元、代理、组合 |
| 行为型 | 责任链、命令、解释器、迭代器、中介者、备忘录、观察者、状态、策略、模板方法、访问者 | 责任链、命令、迭代器、中介者、观察者、状态、策略、模板方法 |
二、原文涉及的模式关键词
下面保留原文的模式清单,并用更准确的关键词重新归纳:
| 模式 | 核心问题 | 常见 JavaScript 例子 |
|---|---|---|
| 单例 | 怎样在一个作用域内复用同一个实例 | ES Module 导出的配置对象、缓存管理器 |
| 策略 | 怎样在运行时替换一组算法 | 不同折扣、排序或校验策略 |
| 代理 | 怎样控制对目标对象的访问 | 缓存代理、权限代理、Proxy 陷阱 |
| 迭代器 | 怎样按统一协议逐项消费集合 | for...of、Symbol.iterator |
| 发布-订阅 | 怎样让发布者和订阅者通过事件通道解耦 | 事件总线、消息队列、RxJS |
| 命令 | 怎样把一次操作封装成对象或函数 | 撤销/重做、任务队列 |
| 组合 | 怎样把树状对象统一当作单个对象处理 | 文件夹与文件、UI 组件树 |
| 模板方法 | 怎样固定算法骨架而替换若干步骤 | 基类流程、钩子函数 |
| 享元 | 怎样共享不随实例变化的内部状态 | 大量文本/粒子使用共享样式对象 |
| 责任链 | 怎样让请求沿处理器链传递 | 中间件、权限检查链 |
| 中介者 | 怎样由一个协调对象管理多个通信者 | 表单协调器、页面控制器 |
| 装饰器 | 怎样在不改目标接口的情况下叠加行为 | 日志、重试、缓存包装器 |
| 状态 | 怎样让对象根据状态改变行为 | 播放器、订单状态机 |
| 适配器 | 怎样把已有接口转换成调用方需要的接口 | 统一不同后端返回格式 |
| 观察者 | 怎样在状态改变时通知多个观察者 | DOM 事件、事件发射器、响应式系统 |
“适配者”通常应写作“适配器”。“观察者”和“发布-订阅”经常被混用,但两者的耦合关系不同:观察者一般由主题直接持有观察者列表;发布-订阅通常多了一个事件总线或消息代理,发布者和订阅者彼此不知道对方。
三、策略模式:把变化的算法作为参数
策略模式适合“输入流程相同,但某一步算法会变化”的场景。函数是一等值,因此 JavaScript 不需要为每个策略都创建一个类:
const discountStrategies = {
none: price => price,
member: price => price * 0.9,
holiday: price => price * 0.8
}
function finalPrice(price, strategyName = 'none') {
const strategy = discountStrategies[strategyName]
if (!strategy) throw new RangeError(`未知策略:${strategyName}`)
return strategy(price)
}
console.log(finalPrice(100, 'member')) // 90
策略表应当只接收必要参数,并在边界处校验输入。不要为了“使用模式”把一个简单的 if 强行拆成十几个文件。
四、观察者与发布-订阅
一个最小事件发射器可以使用 Set 保存监听器,并让 on 返回取消订阅函数。复制监听器集合后再通知,可以避免某个监听器在通知过程中增删监听器导致遍历行为难以理解:
class EventEmitter {
constructor() {
this.events = new Map()
}
on(event, listener) {
if (typeof listener !== 'function') {
throw new TypeError('listener 必须是函数')
}
let listeners = this.events.get(event)
if (!listeners) {
listeners = new Set()
this.events.set(event, listeners)
}
listeners.add(listener)
return () => this.off(event, listener)
}
off(event, listener) {
const listeners = this.events.get(event)
if (!listeners) return
listeners.delete(listener)
if (listeners.size === 0) this.events.delete(event)
}
emit(event, ...args) {
const listeners = this.events.get(event)
if (!listeners) return
for (const listener of [...listeners]) {
listener(...args)
}
}
}
const player = new EventEmitter()
const unsubscribe = player.on('play', title => {
console.log(`播放:${title}`)
})
player.emit('play', 'demo.mp3')
unsubscribe()
player.emit('play', '不会再被输出')
DOM 的 addEventListener 也是“事件通知”风格的 API,但它由浏览器管理事件源和监听器,不必自己实现事件总线:
const button = document.querySelector('button')
const onClick = event => console.log(event.currentTarget)
button.addEventListener('click', onClick)
button.removeEventListener('click', onClick)
Vue 2 的响应式实现大量使用 Object.defineProperty(),Vue 3 的响应式对象主要使用 Proxy;组件 $emit 更准确地说是组件事件分发。它们都可以帮助理解观察者思想,但不应笼统地说“某框架完全等于观察者模式”。
五、迭代器与组合
迭代器只要求对象提供 next(),每次返回 { value, done }。for...of 会通过 Symbol.iterator 获取迭代器:
const range = {
from: 1,
to: 3,
[Symbol.iterator]() {
let current = this.from
const end = this.to
return {
next() {
if (current <= end) return { value: current++, done: false }
return { value: undefined, done: true }
}
}
}
}
console.log([...range]) // [1, 2, 3]
组合模式把一组具有相同外部操作的叶子节点和容器节点组成树。例如文件和文件夹都可以暴露 size():
class FileNode {
constructor(size) {
this.size = size
}
totalSize() {
return this.size
}
}
class Folder {
constructor() {
this.children = []
}
add(child) {
this.children.push(child)
return this
}
totalSize() {
return this.children.reduce((sum, child) => sum + child.totalSize(), 0)
}
}
const root = new Folder()
root.add(new FileNode(10)).add(new FileNode(20))
console.log(root.totalSize()) // 30
六、代理模式与 ECMAScript Proxy
设计模式中的代理是“为目标提供替代访问入口”。JavaScript 的 Proxy 是 ECMAScript 内置对象,可以拦截属性读取、写入、调用等操作,但使用它并不自动等于完整的代理模式。
const target = { name: '原对象' }
const cached = new Map()
const proxy = new Proxy(target, {
get(object, property, receiver) {
if (property === 'name' && cached.has(property)) {
return cached.get(property)
}
const value = Reflect.get(object, property, receiver)
if (property === 'name') cached.set(property, value)
return value
},
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 = '新名称'
console.log(proxy.name)
拦截器必须遵守代理不变式,例如不能随意报告一个不可配置、不可写属性已经被改变。对普通转发操作优先使用 Reflect,并注意代理会增加调试和性能成本。
七、单例模式:不要把“全局变量”当成必要条件
单例的意图是:在一个明确的生命周期或作用域内只复用一个实例。它不是“整个互联网只能有一个对象”。不同的 JavaScript Realm、不同的 Worker、重复加载的包,可能各自拥有不同实例。
ES Module 本身会缓存同一模块在同一模块图中的执行结果,因此一个模块导出的对象常常已经满足“模块级共享”:
// config.js
export const appConfig = {
apiBase: '/api'
}
如果需要延迟创建,可以使用闭包工厂:
const getSingleton = (() => {
let instance
return create => {
if (!instance) instance = create()
return instance
}
})()
const first = getSingleton(() => ({ createdAt: Date.now() }))
const second = getSingleton(() => ({ createdAt: Date.now() }))
console.log(first === second) // true
比起让类构造函数偷偷返回旧对象,显式的模块导出或工厂函数更容易测试。单例也会带来全局状态、测试隔离困难和生命周期不清晰等问题。
八、责任链、命令、模板方法、状态和享元
这些模式可以用普通函数组合,不必机械地使用继承:
function withAuth(next) {
return context => {
if (!context.user) throw new Error('未登录')
return next(context)
}
}
function withAdmin(next) {
return context => {
if (!context.user.isAdmin) throw new Error('无权限')
return next(context)
}
}
const handleAdminPage = withAuth(withAdmin(context => `欢迎,${context.user.name}`))
console.log(handleAdminPage({ user: { name: 'Ada', isAdmin: true } }))
上例是责任链/中间件风格。命令模式可以把操作和执行者分离,并为撤销保留函数:
function createCommand(doIt, undo) {
return { doIt, undo }
}
const history = []
const state = { count: 0 }
const addOne = createCommand(
() => { state.count += 1 },
() => { state.count -= 1 }
)
addOne.doIt()
history.push(addOne)
history.pop().undo()
console.log(state.count) // 0
模板方法固定流程,步骤通过钩子传入;状态模式把不同状态的行为分开;享元模式共享不随实例变化的内部数据。它们的共同原则是明确“哪些数据可以共享、哪些行为允许替换”,而不是为了模式名称增加层级。
九、适配器、装饰器与工厂
适配器把已有接口转换成调用方需要的接口:
function legacyRequest(url, callback) {
setTimeout(() => callback(null, { code: 200, body: 'ok' }), 0)
}
function request(url) {
return new Promise((resolve, reject) => {
legacyRequest(url, (error, response) => {
if (error) reject(error)
else resolve(response.body)
})
})
}
request('/users').then(console.log)
装饰器可以是一个包装函数:
function withLogging(fn) {
return function (...args) {
console.log('调用参数:', args)
const result = fn.apply(this, args)
console.log('调用结果:', result)
return result
}
}
const add = withLogging((a, b) => a + b)
console.log(add(1, 2))
不要把上面的函数装饰器和 @decorator 语法混为一谈。ECMAScript 装饰器仍应按提案/工具链支持情况使用,TypeScript 的 legacy decorators 与标准提案的签名也不同,不能把旧文章中的 target, name, descriptor 示例直接称为 ES7 原生语法。
工厂模式把创建细节集中起来:
const formatters = {
text: value => String(value),
json: value => JSON.stringify(value)
}
function createFormatter(type) {
const formatter = formatters[type]
if (!formatter) throw new RangeError(`不支持的格式:${type}`)
return formatter
}
console.log(createFormatter('json')({ ok: true })) // {"ok":true}
十、如何选择
可以从以下问题开始,而不是先背模式名:
- 哪一部分最可能变化?把变化点参数化或隔离。
- 调用方是否应该知道具体实现?如果不应该,考虑适配器、工厂或代理。
- 对象之间是直接通知,还是需要一个消息中介?分别考虑观察者和发布-订阅/中介者。
- 是否真的需要全局唯一实例?如果只是共享缓存,模块导出通常更简单。
- 抽象是否让测试和阅读更困难?如果是,删除模式往往是更好的设计。
参考资料与原文
- MDN:JavaScript 继承与原型链
- MDN:Proxy
- MDN:迭代协议
- MDN:模块
- JavaScript 设计模式(原文来源)
- 《JavaScript 设计模式与开发实践》
原文中的 GitHub 示例链接和部分框架说明年代较早,本文保留模式清单与历史背景,但以当前规范和可运行示例为准。