【设计模式】我这样学习设计模式-发布订阅者模式

发布-订阅者模式

虽然你可能还不熟悉 发布-订阅者 模式,但你肯定已经用过它了。因为 发布-订阅者 模式在前端领域可谓是无处不在。

为什么这么说呢,因为 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 用来操作 domtodoEvent.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 小案例就已经完成了。

61.gif

那么现在你知道为什么 EventTarget.addEventListener() 就是一个发布订阅者模式了么?它和我们自己定义的 on 方法是不是特别像呢?

参考

web前端不可不掌握的核心设计模式:发布订阅者模式(附实战)

小伙伴们觉的对你有帮助的请点赞👍👍支持一下,感觉写的不错的请关注一下专栏

👉👉适合前端人员的设计模式

  • 3
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值