JavaScript 设计模式:从 8 个经典模式到现代工程实践
Category(分类): JavaScript Status: 已更新
设计模式不是必须套用的代码模板,而是对重复出现的设计问题进行命名和总结。模式的价值在于明确职责、降低耦合、隔离变化和改善沟通;如果只是为了“使用某个模式”而增加抽象层,反而会让代码更复杂。
原文介绍单例、构造器、建造者、代理、外观、观察者、策略和迭代器 8 种模式,下面保留这条主线,并修正旧浏览器代码、构造函数实例方法和事件总线示例中的错误。

图来自原文,属于学习示意,不代表某个框架内部只有这些模式,也不代表每个业务都需要使用模式。
一、先理解模式的适用边界
在前端项目中,模块、函数、组合式函数、事件目标、状态管理器和依赖注入本身就提供了很多抽象能力。选择模式前可以先问:
- 变化点是什么?
- 是否存在重复的条件分支或对象创建流程?
- 是否需要隔离第三方 API、浏览器 API 或网络服务?
- 是否需要多个订阅者、取消订阅、错误隔离和生命周期管理?
- 抽象是否让测试更容易,而不是更难?
二、单例模式(Singleton)
2.1 概念
单例模式希望在某个作用域内只创建一个实例,并提供统一访问入口。这里的“一个”必须明确:可能是一个模块实例、一个应用实例、一个浏览器 realm,或一个进程,而不是天然的全局唯一。
ES 模块本身会缓存同一模块图中的模块实例,因此很多前端场景可以直接用模块作用域实现单例,不必手写全局变量:
// store.js
class AppStore {
#state = { theme: 'light' }
getState() {
return { ...this.#state }
}
setTheme(theme) {
this.#state.theme = theme
}
}
const store = new AppStore()
export default store
// 任意导入方拿到同一模块导出的实例
import store from './store.js'

2.2 适用场景与风险
适用场景:
- 应用级配置和只读配置;
- 日志、缓存、路由器或连接管理器;
- 需要统一协调的资源。
注意事项:
- 单例会隐藏依赖,降低测试隔离能力;
- 一个模块一个实例不等于多个标签页共享一个实例;
- SSR 应用不能把带请求状态的单例放在进程级模块变量中,否则可能串请求;
- 单例的生命周期、清理和热更新行为需要明确;
- 闭包不会“自动造成内存泄漏”,只有仍然可达的引用长期持有不需要的对象才会造成泄漏。
如果资源需要显式关闭,最好提供 dispose(),或由应用生命周期统一管理。
三、构造器模式(Constructor)
3.1 概念
构造器模式使用构造函数或类创建某一类对象。JavaScript 原型机制允许实例共享方法,因此不要把大量公共方法都定义在构造函数中:
function Toolkit(name = 'js 工具库') {
this.name = name
}
Toolkit.prototype.getElement = function (selector, root = document) {
return root.querySelector(selector)
}
Toolkit.prototype.isArray = Array.isArray
const toolkit = new Toolkit()
也可以使用类语法:
class Toolkit {
constructor(name = 'js 工具库') {
this.name = name
}
getElement(selector, root = document) {
return root.querySelector(selector)
}
isArray(value) {
return Array.isArray(value)
}
}

3.2 工程建议
- 构造函数首字母大写是约定,不是语法要求;
- 是否允许不使用
new要明确,不要静默修复所有错误; - 原型方法适合无实例状态的公共行为;
- 使用类字段或在构造函数中创建箭头函数,会为每个实例创建新函数,适合需要固定
this的少数回调,不应无差别使用; - 构造对象的成本不只是
new,还包括初始化网络、事件监听和资源分配。
四、建造者模式(Builder)
4.1 概念
建造者模式将复杂对象的构建步骤拆开,让调用方按需配置,最后生成结果。它适合参数多、构建顺序复杂或需要多种构建表示的对象。

4.2 示例:验证码配置构建
原文的 Canvas 验证码示例存在变量未声明、回调中的 text 作用域错误和配置为空时崩溃等问题。下面保留“逐步配置再构建”的思路:
class CaptchaBuilder {
#options = {
width: 200,
height: 40,
length: 4,
lineCount: 4
}
setSize(width, height) {
this.#options.width = width
this.#options.height = height
return this
}
setLength(length) {
if (!Number.isInteger(length) || length < 1) {
throw new RangeError('验证码长度必须是正整数')
}
this.#options.length = length
return this
}
setLineCount(lineCount) {
if (!Number.isInteger(lineCount) || lineCount < 0) {
throw new RangeError('干扰线数量必须是非负整数')
}
this.#options.lineCount = lineCount
return this
}
build() {
return Object.freeze({ ...this.#options })
}
}
const options = new CaptchaBuilder()
.setSize(240, 48)
.setLength(5)
.setLineCount(6)
.build()
Builder 不一定要返回类实例,也可以返回普通对象、请求配置或不可变数据。对于只有两三个可选参数的函数,直接使用参数对象通常更简单:
createCaptcha({ width: 240, height: 48, length: 5 })
五、代理模式(Proxy)
5.1 概念
代理对象控制对真实对象的访问,可以做权限检查、缓存、延迟创建、日志、远程调用或资源占位。代理会增加间接层,因此只有在访问控制或变化隔离确实有价值时才使用。

5.2 缓存代理
function sum(a, b) {
return a + b
}
function memoizeBinary(fn) {
const cache = new Map()
return function memoized(a, b) {
const key = `${typeof a}:${String(a)}|${typeof b}:${String(b)}`
if (cache.has(key)) return cache.get(key)
const result = fn.call(this, a, b)
cache.set(key, result)
return result
}
}
const cachedSum = memoizeBinary(sum)
console.log(cachedSum(1, 2))
字符串拼接作为缓存键仍可能发生碰撞,复杂参数应使用嵌套 Map、稳定序列化或明确的业务 ID。缓存还要考虑容量、失效时间、异常是否缓存和并发请求合并。
5.3 与 JavaScript Proxy 的关系
JavaScript 的 Proxy 是实现代理模式的语言能力之一,但“代理模式”不等于一定要使用 new Proxy()。例如图片占位符、请求缓存和权限服务也可以用普通对象组合实现。
const securedUser = new Proxy({ name: 'Ada' }, {
get(target, property, receiver) {
if (property === 'password') {
throw new Error('禁止读取 password')
}
return Reflect.get(target, property, receiver)
}
})
Proxy trap 必须遵守对象属性不变量;生产响应式系统还要处理数组、集合、原型、私有字段和性能,不能用十几行示例替代完整框架实现。
六、外观模式(Facade)
6.1 概念
外观模式为复杂子系统提供一个更容易使用的统一入口,调用方不必了解内部多个 API 的协调细节。它可以降低调用方与子系统的耦合,但外观层不能替代清晰的错误处理和权限边界。


6.2 示例:统一事件绑定
原文的 on(type, fn) 使用了未定义的 dom,并把 IE 专有 attachEvent 当成现代兼容方案。现代代码可以直接封装 addEventListener:
function on(element, type, listener, options) {
if (!element || typeof element.addEventListener !== 'function') {
throw new TypeError('element 必须提供 addEventListener()')
}
element.addEventListener(type, listener, options)
return () => element.removeEventListener(type, listener, options)
}
const button = document.querySelector('button')
const off = button && on(button, 'click', () => {
console.log('clicked')
})
// 需要时取消
// off?.()
如果项目需要支持已停止维护的旧 IE,应把兼容代码隔离在独立适配层,而不是在每次调用时重复探测。浏览器 API、Fetch、存储和权限等都适合用 Facade 提供业务级接口。
七、观察者模式(Observer)
7.1 概念
观察者模式建立一对多关系:主题对象保存观察者,状态变化时通知它们。它与发布/订阅模式相似,但发布/订阅通常通过独立的消息中介解耦发布者和订阅者,发布者不必直接持有订阅者列表。

7.2 可取消的主题
class Subject {
#observers = new Map()
subscribe(type, listener) {
if (typeof listener !== 'function') {
throw new TypeError('listener 必须是函数')
}
const listeners = this.#observers.get(type) ?? new Set()
listeners.add(listener)
this.#observers.set(type, listeners)
return () => this.unsubscribe(type, listener)
}
unsubscribe(type, listener) {
const listeners = this.#observers.get(type)
if (!listeners) return false
const removed = listeners.delete(listener)
if (listeners.size === 0) this.#observers.delete(type)
return removed
}
publish(type, payload) {
const listeners = this.#observers.get(type)
if (!listeners) return
// 复制快照,允许监听器在回调中取消自己
for (const listener of [...listeners]) {
listener(payload)
}
}
}
const subject = new Subject()
const unsubscribe = subject.subscribe('message', message => {
console.log(message)
})
subject.publish('message', 'hello')
unsubscribe()
生产事件系统还应决定:一个监听器抛错是否影响其他监听器、是否异步派发、是否保留历史事件、是否限制监听数量,以及页面销毁时如何自动取消。
八、策略模式(Strategy)
8.1 概念
策略模式把可以互相替换的算法封装为独立策略,调用方依赖统一接口,避免把大量业务分支堆在一个函数里。

8.2 示例:折扣策略
const discountStrategies = {
normal: price => price,
member: price => price * 0.9,
vip: price => price * 0.8
}
function calculatePrice(price, type = 'normal') {
const strategy = discountStrategies[type]
if (!strategy) throw new Error(`未知折扣策略:${type}`)
return strategy(price)
}
console.log(calculatePrice(100, 'vip'))
如果策略数量很少且不会变化,if/switch 并不一定是坏代码;策略模式的收益来自独立替换、测试和扩展,而不是消灭所有条件语句。表单校验、支付方式、排序算法、序列化格式和权限判断都可能使用策略模式。
九、迭代器模式(Iterator)
9.1 概念
迭代器模式提供统一的顺序访问方式,而不暴露集合内部表示。JavaScript 已经内置了迭代协议,因此 for…of、展开语法和 Array.from() 都是迭代器模式的日常应用。

9.2 自定义可迭代对象
const range = {
start: 1,
end: 3,
*[Symbol.iterator]() {
for (let value = this.start; value <= this.end; value += 1) {
yield value
}
}
}
console.log([...range]) // [1, 2, 3]
9.3 改进原文的 _each
function each(collection, callback) {
if (collection == null) return
if (typeof collection[Symbol.iterator] === 'function') {
let index = 0
for (const value of collection) {
callback(value, index, collection)
index += 1
}
return
}
if (typeof collection === 'object') {
for (const key of Object.keys(collection)) {
callback(collection[key], key, collection)
}
}
}
原文使用 for…in 遍历对象,可能把继承来的可枚举属性也带入;业务工具通常应使用 Object.keys(),除非明确需要遍历原型链。
十、补充:现代项目常见的相关模式
1. 工厂模式
把对象创建逻辑集中起来,调用方依赖抽象接口:
function createFormatter(type) {
if (type === 'json') return value => JSON.stringify(value)
if (type === 'text') return value => String(value)
throw new Error(`不支持的格式:${type}`)
}
2. 适配器模式
把第三方库或旧 API 转换为项目统一接口。Fetch、地图 SDK、支付 SDK 和存储驱动都适合隔离在适配器后面。
3. 装饰器模式
不修改原函数或对象,通过包装增加日志、重试、鉴权、缓存等行为。JavaScript 的函数是一等对象,普通高阶函数通常比实验性的 @decorator 语法更具兼容性。
function withLogging(fn) {
return (...args) => {
console.log('调用参数', args)
const result = fn(...args)
console.log('调用结果', result)
return result
}
}
4. 组合优于继承
现代前端组件更常使用组合函数、依赖注入和小型纯函数,而不是构造很深的继承树。设计模式应该服务于可读性、测试性和变化隔离。
十一、模式选择清单
| 问题 | 可以考虑 |
|---|---|
| 某个作用域内需要一个共享资源 | 模块单例、依赖注入 |
| 对象创建步骤很多 | 建造者、工厂 |
| 需要控制或延迟访问 | 代理、适配器 |
| 多个 API 需要统一入口 | 外观 |
| 状态变化要通知多个对象 | 观察者、发布/订阅 |
| 同一问题有多种可替换算法 | 策略 |
| 需要统一遍历不同集合 | 迭代器、原生 iterable |
| 想附加日志/缓存/重试 | 高阶函数、装饰器 |
总结
- 设计模式是可沟通的设计词汇,不是面试题模板;
- 模块系统已经为许多前端单例场景提供了实例缓存;
- 构造函数的公共方法应尽量共享在原型上;
- 观察者要提供取消订阅和生命周期管理,发布/订阅还要考虑错误隔离;
Proxy是语言能力,代理模式还可以由普通对象和函数组合实现;- 迭代器、事件目标、Fetch 和模块都是现代 JavaScript 自带的抽象基础;
- 选择模式前先确认它是否真的降低了耦合和维护成本。