从入门到手写发布订阅,这一篇就够了

引言

在软件开发中,设计模式是一种被广泛应用的解决方案,它可以帮助开发人员更好地组织和设计他们的代码。其中,发布订阅模式(Publish-Subscribe Pattern)作为一种常见的设计模式,被广泛应用于事件处理、消息传递等领域。通过发布订阅模式,对象之间可以建立一种松散的耦合关系,当一个对象的状态发生变化时,所有依赖于它的对象都能够得到及时通知。本文将深入探讨发布订阅模式的原理、优点、应用场景以及与观察者模式的区别,希望能够为读者带来对这一设计模式的全面了解和应用指导。

正文

什么是发布订阅

简单来讲,发布订阅模式就是分两步,一个发布,一个订阅,我们来看看下面这段代码来帮助我们理解一下吧:

<div class="box"></div>

<script>
// 创建一个支持冒泡且不能取消的look事件
let ev = new Event('look',{bubbles: true, cancelable: false});

let box = document.getElementById('box')
box.addEventListener('look',() => {
    conslog("在box上触发了look事件")
}

box.dispatchEvent(ev);  // 在box上发布look事件
</script>

这段代码通过创建一个自定义事件’look’,并在一个<div>元素上触发该事件。从发布订阅模式的角度来分析这段代码,我们可以看到以下几点:

  1. 发布者(Publisher) :在这段代码中,box元素可以被视为发布者,它通过dispatchEvent方法发布了一个自定义事件’look’。(为什么要自定义事件呢?因为发布自定义事件的主要目的在于实现模块之间的解耦和通信,而一些复杂的模块之间的通信单靠自带的事件是不够的,所以这个时候就要靠我们自定义事件了。)
  2. 订阅者(Subscriber) :通过addEventListener方法,box元素订阅了’look’事件,并在事件触发时执行相应的回调函数。
  3. 消息通道:在这里,事件’look’充当了消息通道的角色,将事件传递给所有订阅了该事件的元素。
  4. 事件传播:通过设置bubbles: true,事件’look’支持冒泡,可以在父容器上继续传播。如果取消注释window.addEventListener('look', ...)这段代码,可以看到事件’look’在box元素上触发后会冒泡到window上。
  5. 松耦合性:发布订阅模式通过事件机制实现了发布者和订阅者之间的松耦合关系,发布者无需知道订阅者的具体实现,只需发布事件即可。

总的来说,这段代码展示了如何使用发布订阅模式来实现事件的发布和订阅,从而实现对象之间的解耦和灵活性,这样可能不太好理解,那我把他用一个例子说明一下:

假设有一个房地产公司,他们通过在公众号上发布最新的房产信息。客户可以通过购买房产来获取房产信息。这个场景可以用来解释发布订阅模式。

  1. 发布者(Publisher) :房地产公司是发布者,他们在公众号上发布最新的房产信息。公众号就是发布者,负责发布房产信息。
  2. 订阅者(Subscriber) :客户是订阅者,他们购买了房产后订阅了房地产公司的公众号。订阅了公众号的客户就是订阅者,他们希望获取最新的房产信息。
  3. 消息通道:公众号上发布的房产信息就是消息通道,通过这个通道,房地产公司可以将最新的信息传递给订阅者(客户)。
  4. 事件传播:房地产公司发布新的房产信息后,公众号会将这些信息传播给所有订阅了公众号的客户。客户可以及时了解到最新的房产信息。
  5. 松耦合性:通过购买房产并订阅公众号的方式,客户和房地产公司之间实现了松耦合,客户不需要直接联系房地产公司来获取信息,而是通过公众号作为中介来获取信息。

在这个例子中,发布订阅模式的应用使得房地产公司和客户之间的通信更加灵活和解耦。房地产公司可以随时发布最新的房产信息,而客户可以通过订阅公众号来获取这些信息,实现了信息的传递和交流。

需要注意的参数

我们要来讲讲Event里接收的三个参数:

  1. bubblesbubbles 是一个布尔值,表示该事件是否会冒泡。如果 bubbles 为 true,则事件会从触发的元素开始冒泡到DOM树的根节点;如果 bubbles 为 false,则事件不会冒泡,只会在触发的元素上触发。
  2. cancelablecancelable 也是一个布尔值,表示该事件是否可被取消。如果 cancelable 为 true,则事件可以被取消(通过调用 event.preventDefault() 方法取消事件的默认行为);如果 cancelable 为 false,则事件不可被取消。
  3. composedcomposed 同样是一个布尔值,表示该事件是否会穿越 Shadow DOM 的边界。如果 composed 为 true,则事件会穿越 Shadow DOM 的边界,即事件会从 Shadow DOM 中的元素冒泡到外部的 DOM 结构;如果 composed 为 false,则事件不会穿越 Shadow DOM 的边界,只在 Shadow DOM 内部传播。

那我们又要知道什么是影子DOM了,直接来理解:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
  <style>
    .title{
      color: red;
      font-size: 26px;
    }
    body{
      --color: green
    }
  </style>
</head>
<body>
  <div>
    <div class="title">我是真实的标题</div>
  </div>
  <div id="root"></div>

  <script>
    let root = document.getElementById("root");
    let rootShadow = root.attachShadow({ mode: 'closed', delegatesFocus: false });
    rootShadow.innerHTML = `
      <input />
      <div class="title shadow">我是影子DOM提供的标题</div>
      <style>
        :host{
          color: var(--color)
        }
      </style>
    `
  
    console.log(root.shadowRoot);
  </script>
</body>
</html>

Shadow DOM(阴影 DOM)是 Web 标准中的一项技术,用于创建封装和隔离的 DOM 子树。通过 Shadow DOM 技术,开发人员可以将 DOM 结构和样式封装在一个独立的作用域内,避免与全局样式和脚本发生冲突,同时提高组件化和可维护性。

具体来说,Shadow DOM 允许开发人员创建一个独立的 DOM 子树,这个子树可以包含 HTML 元素、CSS 样式和 JavaScript 代码。这个子树被称为 Shadow DOM,它与外部的 DOM 结构相互隔离,内部的样式和脚本不会影响外部的页面,反之亦然。

在这段代码中,我们创建了一个包含影子DOM的自定义元素,并在影子DOM中定义了一个输入框和一个具有类名 .title 的 <div> 元素。下面是对代码的影子DOM分析:

  1. 在影子DOM中,我们使用 root.attachShadow({ mode: 'closed', delegatesFocus: false }) 方法将 <div id="root"> 元素设置为影子DOM宿主,并且设置了 mode: 'closed',表示影子DOM是封闭的,外部无法访问影子DOM。
  2. 在影子DOM中,我们使用 rootShadow.innerHTML 将输入框和具有类名 .title 的 <div> 元素添加到影子DOM中。这些元素只在影子DOM中存在,不会影响外部DOM结构。
  3. 在影子DOM中,我们使用 :host 伪类来定义影子DOM宿主元素的样式。在这里,我们设置了宿主元素的文本颜色为 var(--color),其中 --color 是在全局样式中定义的变量,值为 green
  4. 最后,通过 console.log(root.shadowRoot) 打印出宿主元素的影子DOM,以便查看影子DOM中的内容。

总结一下,这段代码展示了如何创建一个包含影子DOM的自定义元素,并在影子DOM中定义元素和样式。影子DOM的特性包括封闭性、样式隔离和作用域限制,可以帮助我们更好地组织和管理页面结构和样式。

发布订阅处理异步

看到这个标题肯定会有同学要问了,什么!发布订阅还可以处理异步,当然可以,只不过我们平常不用罢了,我们来看看这段代码怎么用发布订阅解决:

<script>
    function fnA() {
        setTimeout(() => {
            console.log('请求A完成');
            window.dispatchEvent(finish)
        }, 1000);
    }
    function fnB() {
        setTimeout(() => {
            console.log('请求B完成');
        }, 500);
    }
</script>

这要怎么完成A再完成B呢?用发布订阅的模式来思考的话,只要A执行完之后派发一个事件,B在这个事件派发完成之后再执行不就行了吗,我们来看代码:

<script>
    let finish = new CustomEvent('finish', {bubbles: true, cancelable: false})

    function fnA() {
        setTimeout(() => {
            console.log('请求A完成');
            window.dispatchEvent(finish)
        }, 1000);
    }
    function fnB() {
        setTimeout(() => {
            console.log('请求B完成');
        }, 500);
    }

    fnA()
    window.addEventListener('finish', () => {
        fnB()
    })
    
</script>

通过在 fnA 执行完成后派发一个自定义事件 finish,然后在这个事件派发完成后执行 fnB,实现了一种基本的发布订阅模式。

具体来说,代码的执行流程如下:

  1. fnA 函数在 setTimeout 中模拟了一个异步请求,1秒后请求完成,并打印 ‘请求A完成’,然后派发了一个名为 finish 的自定义事件到 window 对象上。
  2. fnB 函数也在 setTimeout 中模拟了一个异步请求,500毫秒后请求完成,并打印 ‘请求B完成’。
  3. 立即执行 fnA(),开始执行请求A。
  4. 使用 window.addEventListener 监听 finish 事件,当 finish 事件被派发时,执行 fnB(),开始执行请求B。

这种模式确保了在请求A完成后再执行请求B,通过事件的发布和订阅机制,实现了异步操作的协同处理。这种方式可以帮助代码更好地组织和管理异步操作,保证它们在正确的时机被触发和执行。

手写发布订阅

在手写发布订阅之前,我们得先了解class的语法,我先来给大家普及一下class语法:

class Point {
  constructor(x, y) { // constructor就相当于把类变成了构造函数
    this.x = x;
    this.y = y;
  }
  get toString() { // 相当于在一个对象里定义了一个方法
    return `(${this.x},${this.y})`
  }
  static foo() { // 静态方法
    return 'foo';
  }
}
let p = new Point(1, 2)

console.log(p.toString);

这段代码定义了一个名为 Point 的类,其中包含构造函数、实例方法和静态方法。

  1. 构造函数:构造函数通过 constructor 方法定义,用于初始化类的实例。在这里,构造函数接受两个参数 x 和 y,并将它们分别赋值给实例的 x 和 y 属性。
  2. 实例方法:在类中使用 get 关键字定义了一个名为 toString 的实例方法。这里需要注意的是,使用 get 关键字定义的方法会被当作属性访问器,而不是普通方法。在这个方法中,返回了一个包含当前实例 x 和 y 属性的字符串。
  3. 静态方法:使用 static 关键字定义了一个静态方法 foo。静态方法属于类本身,而不是类的实例,因此可以直接通过类名调用,而不需要创建类的实例。
  4. 创建实例:通过 new Point(1, 2) 创建了一个 Point 类的实例,并将其赋值给变量 p
  5. 调用实例方法:通过 console.log(p.toString) 调用了实例方法 toString。由于 toString 是一个属性访问器,所以在调用时不需要加括号,类似于访问属性的方式。这将输出实例的坐标信息,即 (1,2)

总结:这段代码展示了如何使用ES6中的class语法来定义类、构造函数、实例方法和静态方法。通过这种方式,可以更清晰地组织和管理代码,使代码结构更加直观和易于理解。

现在进入正题,如果面试官给你一下代码,让你手写一个发布订阅,你该怎么写:

class EventEmitter {
    constructor() {
      
    }
    on() {

    }
    once() {

    }
    emit() {

    }
    off() {

  }

下面是我们的思路:
发布订阅模式通常包括以下几个核心方法:

  1. 订阅事件(Subscribe) :用户可以通过 on 方法订阅特定事件,当事件被触发时,订阅者会收到通知。
  2. 一次性订阅事件(Once) :用户可以通过 once 方法一次性订阅特定事件,当事件被触发时,订阅者只会收到一次通知。
  3. 发布事件(Emit) :用户可以通过 emit 方法触发特定事件,通知所有订阅该事件的订阅者。
  4. 取消订阅事件(Unsubscribe) :用户可以通过 off 方法取消订阅特定事件。

然后根据这几点进行补充,我直接把代码放出来讲解:

class EventEmitter {
    constructor() {
      this.event = {}  // 'run': [fun]
    }
    on(type, cb) {
      if (!this.event[type]) {
        this.event[type] = [cb]
      } else {
        this.event[type].push(cb)
      }
    }
    once(type, cb) {
      const fn = (...args) => {
        cb(...args)
        this.off(type, fn)
      }
      this.on(type, fn)
    }
    emit(type, ...args) {
      if (!this.event[type]) {
        return
      } else {
        this.event[type].forEach(cb => {
          cb(...args) 
        });
      }
    }
    off(type, cb) {
      if (!this.event[type]) {
        return
      } else {
        this.event[type] = this.event[type].filter(item => item !== cb);
      }
    }
  }
  1. 构造函数
constructor() {
  this.event = {}  // 'run': [fun]
}

构造函数初始化了一个空对象 this.event,用于存储不同类型事件对应的回调函数数组。

  1. 订阅事件方法 on
on(type, cb) {
  if (!this.event[type]) {
    this.event[type] = [cb]
  } else {
    this.event[type].push(cb)
  }
}

on 方法用于订阅事件。如果当前事件类型在 this.event 中不存在,则将回调函数 cb 存入数组中;如果已存在,则将回调函数追加到对应事件类型的回调函数数组中,在这段代码中,type 是用来标识事件类型的参数。在发布订阅模式中,我们可以定义不同的事件类型,

比如 'click''hover''change' 等,用来表示不同的交互或状态变化。通过指定不同的 type,我们可以订阅特定类型的事件并执行相应的回调函数。。

  1. 一次性订阅事件方法 once
once(type, cb) {
  const fn = (...args) => {
    cb(...args)
    this.off(type, fn)
  }
  this.on(type, fn)
}

once 方法用于一次性订阅事件。内部定义了一个新的函数 fn,该函数会在执行回调函数 cb 后立即调用 off 方法,从而实现一次性订阅,...args 是使用了展开语法(spread syntax),它表示将传入的参数展开成一个参数序列。在这里,...args 表示将所有传入的参数都作为一个参数序列传递给回调函数 cb

具体来说,当 emit 方法调用时传入了参数,比如 emit('click', 'button'),那么在 once 方法内部的 fn 函数中的 ...args 就会包含 'button' 这个参数,然后将这个参数序列传递给回调函数 cb。。

  1. 发布事件方法 emit
emit(type, ...args) {
  if (!this.event[type]) {
    return
  } else {
    this.event[type].forEach(cb => {
      cb(...args) 
    });
  }
}

emit 方法用于发布事件。首先检查是否存在对应事件类型的回调函数数组,如果存在,则依次执行数组中的回调函数,并将参数 args 传递给它们。

  1. 取消订阅事件方法 off
off(type, cb) {
  if (!this.event[type]) {
    return
  } else {
    this.event[type] = this.event[type].filter(item => item !== cb);
  }
}

off 方法用于取消订阅特定事件类型的回调函数。首先检查是否存在对应事件类型的回调函数数组,如果存在,则通过 filter 方法将指定的回调函数 cb 从数组中移除。

总结

这篇文章介绍了如何使用 JavaScript 实现一个简单的发布订阅模式。通过定义一个名为 EventEmitter 的类,实现了四个方法:ononceemit 和 off,用于订阅事件、一次性订阅事件、发布事件以及取消订阅事件。

在 on 方法中,通过将事件类型和回调函数存入对象中,实现了事件的订阅;once 方法通过在执行回调函数后立即取消订阅,实现了一次性订阅事件;emit 方法用于发布事件,执行对应事件类型的所有回调函数;off 方法用于取消订阅特定事件类型的回调函数。

通过这些方法的组合,我们可以实现组件之间的解耦和灵活的事件管理,使得不同组件之间可以通过发布订阅模式进行通信和交互。这种模式可以提高代码的灵活性和可维护性,使得代码结构更加清晰和易于扩展。

原文链接:https://juejin.cn/post/7348762390388146216 作者:moyu84

(0)
上一篇 2024年3月22日 下午4:00
下一篇 2024年3月22日 下午4:10

相关推荐

发表回复

登录后才能评论