发布-订阅者模式
虽然你可能还不熟悉 发布-订阅者 模式,但你肯定已经用过它了。因为 发布-订阅者 模式在前端领域可谓是无处不在。
为什么这么说呢,因为 EventTarget.addEventListener()
就是一个 发布-订阅者 模式。先卖个关子,看完本文你就能理解了。
定义
发布-订阅者模式其实是一种对象间 一对多 的依赖关系(利用消息队列)。当一个对象的状态(state
)发生改变时,所有依赖于它的对象都得到状态改变的通知。
订阅者(Subscriber
)把自己想订阅的事件注册(Subscribe
)到调度中心(Event Channel
),当发布者(Publisher
)发布该事件(Publish Event
)到调度中心,也就是该事件触发时,由调度中心统一调度(Fire Event
)订阅者注册到调度中心的处理代码。
特点
⭐ 通常情况下,我们都是先定义一个普通函数或者事件,然后再去调用。发布-订阅者 模式是为了让 发布者 和 订阅者 解耦。
⭐ 发布-订阅者 模式是一对多的关系,也就是说一个调度中心,对应多个订阅者。
⭐ 发布-订阅者 模式会有一个队列(Queue
),也就是先进先出。在 js
中,使用 Array
来模拟队列[fn1,fn2,fn3]
,先定义的先执行。
⭐ 先定义好一个消息队列,需要的对象去订阅。对象不再主动触发,而是被动接收。
一个例子理解
普通程序员张三去书店买书
张三:请问有红宝书吗?
店员:没有。
一小时后·····
张三:请问有红宝书吗?
店员:没有。
一小时后·····
张三:请问有红宝书吗?
店员:没有。
普通的程序员买书,需要频繁的调用对应的方法,这种轮询的方式无疑会增加负担。
那么一个发布订阅者模式的程序员怎样买书呢?
发布订阅者模式程序员李四去书店买书
李四:请问有红宝书吗?
店员:没有。
李四:我要订阅(
on
)这本书,当书有货的时候,请给我打电话(emit
),我就会过来买书(message
)。如果我在其它地方买到书了,请取消订阅(off
)。
在这个例子中,店员属于发布者,李四属于订阅者;李四将买书的事件注册到调度中心,店员作为发布者,当有新书发布时,店员发布该事件到调度中心,调度中心会及时发消息告知李四。
代码演示
发布-订阅者模式实现思路
????️♂️ 创建一个类。
????♀️ 在该类上创建一个缓存列表(调度中心)。
????♀️ 要有一个 on
方法来把函数 fn
都加到缓存列表中,也就是订阅者注册事件到调度中心。
????♀️ 要有一个 emit
方法取到 event
事件类型,根据 event
值去执行对应缓存列表中的函数,也就是发布者发布事件到调度中心,调度中心处理代码。
????♀️ 要有一个 off
方法,根据 event
事件类型取消订阅。
思路的具体实现
⭐ 分析构造函数
根据发布-订阅者模式的实现思路,这个类的结构应该是这样的。
/**
* + 属性:消息队列
* {
* 'click':[fn1,fn2,fn3],
* 'mouse':[fn1,fn2,fn3]
* }
* + 能向消息队列里面添加内容 $on
* + 能删除消息队列中的内容 $off
* + 触发消息队列里面的内容 $emit
*/
class Observer {
constructor() {
this.message = {}
};
$on() {};
$off() {};
$emit() {};
}
复制代码
⭐ 分析消息队列
参考下方代码,我们的消息队列是一个对象,键为要委托的内容,值为要进行的操作,可以进行多个操作,所以应该是一个存放函数的数组。
//消息A
const handlerA = () => {
console.log('????????~ handlerA');
}
//消息B
const handlerB = () => {
console.log('????????~ handlerB');
}
//消息C
const handlerC = () => {
console.log('????????~ handlerC');
}
//使用构造函数创建一个实例
const person1 = new Observer()
//向 person1 委托一些内容,帮我观察(订阅)
//当有红宝书的时候,执行消息A和消息B
person1.$on('红宝书', handlerA)
person1.$on('红宝书', handlerB)
//当有黄宝书的时候,执行消息B和消息C
person1.$on('黄宝书', handlerB)
person1.$on('黄宝书', handlerC)
复制代码
⭐ 分析 $on() 方法
class Observer {
//订阅者
$on(type, fn) {
// 先判断有没有这个属性
// 如果没有就初始化一个空的数组
if (!this.message[type]) {
this.message[type] = []
}
// 如果有就向数组的后面push一个fn(订阅)
this.message[type].push(fn)
};
}
person1.$on('红宝书', handlerA)
person1.$on('红宝书', handlerB)
复制代码
⭐ 分析 $off() 方法
$off()
可以取消订阅某个消息,也可以取消整个订阅消息队列。
class Observer {
constructor() {
this.message = {}
};
//取消订阅
$off(type, fn) {
//先判断有没有订阅
if (!this.message[type]) {
return
}
//判断有没有fn这个消息
if (!fn) {
//如果没有fn就删除整个消息队列
this.message[type] = undefined
return
}
//如果有fn就只是删除fn这个消息
this.message[type] = this.message[type].filter(item => item !== fn)
};
}
//整个消息队列都不需要进行托管了
person1.$off('红宝书')
//消息队列依然需要托管,只不过要删除handlerA这个消息
person1.$off('红宝书', handlerA)
复制代码
⭐ 分析 $emit() 方法
//发布者
$emit(type) {
//先判断有没有订阅
if (!this.message[type]) {
return
}
//循环执行消息(发布)
this.message[type].forEach((item) => {
item()
})
};
//发射事件
person1.$emit('红宝书')
复制代码
⭐ 完整代码
(() => {
class Observer {
constructor() {
// 消息队列
this.message = {}
};
$on(type, fn) {
// 先判断有没有这个属性
// 如果没有就初始化一个空的数组
if (!this.message[type]) {
this.message[type] = []
}
// 如果有就向数组的后面push一个fn
this.message[type].push(fn)
};
$off(type, fn) {
//先判断有没有订阅
if (!this.message[type]) {
return
}
//判断有没有fn这个消息
if (!fn) {
//如果没有fn就删除整个消息队列
this.message[type] = undefined
return
}
//如果有fn就只是删除fn这个消息
this.message[type] = this.message[type].filter(item => item !== fn)
};
$emit(type) {
//先判断有没有订阅
if (!this.message[type]) {
return
}
//循环执行消息
this.message[type].forEach((item) => {
item()
})
};
}
})()
复制代码
实际应用场景ToDoList
干说不练假把式,在我们初步了解了发布-订阅者模式之后,来写一个例子进行练习。
例子选择老生常谈的 ToDoList
。
分析结构
通常情况下,我们有一个 handleDom
来操作 Dom
;一个 handleData
来操作数据。
当我们在添加一个 todo
的时候,会声明一个 handlerFn
函数,在函数体中分别执行操作数据和操作 dom
的操作。
function handlerFn() {
//操作数据
handleData();
//操作dom
handleDom();
}
复制代码
或者在操作数据之后操作 dom
。
//操作数据
function handleData() {
//操作dom
handleDom();
}
复制代码
无论选择哪种方式,这两种方式都会使对数据和 dom
的操作产生耦合。下面我们利用发布订阅者模式来进行解藕。
发布-订阅者模式
我们需要三个文件,todoDom.ts
用来操作 dom
; todoEvent.ts
用来操作数据,可以通过下方的代码看到,二者没有任何的耦合。
todoList.ts
用来建立发布订阅者,通过它来做到数据和 dom
的连接。
todoDom.ts
import { ITodo } from './todoList';
class TodoDom {
private oTodoList: HTMLElement;
constructor(oTodoList: HTMLElement) {
this.oTodoList = oTodoList;
}
//生成实例,需要传入一个dom节点
public static create(oTodoList: HTMLElement) {
return new TodoDom(oTodoList);
}
//添加待办
public addItem(todo: ITodo): Promise<void> {
return new Promise((resolve, reject) => {
//生成节点
const oItem: HTMLElement = document.createElement('div');
oItem.className = 'todo-item';
oItem.innerHTML = this.todoView(todo);
this.oTodoList.appendChild(oItem);
resolve();
});
}
//移除待办
public removeItem(id: number): Promise<void> {
return new Promise((resolve, reject) => {
//获取待办列表
const oItems: HTMLCollection = document.getElementsByClassName('todo-item');
//根据id查找
Array.from(oItems).forEach((oItem) => {
const _id = Number.parseInt(oItem.querySelector('button').dataset.id);
//移除对应dom
if (_id === id) {
oItem.remove();
resolve();
}
});
reject();
});
}
//修改待办状态
public toggleItem(id: number): Promise<void> {
return new Promise((resolve, reject) => {
//获取待办列表
const oItems: HTMLCollection = document.getElementsByClassName('todo-item');
//根据id查找
Array.from(oItems).forEach((oItem) => {
const oCheckBox: HTMLInputElement = oItem.querySelector('input');
const _id = parseInt(oCheckBox.dataset.id);
//修改对应dom的状态
if (_id === id) {
const oContent: HTMLSpanElement = oItem.querySelector('span');
oContent.style.textDecoration = oCheckBox.checked ? 'line-through' : 'none';
resolve();
}
});
reject();
});
}
//插入节点
private todoView({ id, content, completed }: ITodo): string {
return `
<input type="checkbox" ${completed ? 'checked' : ''} data-id="${id}">
<span style="text-decoration:${completed ? 'line-through' : 'none'}">${content}</span>
<button data-id="${id}"></button>
`;
}
}
export default TodoDom;
复制代码
todoEvent.ts
import { ITodo } from './todoList';
class TodoEvent {
// 待办列表数组
private todoData: ITodo[] = [];
// 生成实例
public static create(): TodoEvent {
return new TodoEvent();
}
// 增加待办
public addTodo(todo: ITodo): Promise<ITodo> {
return new Promise((resolve, reject) => {
//查找待办
const _todo: ITodo = this.todoData.find((t) => t.content === todo.content);
//如果已经存在 返回失败内容
if (_todo) {
console.log('????????~ 该项已存在');
return reject(1001);
}
//否则添加一个待办
this.todoData.push(todo);
console.log('????????~ 添加成功:', this.todoData);
resolve(todo);
});
}
//删除待办
public removeTodo(id: number): Promise<number> {
return new Promise((resolve, reject) => {
//根据id筛选掉对应待办
this.todoData = this.todoData.filter((t) => t.id !== id);
resolve(id);
});
}
//切换待办状态
public toggleTodo(id: number): Promise<number> {
return new Promise((resolve, reject) => {
//遍历待办列表数组
this.todoData = this.todoData.map((t) => {
//找到对应id,修改状态
if (t.id === id) {
t.completed = !t.completed;
resolve(id);
}
return t;
});
});
}
}
export default TodoEvent;
复制代码
todoList.ts
export interface ITodo {
// id 唯一标识
id: number;
// 内容
content: string;
// 是否完成
completed: boolean;
}
class TodoList {
private oTodoList: HTMLElement;
//消息队列
private message: Object = {};
constructor(oTodoList: HTMLElement) {
this.oTodoList = oTodoList;
}
//初始化该观察者,暴露该方法,由于我们要操作dom,所以需要传入一个总dom参数
public static create(oTodoList: HTMLElement) {
return new TodoList(oTodoList);
}
public on(type: string, fn: Function) {
// 先判断有没有这个属性
// 如果没有就初始化一个空的数组
if (!this.message[type]) {
this.message[type] = [];
}
// 如果有就向数组的后面push一个fn
this.message[type].push(fn);
}
public off(type: string, fn: Function) {
//先判断有没有订阅
if (!this.message[type]) {
return;
}
//判断有没有fn这个消息
if (!fn) {
//如果没有fn就删除整个消息队列
this.message[type] = undefined;
return;
}
//如果有fn就只是删除fn这个消息
this.message[type] = this.message[type].filter((item: Function) => item !== fn);
}
public emit<T>(type: string, param: T) {
//表示执行的是第几个Promise
let i: number = 0;
//待执行的函数数组
let handlers: Function[];
//每次执行的都是一个单独的Promise
let res: Promise<unknown>;
handlers = this.message[type];
//如果这个数组长度不为0,才执行
if (handlers.length) {
//Promise.then的形式
res = handlers[i](param);
while (i < handlers.length - 1) {
i++;
res = res.then((param) => {
return handlers[i](param);
});
}
}
}
}
export default TodoList;
复制代码
index.js
当然还需要一个入口文件,让程序跑起来。
//index.js
import type { ITodo } from './src/todoList';
import TodoList from './src/todoList';
import TodoEvent from './src/todoEvent';
import TodoDom from './src/todoDom';
((document) => {
//获取对应节点
const oTodoList: HTMLElement = document.querySelector('.todo-list');
const oAddBtn: HTMLElement = document.querySelector('.add-btn');
const oInput: HTMLInputElement = document.querySelector('.todo-input input');
//创建三个类的实例
const todoList: TodoList = TodoList.create(oTodoList);
const todoEvent: TodoEvent = TodoEvent.create();
const todoDom: TodoDom = TodoDom.create(oTodoList);
const init = (): void => {
//订阅事件
todoList.on('add', todoEvent.addTodo.bind(todoEvent));
todoList.on('add', todoDom.addItem.bind(todoDom));
todoList.on('remove', todoEvent.removeTodo.bind(todoEvent));
todoList.on('remove', todoDom.removeItem.bind(todoDom));
todoList.on('toggle', todoEvent.toggleTodo.bind(todoEvent));
todoList.on('toggle', todoDom.toggleItem.bind(todoDom));
//绑定事件
bindEvent(todoList, oTodoList, oAddBtn, oInput);
//触发事件
};
const bindEvent = (todoList: TodoList, list: HTMLElement, btn: HTMLElement, input: HTMLInputElement) => {
//为添加按钮绑定一个点击事件
btn.addEventListener(
'click',
() => {
const val: string = input.value.trim();
if (!val.length) {
return;
}
todoList.emit<ITodo>('add', {
id: new Date().getTime(),
content: val,
completed: false,
});
input.value = '';
},
false
);
//为所有的checkbox添加一个切换状态事件
//为所有的删除按钮添加一个删除事件
list.addEventListener(
'click',
(e: MouseEvent) => {
const tar = e.target as HTMLLIElement;
const targetName = tar.tagName.toLowerCase();
if (targetName === 'input' || targetName === 'button') {
const id: number = parseInt(tar.dataset.id);
switch (targetName) {
case 'input':
todoList.emit<number>('toggle', id);
break;
case 'button':
todoList.emit<number>('remove', id);
break;
default:
break;
}
}
},
false
);
};
init();
})(document);
复制代码
到此,一个基于发布订阅者模式的 todoList
小案例就已经完成了。
那么现在你知道为什么 EventTarget.addEventListener()
就是一个发布订阅者模式了么?它和我们自己定义的 on
方法是不是特别像呢?