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

显示模式

登录
ARCHIVE DOCUMENTJS

JavaScript 设计模式:从 8 个经典模式到现代工程实践

所属馆藏
JavaScript
文件格式
Markdown
原始路径
JavaScript/02-15分钟带你了解前端工程师必知的javascript设计模式(附详细思维导图和源码)
本文目录13 个章节
  1. 一、先理解模式的适用边界
  2. 二、单例模式(Singleton)
  3. 三、构造器模式(Constructor)
  4. 四、建造者模式(Builder)
  5. 五、代理模式(Proxy)
  6. 六、外观模式(Facade)
  7. 七、观察者模式(Observer)
  8. 八、策略模式(Strategy)
  9. 九、迭代器模式(Iterator)
  10. 十、补充:现代项目常见的相关模式
  11. 十一、模式选择清单
  12. 总结
  13. 参考资料

JavaScript 设计模式:从 8 个经典模式到现代工程实践

Category(分类): JavaScript Status: 已更新

设计模式不是必须套用的代码模板,而是对重复出现的设计问题进行命名和总结。模式的价值在于明确职责、降低耦合、隔离变化和改善沟通;如果只是为了“使用某个模式”而增加抽象层,反而会让代码更复杂。

原文介绍单例、构造器、建造者、代理、外观、观察者、策略和迭代器 8 种模式,下面保留这条主线,并修正旧浏览器代码、构造函数实例方法和事件总线示例中的错误。

设计模式总览(历史资料)

图来自原文,属于学习示意,不代表某个框架内部只有这些模式,也不代表每个业务都需要使用模式。

一、先理解模式的适用边界

在前端项目中,模块、函数、组合式函数、事件目标、状态管理器和依赖注入本身就提供了很多抽象能力。选择模式前可以先问:

  1. 变化点是什么?
  2. 是否存在重复的条件分支或对象创建流程?
  3. 是否需要隔离第三方 API、浏览器 API 或网络服务?
  4. 是否需要多个订阅者、取消订阅、错误隔离和生命周期管理?
  5. 抽象是否让测试更容易,而不是更难?

二、单例模式(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 自带的抽象基础;
  • 选择模式前先确认它是否真的降低了耦合和维护成本。

参考资料

457 DOCUMENTS · 10 COLLECTIONS
ARCHIVE SEARCH457 篇文章

SEARCH GUIDE

输入关键词开始搜索

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

按分类浏览

10 COLLECTIONS