观察者模式(行为型)
观察者模式 —— 当产品经理拉了一个钉钉群...
观察者模式定义了一种一对多的依赖关系,让多个观察者对象同时监听某一个目标对象,当这个目标对象的状态发生变化时,会通知所有观察者对象,使它们能够自动更新。
一、生活中的观察者模式
有一天,产品经理 KkOma 把 Tim 拉进了一个钉钉群(员工管理系统需求第 n 次变更群)。这个群中不仅有 Tim,还有后端同学 A,测试同学 B。三位同学刚准备开始干活时,KkOma 却说:“别急,这个需求有点问题需要和业务方再确认一下,大家先各忙各的”。
一天过去了,KkOma 终于确认了需求细节,于是在群里@所有人。三位同学收到消息后,立刻投入到了各自的工作中。上述这个过程,就是一个典型的观察者模式。
重点角色对号入座:
观察者模式有一个别名,叫发布-订阅模式
,这个别名形象地诠释了观察者模式里两个核心的角色要素——发布者
与订阅者
。
上面的过程中,需求文档(目标对象)的发布者只有一个 —— KkOma。而需求信息的接受者却有多个 —— Tim、A、B 同学,这些同学的共性就是他们需要根据需求信息开展自己的工作,因此需要时刻关注着这个群的消息,他们是实打实的订阅者,即观察者对象。
二、在实践中理解定义
结合上面的分析,可以知道,在观察者模式中至少有两个关键角色 —— 发布者和订阅者。用面向对象的话说,就是要有两个类。
首先来看这个发布者的类 Publisher
。这个类应该具备以下的基本技能:
- 拉群(增加订阅者)
- @所有人(通知订阅者)
- 移除项目组成员(移除订阅者)
// 定义发布者类
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);
});
}
}
接下来看订阅者类,作为被动的一方,它的行为只有两个:
- 被通知
- 去执行
既然在 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;
// KkOma 还没有拉群,开发群目前为空
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
方法来通知所有开发者。
// 创建订阅者:前端同学 Tim
const liLei = new DeveloperObserver();
// 创建订阅者:后端同学 A
const A = new DeveloperObserver();
// 创建订阅者:测试同学 B
const B = new DeveloperObserver();
// KkOma 出现了
const hanMeiMei = new PrdPublisher();
// 需求文档出现了
const prd = {
// 具体的需求内容
// ...
};
// KkOma 开始拉群
hanMeiMei.add(liLei);
hanMeiMei.add(A);
hanMeiMei.add(B);
// KkOma 发送了需求文档,并 @ 了所有人
hanMeiMei.setState(prd);
三、Vue 双向绑定的原理
Vue 响应式系统的整个流程如下:
在 Vue 中,每个组件实例都有相应的 watcher 实例对象,它会在组件渲染的过程中把属性记录为依赖,之后当依赖项的 setter
被调用时,会通知 watcher 重新计算,从而致使它关联的组件得以更新 —— 这是一个典型的观察者模式。
在 Vue 双向绑定的实现逻辑中,有这样三个关键角色:
observer
(监听器):注意此observer
非彼observer
。上面的observer
作为设计模式中的一个角色,代表“订阅者”。而 Vue 数据双向绑定的角色结构中,observer
不仅是个数据监听器,还需要对监听到的数据进行转发 —— 也就是说它同时还是个发布者。- watcher(订阅者):
observer
把数据转发给了真正的订阅者 —— watcher 对象。watcher 接收到新的数据后,会去更新视图。 - compile(编译器):MVVM 框架特有的角色,负责对每个节点元素指令进行扫描和解析,指令的数据初始化、订阅者的创建这些“杂活”也归它管~
这三者的配合过程如图所示:
1、核心代码解析
1-1、实现 observer
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;
},
});
}
1-2、实现订阅者 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();
},
});
}
2、Event Bus 与 Event Emitter
Event Bus(Vue、Flutter 等前端框架中有出镜)和 Event Emitter(Node 中有出镜)出场的“剧组”不同,但都对应一个共同的角色 —— 全局事件总线
全局事件总线,严格来说不能说是观察者模式,而是发布-订阅模式,在日常的业务开发中应用非常广。
在 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(...args);
this.off(eventName, wrapper);
};
this.on(eventName, wrapper);
}
}
点击查看 FaceBook 推出的通用 EventEmiiter 库源码
四、观察者模式 VS 发布订阅模式
以上面 KkOma 拉群为例,KkOma 拉群后把需求文档丢给群成员,这种发布者直接触及到订阅者的操作,叫观察者模式。如果 KkOma 没有拉群,而是把需求文档上传到 TAPD 上,TAPD 再通知了每位订阅了该文件的开发者,这种发布者不直接触及到订阅者,由统一的第三方来完成实际的通信的操作,叫做发布-订阅模式。
上面 KkOma 拉群的操作,就是典型的观察者模式;而通过 EventBus 去实现事件监听/发布,则属于发布-订阅模式。
因此,观察者模式和发布-订阅模式之间的区别,在于是否存在第三方、发布者能否直接感知订阅者。