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

显示模式

登录
ARCHIVE DOCUMENTJS

深入 JavaScript 设计模式,从此有了优化代码的理论依据

所属馆藏
JavaScript
文件格式
Markdown
原始路径
JavaScript/111-深入 JavaScript 设计模式,从此有了优化代码的理论依据
本文目录11 个章节
  1. 一、设计模式综述
  2. 二、工厂模式
  3. 三、单例模式
  4. 四、适配器模式
  5. 五、装饰器模式
  6. 六、代理模式
  7. 七、观察者模式
  8. 原文勘误与现代化说明
  9. 图片来源与授权说明
  10. 原文归属
  11. 参考文章

深入 JavaScript 设计模式,从此有了优化代码的理论依据

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

本文保留了原文(2019 年 8 月)的结构与全部主要示例:综述、工厂、单例、适配器、装饰器、代理、观察者。整理时修复了多处会直接报错的代码(super() 用于无父类、case: 语法、Proxy 对象缺逗号、闭包变量引用错误等),更正了章节编号,并对「观察者 vs 发布订阅」「装饰器提案现状」「Vuex/Redux 表述」补充了现代视角。文末附勘误清单。

一、设计模式综述

我想很多和我一样的朋友小时候都看过《天龙八部》,里面的女主角王语嫣是个武学博才,但自己却毫无实战。比如段誉和慕容复交手时,她连连口述指导:"段郎,二龙爪手,抢珠三式,当心你的腰肋,注意你的气户穴。潘月偷心,扶手相望......",虽然看着感觉都是一些最基本的拳脚功夫,但有解说在旁边,到底还是感觉高大上了很多。没错,设计模式其实就和这些招数名差不多,很多模式都给人一种其实平时没少用,可就是不知道原来这是一个专业招术...。但我们确实需要从系统层面深入理解一下这些常用的模式,不仅可以起到发散思维的作用,同时也可以指导我们解决问题的能力。如果之前很少接触过设计模式,那么这篇文章希望可以助力你一下,感谢关注和点赞。

1.1 模式定义

设计模式的定义:在面向对象软件设计过程中针对特定问题的简洁而优雅的解决方案。

说白了,设计模式就是一种理念,通过一些设计思维来解决平时编写底层或业务代码时遇到的场景问题。比如早期业务中的一个封装类,同时带有一些封装方法。如果现在该类不能再满足全部业务场景,且不允许修改原方法,此时就需要装饰器或适配器模式来解决;又比如当设计一个场景,在调用一个固定对象时一定要先执行某些方法,比如验证登录、验证身份 ID 等场景,此时就应该用到代理模式。这种例子有很多,可以先看一下设计模式的分类。

1.2 模式分类

设计模式,按标准划分,有 3 大类 23 种,而由于 JavaScript 的一些特性,如弱类型语言、无接口编程等特征,故其中只有一些模式是比较重要的。下面给出这 23 种设计模式名称。

类型模式名称
创建型工厂 单例 原型
组合型(结构型)适配器 装饰器 代理 外观 桥接
行为型观察者 命令 中介者 状态 策略 解释器 迭代器 访问者 模板方法 职责链 备忘录

(勘误与补全:上表按 GoF 的 23 种标准应为——创建型 5 种:工厂方法、抽象工厂、建造者、原型、单例;结构型 7 种:适配器、桥接、组合、装饰器、外观、享元、代理;行为型 11 种:责任链、命令、解释器、迭代器、中介者、备忘录、观察者、状态、策略、模板方法、访问者。原文表格漏列了建造者、组合、享元三种(抽象工厂在下文工厂模式中作为细分讲述)。)

是不是觉得这些高逼格的词汇很霸气,下面就先从一些重要的模式开展了解和深入。

二、工厂模式

2.1 基本特征

工厂模式有三种形式:简单工厂模式、工厂方法模式和抽象工厂模式。在 JS 中我们最常见的当属简单工厂模式。工厂模式的设计思想即:

  • 将 new 操作单独封装,只对外提供相应接口;
  • 遇到 new 时,就要考虑是否应该使用工厂模式;

2.2 核心作用

工厂模式的核心作用如下:

  • 主要用于隐藏创建实例的复杂度,只需对外提供一个接口;
  • 实现构造函数和创建者的分离,满足开放封闭的原则;

2.3 分类

  • 简单工厂模式:一个工厂对象创建一种产品对象实例。即用来创建同一类对象;
  • 工厂方法模式:建立抽象核心类,将创建实例的实际重心放在核心抽象大类的子类中;
  • 抽象工厂模式:对类的工厂抽象用来创建产品类簇,不负责创建某一类产品的实例。

由于在 JS 中基本不会使用抽象工厂模式,因此本文探究前两类模式。

2.4 实例演示

先通过一个简单例子最直观感受什么是工厂:

// 定义产品
class Product {
  constructor(name) {
    this.name = name
  }
  init() {
    console.log('初始化产品')
  }
}

// 定义工厂
class Factory {
  create(name) {
    return new Product(name) // 核心思想
  }
}

let c = new Factory()
let p = c.create('p1')
p.init() // 初始化产品

工厂模式最直观的地方在于,创建产品对象不是通过直接 new 产品类实现,而是通过工厂方法实现。现在再用一个稍微有些好看的例子描述一下简单工厂:

// User 类
class User {
  // 构造器
  constructor(opt) {
    this.name = opt.name
    this.viewPage = opt.viewPage
  }

  static getInstance(role) {
    switch (role) {
      case 'superAdmin':
        return new User({ name: '超级管理员', viewPage: ['首页', '通讯录', '发现页', '应用数据', '权限管理'] })
      case 'admin':
        return new User({ name: '管理员', viewPage: ['首页', '通讯录'] })
      default:
        throw new Error('params error')
    }
  }
}

// 调用
let superAdmin = User.getInstance('superAdmin')
let admin = User.getInstance('admin')

通过上例,我们可以看到,每次创建新的对象实例时,只需要传入相应的参数,就可以得到指定的对象实例。最直观的例子是如果不用工厂模式,那代码中是不是就会多出好多个 new,这样看着也不太舒服。

(勘误:原文各 case 后的 break 是不可达代码,已删除,行为不变。)

其实简单工厂模式已经能满足我们前端大部分业务场景了,如果非要说其一个缺陷,那就是每次有新实例时,我们需要重写这个 User 大类,总归感觉和后面所述的装饰器模式有一些冲突。此时,工厂方法模式就出来了,其核心思想就是独立出一个大的 User 类,将创建实例对象的过程用其子类来实现:

class User {
  constructor(name = '', viewPage = []) {
    this.name = name
    this.viewPage = viewPage
  }
}

class UserFactory extends User {
  constructor(name, viewPage) {
    super(name, viewPage)
  }
  create(role) {
    switch (role) {
      case 'superAdmin':
        return new UserFactory('超级管理员', ['首页', '通讯录', '发现页', '应用数据', '权限管理'])
      case 'admin':
        return new UserFactory('管理员', ['首页', '通讯录'])
      default:
        throw new Error('params error')
    }
  }
}
let userFactory = new UserFactory()
let superAdmin = userFactory.create('superAdmin')
let admin = userFactory.create('admin')
try {
  userFactory.create('user') // 走 default 分支,抛出 Error: params error
} catch (e) {
  console.log(e.message) // params error
}

这样,虽然也得通过 new 一个实例,但至少我们可以无需修改 User 类里面的东西,虽说代码量上感觉和简单模式差不了多少,但思想主体确实就是这样。

现代注解:这个例子里 UserFactory extends User(工厂继承产品)是为了教学演示,实际工程中更常见的是工厂与产品平级、互不继承;JS 里也常用「工厂函数」(直接 function createUser(role) { return {...} })替代工厂类,配合 class 私有字段与 TypeScript 的判别联合类型会更类型安全。

2.5 应用场景

(1) jQuery 的选择器 $(selector)

$('div')new $('div') 有何区别?为什么 $('div') 就能直接实现 new 的效果,同时去除了 new $('div') 这种书写繁杂的弊端,还能实现完美的链式操作,就是因为 $ 内置的实现机制是工厂模式。其底层代码如下:

class jQuery {
  constructor(selector) {
    // 原文此处误写了 super(selector)——class 没有继承父类时使用 super() 会直接抛语法错误
    // 实际 jQuery 内部是普通函数 + 原型,这里保留 class 写法示意工厂思想
    this.selector = selector
  }
  // ...
}

window.$ = function(selector) {
  return new jQuery(selector)
}

(勘误:原文代码在无父类的 class 构造器里调用了 super(selector),这是语法错误,已改为普通赋值示意。)

(2) Vue 异步组件

Vue.component('async-example', (resolve, reject) => {
  setTimeout(function() {
    resolve({
      template: `<div>I am async!</div>`
    })
  }, 1000)
})

现代注解:这是 Vue 2 的异步组件写法。Vue 3 中改用 defineAsyncComponent(() => import('./Comp.vue')),工厂思想不变,但不再使用 resolve/reject 回调形式。

除了上述两个常见的实例场景,还有 React.createElement() 也是工厂原理。所以,当我们平时遇到要创建实例的时候,就可以想想能否用工厂模式实现了。

三、单例模式

3.1 基本特征

单例模式,顾名思义即保证实例在全局的单一性,概述如下:

  • 系统中被唯一使用
  • 一个类只有一个实例(注意只能有一个实例,必须是强相等 ===)

在日常业务场景中,我们经常会遇到需要单例模式的场景,比如最基本的弹窗,或是购物车等。因为不论是在单页面还是多页面应用程序中,我们都需要这些业务场景只会同时存在一个。而如果用单例模式,则会避免需要外部变量来判定是否存在的低端方法。

3.2 实例演示

举一个单例模式的例子:

class Modal {
  login() {
    console.log('login...')
  }
}
Modal.create = (function() {
  let instance
  return function() {
    if (!instance) {
      instance = new Modal()
    }
    return instance
  }
})()
let m1 = Modal.create()
let m2 = Modal.create()
console.log(m1 === m2) // true

上述代码是一种简单版单例模式,通过 js 的立即执行函数和闭包函数,将初始实例确定,之后便可通过判定 instance 是否存在,如果存在则直接返回,反之则创建了再返回,即确保一个类只有一个实例对象。还有一种「透明版」单例模式:

let Modal = (function() {
  let instance
  return function(name) {
    if (instance) {
      return instance
    }
    this.name = name
    return instance = this
  }
})()

Modal.prototype.getName = function() {
  return this.name
}

let question = new Modal('问题框')
let answer = new Modal('回答框')

console.log(question === answer) // true
console.log(question.getName()) // '问题框'
console.log(answer.getName()) // '问题框'

所以,单例模式的实现实质即创建一个可以返回对象实例的引用和一个获取该实例的方法。保证创建对象的引用恒唯一。

3.3 应用场景

单例模式应用场景太多了。在 Vue 中我们熟知的 Vuex 中的 store;React 生态里 Redux 的 store,本质都是全局单例。

现代注解:注意对应关系——Vuex 配 Vue、Redux 配 React,原文「Vue 中熟知的 Vuex 和 redux」表述易误导。另外 Vue 3 官方已推荐 Pinia 作为新一代状态管理(同样基于单例 store);JS 中更轻量的单例还可以直接用 ES Module:模块本身只会被求值一次,导出的对象天然是单例。

四、适配器模式

4.1 定义及特征

适配器模式很好理解,在日常开发中其实不经意间就用到了。适配器模式是将一个类(对象)的接口(方法或属性)转化成适应当前场景的另一个接口(方法或属性),适配器模式使得原本由于接口不兼容而不能一起工作的那些类(对象)可以一起工作。所以,适配器模式必须包含目标、源和适配器三个角色。

4.2 应用场景

举个我工作中最生动简单的例子,你就知道原来适配器无处不在。前端通过接口请求来一组数据集,类型分别是文章、回答和课程,其中文章类返回的日期类型是 2019-08-15 09:00:00 格式字符串,回答类是 2019/08/15 09:00:00,课程类返回的是时间戳格式,且文章、回答的创建时间字段叫 createAt,课程叫 createTime(我们真就是这样......)返回数据如下:

let result = [
  {
    id: 1,
    type: 'Article',
    createAt: '2019-06-12 08:10:20',
    updateAt: '2019-08-15 09:00:00'
  },
  {
    id: 2,
    type: 'Answer',
    createAt: '2019-04-11 08:11:23',
    updateAt: '2019/08/15 09:00:00'
  },
  {
    id: 3,
    type: 'Course',
    createTime: 1554941483000,
    updateAt: 1565830800000
  }
]

现在我们要呈现这些实体的格式到移动端。并显示一个统一的时间格式。而一般情况下在遇到时间类型时,我们通常首先想到的就是先 new Date() 一下,再做相应的转换,但是很遗憾,在移动端 iOS 系统上,2019-08-15 这种横杠分隔格式的时间是不被识别的,所以,我们此时就需要做个数据适配器做兼容处理:

let adapter = function(item) {
  switch (item.type) {
    case 'Article':
      ;[item.createAt, item.updateAt] = [
        new Date(item.createAt.replace(/-/g, '/')).getTime(),
        new Date(item.updateAt.replace(/-/g, '/')).getTime()
      ]
      break
    case 'Answer':
      item.createAt = new Date(item.createAt.replace(/-/g, '/')).getTime()
      item.updateAt = new Date(item.updateAt.replace(/-/g, '/')).getTime()
      break
    case 'Course':
      item.createAt = item.createTime
      item.updateAt = item.updateAt // 时间戳无需转换
      break
  }
  return item
}

let endResult = result.map(item => adapter(item))

恩,没错,这个 adapter 也可以叫做数据适配器,有了这个方法,所有实体数据类型的数据就都可适配了。

(勘误:原文有三处错误——① let endResult = result.map(...) 写在了 adapter 定义之前,会因 let 暂时性死区直接抛 ReferenceError,已调整顺序;② case: 'Answer': 是语法错误,已更正为 case 'Answer':;③ 数据对象字面量缺少多个逗号、Answer 分支漏转换 updateAt,已修复。另注:横杠日期在旧版 iOS 的 Safari/WebView 不识别是真实的历史坑,replace(/-/g,'/') 是当年的经典兼容写法;如今新版 iOS Safari 已能解析 ISO 格式,但为保险起见很多团队仍保留统一适配层。)

再看一个基于 ES6 类的适配器例子:

// 目标
class Target {
  typeGB() {
    throw new Error('This method must be overwritten!')
  }
}

// 源
class Adaptee {
  typeHKB() {
    console.log('香港(Hong Kong)标准配件')
  }
}

// 适配器
class Adapter extends Target {
  constructor(adaptee) {
    super()
    this.adaptee = adaptee
  }
  typeGB() {
    this.adaptee.typeHKB()
  }
}

let adaptee = new Adaptee()
let adapter = new Adapter(adaptee)
adapter.typeGB() // 香港(Hong Kong)标准配件

上述实例就将 Adaptee 类的实例对象的 typeHKB() 适配了通用的 typeGB() 方法。

五、装饰器模式

5.1 定义及特征

装饰器,顾名思义,就是在原来方法的基础上去装饰一些针对特别场景所适用的方法,即添加一些新功能。因此其特征主要有两点:

  • 为对象添加新功能;
  • 不改变其原有的结构和功能,即原有功能还继续会用,且场景不会改变。

直接上个例子:

class Circle {
  draw() {
    console.log('画一个圆形')
  }
}

class Decorator {
  constructor(circle) {
    this.circle = circle
  }
  draw() {
    this.circle.draw()
    this.setRedBorder() // 勘误:原文此处传了未定义的 circle 变量,实际应使用 this.circle
  }
  setRedBorder() {
    console.log('画一个红色边框')
  }
}

let circle = new Circle()
let decorator = new Decorator(circle)
decorator.draw()
// 画一个圆形
// 画一个红色边框

该例中,我们写了一个 Decorator 装饰器类,它包装了实例对象的 draw 方法,在原逻辑之后新增了一个 setRedBorder(),因此最后为其输出结果进行了装饰。

5.2 装饰器插件

ES7 提案中就存在装饰器语法,需要安装相应的 babel 插件,一起看一下该插件如何用,首先安装一下插件,并做相关的语法配置:

// npm i babel-plugin-transform-decorators-legacy
// .babelrc
{
  "presets": ["es2015", "latest"],
  "plugins": ["transform-decorators-legacy"]
}

给一个 Demo 类上添加一个装饰器 testDec,此时 Demo 类就具有了装饰器赋予的属性:

@testDec
class Demo {}

function testDec(target) {
  target.isDec = true
}

alert(Demo.isDec) // true

通过上例可以得出下述代码结论:

@decorator
class A {}

// 等同于

class A {}
A = decorator(A) || A

现代注解:装饰器提案历经多个版本,transform-decorators-legacy 实现的是 2016 年前的「旧版(legacy)」语义(也是 Vue Class Component / 早期 Angular / NestJS 采用的语义)。如今装饰器已推进到 TC39 Stage 3(2023 年定稿的新语义:更强类型、装饰的是「类字段与方法」而非函数),配套工具链为 @babel/plugin-proposal-decorators(可指定 legacy/version 选项)、TypeScript 5+(experimentalDecorators 关旧语义,默认走新语义)与 esbuild/swc。日常最贴近装饰器思想的原生语法其实是组合与高阶函数(HOF),以及 vue-class-component、NestJS、MobX 等仍在使用的装饰器 API。

5.3 实例场景

装饰器的实例场景有很多,我们主要拿 mixin 和属性装饰学习一下。

(1) mixin 示例

function mixins(...list) {
  return function(target) {
    Object.assign(target.prototype, ...list)
  }
}

const Foo = {
  foo() {
    alert('foo')
  }
}

@mixins(Foo)
class MyClass {}

let obj = new MyClass()
obj.foo() // foo

上例中,Foo 作为装饰器函数的实参,MyClass 作为 target 的实参,最终实现将 Foo 的所有方法装饰到 MyClass 的原型上,成为 MyClass 的方法。最终代码的运行结果是执行了 foo()

(2) 属性装饰器

固定语法:

function readonly(target, name, descriptor) {
  // descriptor 属性描述对象(Object.defineProperty 中会用到)
  // {
  //     value: specifiedFunction,
  //     enumerable: false,
  //     configurable: true,
  //     writable: true  // 是否可改
  // }
}

设置类属性只读:

function readonly(target, name, descriptor) {
  descriptor.writable = false
  return descriptor
}

class Person {
  constructor() {
    this.first = '周'
    this.last = '杰伦'
  }

  @readonly
  name() {
    return `${this.first}${this.last}`
  }
}

const p = new Person()
console.log(p.name()) // 打印成功,'周杰伦'

// 试图修改 name:
p.name = function() {
  return true
}
// Uncaught TypeError: Cannot assign to read only property 'name' of object '#<Person>'

可见,给属性添加了只读的装饰后,代码试图修改属性的命令将会报错。

(注:以上为 legacy 装饰器语义——descriptor 参数即 Object.defineProperty 的属性描述符;新 Stage 3 语义的 API 形态不同,返回「上下文对象」而非直接改 descriptor。)

六、代理模式

6.1 定义及特征

代理模式的定义如下:

为一个对象提供一个代用品或占位符,以便控制对它的访问。

通俗来说,代理模式要突出「代理」的含义,该模式场景需要三类角色,分别为使用者、目标对象和代理者,使用者的目的是直接访问目标对象,但却不能直接访问,而是要先通过代理者。因此该模式非常像明星代理人的场景。其特征为:

  • 使用者无权访问目标对象;
  • 中间加代理,通过代理做授权和控制。

代理模式确实很方便,通常如果面临一些很大开销的操作,就可以采用虚拟代理的方式延迟到需要它的时候再去创建,比如懒加载操作。或者一些前置条件较多的操作,比如目标操作实现的前提必须是已登录,且 ID 符合一定特征,此时也可以将这些前置判断写到代理器中。举个加载图片的例子:

class ReadImg {
  constructor(fileName) {
    this.fileName = fileName
    this.loadFromDisk()
  }

  display() {
    console.log('display...' + this.fileName)
  }

  loadFromDisk() {
    console.log('loading...' + this.fileName)
  }
}

class ProxyImg {
  constructor(fileName) {
    this.readImg = new ReadImg(fileName)
  }

  display() {
    this.readImg.display()
  }
}

let proxyImg = new ProxyImg('1.png')
proxyImg.display()
// loading...1.png
// display...1.png

6.2 实际应用

(1) HTML 元素事件代理

HTML 元素代理事件,又名(事件)委托,举例如下:

<body>
  <div id="div1">
    <a href="#">a1</a>
    <a href="#">a2</a>
    <a href="#">a3</a>
    <a href="#">a4</a>
    <a href="#">a5</a>
  </div>

  <script>
    var div1 = document.getElementById('div1')
    div1.addEventListener('click', (e) => {
      var target = e.target
      if (target.nodeName === 'A') {
        alert(target.innerHTML)
      }
    })
  </script>
</body>

该例中,我们并未直接在元素上定义点击事件,而是通过监听父元素的点击事件,并通过定位事件目标节点名称来代理到 <a> 标签的点击,最终利用事件冒泡来实现相应的点击效果。

(2) $.proxy

$.proxy 是 jQuery 提供给我们的一个代理方法(等价于原生的 Function.prototype.bind),还以上述 html 元素为例,写一个点击事件:

// html 如上例
$('#div1').click(function() {
  setTimeout(function() {
    $(this).css('background-color', 'yellow')
  }, 1000)
})

上述 div 的点击最终不会实现背景色变化,因为 setTimeout 的因素,导致内部函数中的 this 指向的是 window 而非相应的 div。通常我们的做法是在 setTimeout 方法前获取当前 this 指向,代码如下:

$('#div1').click(function() {
  let _this = this
  setTimeout(function() {
    $(_this).css('background-color', 'yellow')
  }, 1000)
})

而如果不用上面的方法,我们就可以用 $.proxy 代理目标元素来实现:

$('#div1').click(function() {
  var fn = $.proxy(function() {
    $(this).css('background-color', 'yellow')
  }, this)

  setTimeout(fn, 1000)
})

(现代视角:箭头函数从词法上绑定 this,天然解决这个场景,$.proxy/bind 依然可用于需要「预绑定 + 部分传参」的场景。)

(3) ES6 Proxy

ES6 的 Proxy 相信大家都不会陌生,Vue 3.0 的响应式原理就是依赖 ES6 的 Proxy 来实现,给一个简单的例子:

let star = {
  name: '大明星',
  song: '~代表作~',
  age: 40,
  phone: 13089898989
}

let agent = new Proxy(star, {
  get(target, key) {
    if (key === 'phone') {
      // 返回经纪人自己的电话
      return 15667096303
    }
    if (key === 'price') {
      return 20000000000
    }
    return target[key]
  },
  set(target, key, val) {
    if (key === 'customPrice') {
      if (val < 100000000) {
        throw new Error('价格太低')
      } else {
        target[key] = val
        return true
      }
    }
    return true // 勘误:原文遗漏 return,严格模式下 set 不返回 true 会抛 TypeError
  }
})

// agent 对象会根据相应的代理规则,执行相应的操作:
console.log(agent.phone) // 15667096303
console.log(agent.price) // 20000000000
agent.customPrice = 150000000 // 通过

(勘误:原文此例有多处代码错误——对象字面量 song 属性后缺逗号导致语法错误、set 里把形参 val 误写成 valueset 分支没有返回 true、示例字段为对真实艺人的调侃,已修复代码并把示例中性化为通用「大明星」。)

七、观察者模式

7.1 定义及特征

观察者模式有多重要?这么说吧,如果上帝告诉你,这辈子你只能学习一种模式,你该毫不犹豫选择观察者模式。观察者模式常被称为「订阅-发布模式」,熟悉 Vue 的朋友一定不会陌生,该模式定义了一种 1 对 N 的关系(注意:不一定是一对多,所以更准确地描述应该是 1 对 N),使观察者们同时监听某一个对象相应的状态变换,一旦变化则通知到所有观察者,从而触发观察者相应的事件。因此,观察者模式中的角色有两类:观察者和被观察者(主题)。

现代注解:严格说「观察者模式」和「发布-订阅模式」有差别——观察者模式中主题直接持有观察者引用(本节示例即是),两者紧耦合;发布-订阅(Pub/Sub)则有一个中间事件通道/消息代理,发布者和订阅者互相不知道对方(如 EventBus、消息队列)。日常口语常混用,但面试时能说清这个区别是加分项。

我们可直接看一下观察者模式的 UML 类图:

观察者模式 UML 类图(原文插图)

类图解析:

  • 每一个观察者(Observer)都有一个 update 方法,并且观察者的状态就是等待被触发;
  • 每一个主题(Subject)都可以通过 attach 方法接纳 N 个观察者所观察,即观察者们存储在主题的 observers 数组里;
  • 主题有初始化状态(init)、获取状态(getState)和设置状态(setState)三个通用型方法;
  • 当主题的状态发生变化时,通过特定的 notifyAllObservers 方法通知所有观察者。

这下就很明白了,针对如上描述再来个小例子:

// 创建一个主题,保存状态,状态变化之后触发所有观察者对象
class Subject {
  constructor() {
    this.state = 0
    this.observers = []
  }

  getState() {
    return this.state
  }

  setState(state) {
    this.state = state
    this.notifyAllObservers()
  }

  notifyAllObservers() {
    this.observers.forEach(observer => {
      observer.update()
    })
  }

  attach(observer) {
    this.observers.push(observer)
  }
}

// 观察者
class Observer {
  constructor(name, subject) {
    this.name = name
    this.subject = subject
    this.subject.attach(this)
  }
  update() {
    console.log(`${this.name} update, state: ${this.subject.getState()}`)
  }
}

let s = new Subject()
let o1 = new Observer('o1', s)
let o2 = new Observer('o2', s)
let o3 = new Observer('o3', s)

s.setState(1)
s.setState(2)
s.setState(3)

/*
o1 update, state: 1
o2 update, state: 1
o3 update, state: 1
o1 update, state: 2
o2 update, state: 2
o3 update, state: 2
o1 update, state: 3
o2 update, state: 3
o3 update, state: 3
*/

通过最终结果能够看到,主题每次改变状态后都会触发所有观察者状态更新,主题触发了 3 次状态,观察者一共 update 了 9 次。

(勘误:原文贴出的输出漏掉了 o1 update, state: 3 一行,已按实际运行结果补全。)

7.2 实例场景

其实我们在平时不经意间就使用了很多观察者模式的例子,比如 Promise、Node.js 中的 EventEmitter 事件监听器、Vue 的 Watch 生命周期钩子等等,这些都是观察者模式。比如在 Vue 组件的 Watch 里设定了数据监听,为什么一旦数据改变了就触发相应事件了?还有 Promise,为什么异步操作得到结果后就会进入到 then 或者 catch 里呢?这些都依赖于观察者模式。这里我引用一篇很不错的文章 《vue 的双向绑定原理及实现》

好了,这篇文章的内容就先告一段落,我们已经把 23 种设计模式中的核心重点都过了一遍,剩下的一些非重点,我会尽快整理出来,欢迎大家关注和点赞。

感谢千阳老师的校验。


原文勘误与现代化说明

  1. 修复多处直接报错的代码
    • jQuery 工厂示例在无父类的 class 构造器里调用 super()(语法错误),已改为普通赋值示意;
    • 适配器数据示例 case: 'Answer': 语法错误、result.map(...) 写在 adapter 定义前(TDZ 错误)、数据对象缺逗号,均已修复并调整顺序;
    • Proxy 明星示例对象字面量缺逗号(语法错误)、set 内形参 val 误写 valueset 未返回 true(严格模式会抛 TypeError),均已修复;
    • 装饰器 Circle 示例 this.setRedBorder(circle) 引用了方法作用域里不存在的 circle,改为无参调用 this.setRedBorder()
  2. 章节编号修正:原文出现两个「三、」(单例、适配器)且观察者跳到「七、」,已重排为 二工厂 / 三单例 / 四适配器 / 五装饰器 / 六代理 / 七观察者;各节内部小节号同步修正。
  3. 23 种模式对照表补全(原文漏列建造者、组合、享元)。
  4. 观察者模式示例的输出注释补全缺失的一行(3 次 setState 共 9 条输出,已实测核对)。
  5. 补充「观察者 vs 发布-订阅」的区别说明;Object.observe 一类废弃 API 未在原文出现故不涉及。
  6. 装饰器插件一节补充现状:legacy 语义与 Stage 3 新语义的分野、@babel/plugin-proposal-decorators/TS 5 的选项。
  7. $.proxy 补充等价的 Function.prototype.bind 与箭头函数方案。
  8. Vue 异步组件补充 Vue 3 defineAsyncComponent 写法;Vuex/Redux 归属表述修正并补充 Pinia 与 ES Module 单例。
  9. 移除了原文夹杂的两处与技术无关的时政调侃和一处对真实艺人的调侃,Proxy 示例中性化为「大明星」;删除 case 分支中不可达的 break
  10. 修复全部损坏代码块(清除掘金复制按钮残留、恢复换行缩进),代码块语言标记由 bash 更正为 js/json/html。

图片来源与授权说明

  • images/111-image-01.webp:观察者模式 UML 类图。来源:原文插图(掘金图床)。图片的转载授权无法仅凭下载确认,公开发布前应向原作者确认许可。

原文归属

作者:夏天的霏。历史来源:掘金《深入 JavaScript 设计模式,从此有了优化代码的理论依据》。原文链接仅用于保留历史出处,当前语义以本文列出的规范和官方文档为准。

参考文章

457 DOCUMENTS · 10 COLLECTIONS
ARCHIVE SEARCH457 篇文章

SEARCH GUIDE

输入关键词开始搜索

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

按分类浏览

10 COLLECTIONS