8 行为型-观察者模式
观察者模式定义了一种一对多的依赖关系,让多个观察者对象同时监听某一个目标对象,当这个目标对象的状态发生变化时,会通知所有观察者对象,使它们能够自动更新。 —— Graphic Design Patterns
观察者模式,是所有 JavaScript 设计模式中使用频率 最高,面试频率也最高 的设计模式,所以说它十分重要 ——如果我是面试官,考虑到面试时间有限、设计模式这块不能多问,我可能在考查你设计模式的时候只会问观察者模式这一个模式。该模式的权重极高
重点不一定是难点。观察者模式十分重要,但它并不抽象,理解难度不大。这种模式不仅在业务开发中遍地开花,在日常生活中也是非常常见的。为了帮助大家形成初步的理解,在进入代码世界之前,我们照例来看一段日常:
8.1 生活中的观察者模式
周一刚上班,前端开发李雷就被产品经理韩梅梅拉进了一个钉钉群——“员工管理系统需求第99次变更群”。这个群里不仅有李雷,还有后端开发 A,测试同学 B。三位技术同学看到这简单直白的群名便立刻做好了接受变更的准备、打算撸起袖子开始干了。此时韩梅梅却说:“别急,这个需求有问题,我需要和业务方再确认一下,大家先各忙各的吧”。这种情况下三位技术同学不必立刻投入工作,但他们都已经做好了本周需要做一个新需求 的准备,时刻等待着产品经理的号召。
一天过去了,两天过去了。周三下午,韩梅梅终于和业务方确认了所有的需求细节,于是在“员工管理系统需求第99次变更群”里大吼一声:“需求文档来了!”,随后甩出了"需求文档.zip"文件,同时@所有人。三位技术同学听到熟悉的“有人@我”提示音,立刻点开群进行群消息和群文件查收,随后根据群消息和群文件提供的需求信息,投入到了各自的开发里。上述这个过程,就是一个典型的观察者模式 。
重点角色对号入座
观察者模式有一个“别名”,叫发布 - 订阅模式(之所以别名加了引号,是因为两者之间存在着细微的差异,下个小节里我们会讲到这点)。这个别名非常形象地诠释了观察者模式里两个核心的角色要素——“发布者”与 “订阅者”。
在上述的过程中,需求文档(目标对象)的发布者只有一个——产品经理韩梅梅。而需求信息的接受者却有多个——前端、后端、测试同学,这些同学的共性就是他们需要根据需求信息开展自己后续的工作、因此都非常关心这个需求信息,于是不得不时刻关注着这个群的群消息提醒,他们是实打实的订阅者 ,即观察者对象。
现在我们再回过头来看一遍开头我们提到的略显抽象的定义:
观察者模式定义了一种一对多的依赖关系,让多个观察者对象同时监听某一个目标对象,当这个目标对象的状态发生变化时,会通知所有观察者对象,使它们能够自动更新。
在我们上文这个钉钉群里,一个需求信息对象对应了多个观察者(技术同学),当需求信息对象的状态发生变化 (从无到有)时,产品经理通知了群里的所有同学,以便这些同学接收信息进而开展工作:角色划分 --> 状态变化 --> 发布者通知到订阅者,这就是观察者模式的“套路”。
8.2 在实践中理解定义
结合我们上面的分析,现在大家知道,在观察者模式里,至少应该有两个关键角色是一定要出现的——发布者和订阅者 。用面向对象的方式表达的话,那就是要有两个类 。
首先我们来看这个代表发布者的类,我们给它起名叫Publisher。这个类应该具备哪些“基本技能”呢?大家回忆一下上文中的韩梅梅,韩梅梅的基本操作是什么?首先是拉群(增加订阅者),然后是@所有人(通知订阅者),这俩是最明显的了。此外作为群主&产品经理,韩梅梅还具有踢走项目组成员(移除订阅者)的能力。OK,产品经理发布者类的三个基本能力齐了,下面我们开始写代码:
// 定义发布者类
class Publisher {
constructor() {
this.observers = []
console.log('Publisher created')
}
// 增加订阅者
add(observer) {
console.log('Publisher.add invoked')
this.observers.push(observer)
}
// 移除订阅者
remove(observer) {
console.log('Publisher.remove invoked')
this.observers.forEach((item, i) => {
if (item === observer) {
this.observers.splice(i, 1)
}
})
}
// 通知所有订阅者
notify() {
console.log('Publisher.notify invoked')
this.observers.forEach((observer) => {
observer.update(this)
})
}
}
ok,搞定了发布者,我们一起来想想订阅者能干啥——其实订阅者的能力非常简单,作为被动的一方,它的行为只有两个——被通知、去执行(本质上是接受发布者的调用,这步我们在Publisher中已经做掉了)。既然我们在Publisher中做的是方法调用,那么我们在订阅者类里要做的就是方法的定义 :
// 定义订阅者类
class Observer {
constructor() {
console.log('Observer created')
}
update() {
console.log('Observer.update invoked')
}
}
以上,我们就完成了最基本的发布者和订阅者类的设计和编写。在实际的业务开发中,我们所有的定制化的发布者/订阅者逻辑都可以基于这两个基本类来改写。比如我们可以通过拓展发布者类,来使所有的订阅者来监听某个特定状态的变化 。
仍然以开篇的例子为例,我们让开发者们来监听需求文档(prd)的变化:
// 定义一个具体的需求文档(prd)发布类
class PrdPublisher extends Publisher {
constructor() {
super()
// 初始化需求文档
this.prdState = null
// 韩梅梅还没有拉群,开发群目前为空
this.observers = []
console.log('PrdPublisher created')
}
// 该方法用于获取当前的prdState
getState() {
console.log('PrdPublisher.getState invoked')
return this.prdState
}
// 该方法用于改变prdState的值
setState(state) {
console.log('PrdPublisher.setState invoked')
// prd的值发生改变
this.prdState = state
// 需求文档变更,立刻通知所有开发者
this.notify()
}
}
作为订阅方,开发者的任务也变得具体起来:接收需求文档、并开始干活:
class DeveloperObserver extends Observer {
constructor() {
super()
// 需求文档一开始还不存在,prd初始为空对象
this.prdState = {}
console.log('DeveloperObserver created')
}
// 重写一个具体的update方法
update(publisher) {
console.log('DeveloperObserver.update invoked')
// 更新需求文档
this.prdState = publisher.getState()
// 调用工作函数
this.work()
}
// work方法,一个专门搬砖的方法
work() {
// 获取需求文档
const prd = this.prdState
// 开始基于需求文档提供的信息搬砖。。。
...
console.log('996 begins...')
}
}
下面,我们可以 new 一个 PrdPublisher 对象(产品经理),她可以通过调用 setState 方法来更新需求文档。需求文档每次更新,都会紧接着调用 notify 方法来通知所有开发者:
目标对象的状态发生变化时,会通知所有观察者对象,使它们能够自动更新。
OK,下面我们来看看韩梅梅和她的小伙伴们是如何搞事情的吧:
// 创建订阅者:前端开发李雷
const liLei = new DeveloperObserver()
// 创建订阅者:服务端开发小A(sorry。。。起名字真的太难了)
const A = new DeveloperObserver()
// 创建订阅者:测试同学小B
const B = new DeveloperObserver()
// 韩梅梅出现了
const hanMeiMei = new PrdPublisher()
// 需求文档出现了
const prd = {
// 具体的需求内容
...
}
// 韩梅梅开始拉群
hanMeiMei.add(liLei)
hanMeiMei.add(A)
hanMeiMei.add(B)
// 韩梅梅发送了需求文档,并@了所有人
hanMeiMei.setState(prd)
以上,就是观察者模式在代码世界里的完整实现流程了。
相信走到这一步,大家对观察者模式的核心思想、基本实现模式都有了不错的掌握。下面我们趁热打铁,一起来看看如何凭借观察者模式,在面试中表演真正的技术~
8.3 Vue数据双向绑定(响应式系统)的实现原理

1. 解析
Vue 框架是热门的渐进式 JavaScript框架。在 Vue 中,当我们修改状态时,视图会随之更新,这就是Vue的数据双向绑定(又称响应式原理)。数据双向绑定是Vue 最独特的特性之一。如果读者没有接触过 Vue,强烈建议阅读Vue官方对响应式原理的介绍 (opens new window)。此处我们用官方的一张流程图来简要地说明一下Vue响应式系统的整个流程:

在 Vue 中,每个组件实例都有相应的 watcher 实例对象,它会在组件渲染的过程中把属性记录为依赖,之后当依赖项的 setter 被调用时,会通知 watcher 重新计算,从而致使它关联的组件得以更新——这是一个典型的观察者模式。这道面试题考察了受试者对Vue底层原理的理解、对观察者模式的实现能力以及一系列重要的JS知识点,具有较强的综合性和代表性。
值得注意的是,在面试过程中,面试官多数情况下不会要求大家写出完整的响应式原理实现代码,而是要求你“说说自己的理解”。在本节,我们不会带大家一行一行写代码(具体深入Vue框架的相关知识,建议大家阅读Vue源码 (opens new window)及这本专门写Vue的小册 (opens new window)。),而是针对Vue响应式系统中与观察者模式紧密关联的这部分知识作讲解,帮助大家捋清楚整套流程里的来龙去脉、加深对观察者模式的理解。
在Vue数据双向绑定的实现逻辑里,有这样三个关键角色:
observer(监听器):注意,此observer非彼observer。在我们上面的解析中,observer作为设计模式中的一个角色,代表“订阅者”。但在Vue数据双向绑定的角色结构里,所谓的observer不仅是一个数据监听器,它还需要对监听到的数据进行转发 ——也就是说它同时还是一个发布者 。watcher(订阅者):observer把数据转发给了真正的订阅者 ——watcher对象。watcher接收到新的数据后,会去更新视图。compile(编译器):MVVM框架特有的角色,负责对每个节点元素指令进行扫描和解析,指令的数据初始化、订阅者的创建这些“杂活”也归它管~
这三者的配合过程如图所示:

OK,实现方案搞清楚了,下面我们给整个流程中涉及到发布-订阅这一模式的代码 来个特写:
2. 核心代码
实现observer
首先我们需要实现一个方法,这个方法会对需要监听的数据对象进行遍历、给它的属性加上定制的
getter和setter函数。这样但凡这个对象的某个属性发生了改变,就会触发setter函数,进而通知到订阅者。这个setter函数,就是我们的监听器:
// observe方法遍历并包装对象属性
function observe(target) {
// 若target是一个对象,则遍历它
if(target && typeof target === 'object') {
Object.keys(target).forEach((key)=> {
// defineReactive方法会给目标属性装上“监听器”
defineReactive(target, key, target[key])
})
}
}
// 定义defineReactive方法
function defineReactive(target, key, val) {
// 属性值也可能是object类型,这种情况下需要调用observe进行递归遍历
observe(val)
// 为当前属性安装监听器
Object.defineProperty(target, key, {
// 可枚举
enumerable: true,
// 不可配置
configurable: false,
get: function () {
return val;
},
// 监听器函数
set: function (value) {
console.log(`${target}属性的${key}属性从${val}值变成了${value}`)
val = value
}
});
}
下面实现订阅者
Dep:
// 定义订阅者类Dep
class Dep {
constructor() {
// 初始化订阅队列
this.subs = []
}
// 增加订阅者
addSub(sub) {
this.subs.push(sub)
}
// 通知订阅者(是不是所有的代码都似曾相识?)
notify() {
this.subs.forEach((sub)=>{
sub.update()
})
}
}
现在我们可以改写
defineReactive中的setter方法,在监听器里去通知订阅者了:
function defineReactive(target, key, val) {
const dep = new Dep()
// 监听当前属性
observe(val)
Object.defineProperty(target, key, {
set: (value) => {
// 通知所有订阅者
dep.notify()
}
})
}
补充(现代做法):本节的响应式实现基于 Vue 2 的
Object.defineProperty,它有两个已知局限——无法侦测对象属性的「新增/删除」(需Vue.set/Vue.delete)和数组下标/length的直接修改。Vue 3 改用 ES6Proxy重写了响应式系统(reactive/ref/effect),天然支持新增属性与数组索引,并做了惰性递归 + 依赖收集优化。设计模式的内核仍是观察者:effect(订阅者) 在读取时被track收集为依赖,写入时trigger通知重跑。
补充(现代做法):Vue 3 移除了实例上的
$on/$emit/$off,官方不再推荐用根实例当 Event Bus。组件通信改用 props/emit、provide/inject或 Pinia;确需全局事件总线时,自己写一个EventEmitter(即下一节的实现)或引入mitt、tiny-emitter等轻量库。
8.4 实现一个Event Bus / Event Emitter
Event Bus(Vue、Flutter 等前端框架中有出镜)和Event Emitter(Node中有出镜)出场的“剧组”不同,但是它们都对应一个共同的角色——全局事件总线 。
全局事件总线,严格来说不能说是观察者模式,而是发布-订阅模式。它在我们日常的业务开发中应用非常广。
如果只能选一道题,那这道题一定是
Event Bus/Event Emitter的代码实现——我都说这么清楚了,这个知识点到底要不要掌握、需要掌握到什么程度,就看各位自己的了。
在Vue中使用Event Bus来实现组件间的通讯
Event Bus/Event Emitter作为全局事件总线,它起到的是一个沟通桥梁 的作用。我们可以把它理解为一个事件中心,我们所有事件的订阅/发布都不能由订阅方和发布方“私下沟通”,必须要委托这个事件中心帮我们实现。
在Vue中,有时候 A 组件和 B 组件中间隔了很远,看似没什么关系,但我们希望它们之间能够通信。这种情况下除了求助于 Vuex 之外,我们还可以通过 Event Bus 来实现我们的需求。
创建一个 Event Bus(本质上也是 Vue 实例)并导出:
const EventBus = new Vue()
export default EventBus
在主文件里引入EventBus,并挂载到全局:
import bus from 'EventBus的文件路径'
Vue.prototype.bus = bus
订阅事件:
// 这里func指someEvent这个事件的监听函数
this.bus.$on('someEvent', func)
发布(触发)事件:
// 这里params指someEvent这个事件被触发时回调函数接收的入参
this.bus.$emit('someEvent', params)
大家会发现,整个调用过程中,没有出现具体的发布者和订阅者(比如上面的
PrdPublisher和DeveloperObserver),全程只有bus这个东西一个人在疯狂刷存在感。这就是全局事件总线的特点——所有事件的发布/订阅操作,必须经由事件中心,禁止一切“私下交易”!
下面,我们就一起来实现一个Event Bus(注意看注释里的解析):
class EventEmitter {
constructor() {
// handlers是一个map,用于存储事件与回调之间的对应关系
this.handlers = {}
}
// on方法用于安装事件监听器,它接受目标事件名和回调函数作为参数
on(eventName, cb) {
// 先检查一下目标事件名有没有对应的监听函数队列
if (!this.handlers[eventName]) {
// 如果没有,那么首先初始化一个监听函数队列
this.handlers[eventName] = []
}
// 把回调函数推入目标事件的监听函数队列里去
this.handlers[eventName].push(cb)
}
// emit方法用于触发目标事件,它接受事件名和监听函数入参作为参数
emit(eventName, ...args) {
// 检查目标事件是否有监听函数队列
if (this.handlers[eventName]) {
// 如果有,则逐个调用队列里的回调函数
this.handlers[eventName].forEach((callback) => {
callback(...args)
})
}
}
// 移除某个事件回调队列里的指定回调函数
off(eventName, cb) {
const callbacks = this.handlers[eventName]
const index = callbacks.indexOf(cb)
if (index !== -1) {
callbacks.splice(index, 1)
}
}
// 为事件注册单次监听器
once(eventName, cb) {
// 对回调函数进行包装,使其执行完毕自动被移除
const wrapper = (...args) => {
cb.apply(this, args)
this.off(eventName, wrapper)
}
this.on(eventName, wrapper)
}
}
在日常的开发中,大家用到
EventBus/EventEmitter往往提供比这五个方法多的多的多的方法。但在面试过程中,如果大家能够完整地实现出这五个方法,已经非常可以说明问题了,因此楼上这个EventBus希望大家可以熟练掌握。学有余力的同学,推荐阅读FaceBook推出的通用EventEmiiter库的源码 (opens new window),相信你会有更多收获。
8.5 观察者模式与发布-订阅模式的区别是什么?

在面试过程中,一些对细节比较在意的面试官可能会追问观察者模式与发布- 订阅模式的区别。这个问题可能会引发一些同学的不适,因为在大量参考资料以及已出版的纸质书籍中,都会告诉大家“发布- 订阅模式和观察者模式是同一个东西的两个名字”。本书在前文的叙述中,也没有突出强调两者的区别。其实这两个模式,要较起真来,确实不能给它们划严格的等号。
为什么大家都喜欢给它们强行划等号呢?这是因为就算划了等号,也不影响我们正常使用,毕竟两者在核心思想、运作机制上没有本质的差别。但考虑到这个问题确实可以成为面试题的一个方向,此处我们还是单独拿出来讲一下。
回到我们上文的例子里。韩梅梅把所有的开发者拉了一个群,直接把需求文档丢给每一位群成员,这种发布者直接触及到订阅者 的操作,叫观察者模式。但如果韩梅梅没有拉群,而是把需求文档上传到了公司统一的需求平台上,需求平台感知到文件的变化、自动通知了每一位订阅了该文件的开发者,这种发布者不直接触及到订阅者、而是由统一的第三方来完成实际的通信的操作,叫做发布- 订阅模式 。
相信大家也已经看出来了,观察者模式和发布-订阅模式之间的区别,在于是否存在第三方、发布者能否直接感知订阅者(如图所示)。

观察者模式

发布-订阅模式
在我们见过的这些例子里,韩梅梅拉钉钉群的操作,就是典型的
观察者模式;而通过EventBus去实现事件监听/发布,则属于发布-订阅模式。
既生瑜,何生亮?既然有了观察者模式,为什么还需要发布-订阅模式呢?
大家思考一下:为什么要有观察者模式?
观察者模式,解决的其实是模块间的耦合问题,有它在,即便是两个分离的、毫不相关的模块,也可以实现数据通信。但观察者模式仅仅是减少了耦合,并没有完全地解决耦合问题 ——被观察者必须去维护一套观察者的集合,这些观察者必须实现统一的方法供被观察者调用,两者之间还是有着说不清、道不明的关系。
而发布- 订阅模式,则是快刀斩乱麻了——发布者完全不用感知订阅者,不用关心它怎么实现回调方法,事件的注册和触发都发生在独立于双方的第三方平台(事件总线)上。发布- 订阅模式下,实现了完全地解耦。
但这并不意味着,
发布-订阅模式就比观察者模式“高级”。在实际开发中,我们的模块解耦诉求并非总是需要它们完全解耦 。如果两个模块之间本身存在关联,且这种关联是稳定的、必要的,那么我们使用观察者模式就足够了。而在模块与模块之间独立性较强、且没有必要单纯为了数据通信而强行为两者制造依赖的情况下,我们往往会倾向于使用发布- 订阅模式。
