深入浅出设计模式之发布订阅及手写

简介: 深入浅出设计模式之发布订阅及手写

前言

在前端开发中,设计模式是一种解决常见问题的重要解决方案。例如以下常见的几种设计模式:

  1. 观察者模式 (Observer Pattern)
  • 描述:一个对象(称为主体)维护其依赖项列表(观察者列表),当对象状态发生变化时,它会通知所有观察者。
  • 应用场景:事件监听、数据绑定等。
  1. 单例模式 (Singleton Pattern)
  • 描述:确保类只有一个实例,并提供全局访问点。
  • 应用场景:全局状态管理、日志记录器等。
  1. 工厂模式 (Factory Pattern)
  • 描述:定义一个创建对象的接口,但允许子类决定要实例化的类。
  • 应用场景:组件或对象的创建。

而发布订阅模式也是一种非常常见的设计模式之一,也经常会使用到。

发布订阅模式允许一个对象(发布者或者称为主题)发布事件,而其他对象(订阅者或者称为观察者)订阅这些事件,当事件发生时,发布者会通知所有订阅者进行相应的处理。这种模式常被用于事件驱动的架构中,如前端开发中的事件处理、消息队列等。

该模式包含三个核心组件:发布者、订阅者、事件。

  • 发布者:当发布者发布事件时,会通知所有订阅者,并调用订阅者的处理方法。
  • 订阅者:订阅者负责订阅事件或者消息,并提供处理事件的方法。当发布者发布相关事件时,订阅者会接收到通知并执行相应的处理逻辑。
  • 事件:事件是发布者和订阅者之间通信的载体,包含了事件类型和相关的数据。

订阅和发布

在js中有许多常见的事件,比如点击事件、鼠标事件、键盘事件。

<!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>
        #box{
            width: 100px;
            height: 100px;
            background: #000;
        }
    </style>
</head>
<body>
    <div id="box">
        
    </div>
    <script>
        let box = document.getElementById("box")
        
        window.addEventListener('click', () => {
             console.log('点击了');
        })
    </script>
</body>
</html>

click事件是js中当中已经存在的事件,所以我们并不需要去订阅及发布它,那么当我们点击时就会执行里面的回调函数:

image.png

我们在box上订阅一个look事件:

<!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>
        #box{
            width: 100px;
            height: 100px;
            background: #000;
        }
    </style>
</head>
<body>
    <div id="box">
        
    </div>
    <script>
        let box = document.getElementById("box")
        box.addEventListener('look', (event) => {
            console.log('在box上触发了look事件');
        })
        
    </script>
</body>
</html>

这里我们通过监听器去监听这个look事件,但是无论我们进行什么操作,这个回调函数并不会执行。

这是因为js中并不存在这个事件。

首先我们需要去创造一个look事件:

let ev = new Event('look', { bubbles: true, cancelable: true })

我们通过构造函数Event去创造一个look事件,第二个参数的意思是该事件支持冒泡且可以被取消。

如果对冒泡不太了解的小伙伴们可以看看我的这篇文章:# 说说如何使用事件委托进行性能优化

那么可以被取消什么意思呢?请往下看:

在我们创建完这个look事件后,那么box就成功订阅了该事件。

接下来我们就需要去发布这个事件:

box.dispatchEvent(ev)  // 在box上发布look事件

发布这个事件之后,那么订阅了该事件的订阅者就能执行回调函数中的代码.

<!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>
        #box{
            width: 100px;
            height: 100px;
            background: #000;
        }
    </style>
</head>
<body>
    <div id="box">
        
    </div>
    <script>
        // 创建一个支持冒泡且能取消的look事件
        let ev = new Event('look', { bubbles: true, cancelable: true })
        let box = document.getElementById("box")
        box.addEventListener('look', (event) => {
            
            console.log('在box上触发了look事件');
        })
        
     
        box.dispatchEvent(ev)  // 在box上发布look事件
    </script>
</body>
</html>

image.png

所以会直接在控制台输出。

可能会有小伙伴们有疑问:为何我们不去进行任何操作就会执行回调函数,比如点击事件我们需要点击才会执行回调函数。这是因为该look事件我们只是单纯的定义出来,不需要任何条件就能触发。所以会直接执行回调函数。

Event参数

bubble

我们可以将bubble设置为true或者false,如果我们设置为true,说明它支持冒泡。

let box = document.getElementById("box")
        
        box.addEventListener('look', (event) => {
            console.log('在box上触发了look事件');
        })
        
        window.addEventListener('look', () => {
            console.log('在window上触发了look事件');
        })
        box.dispatchEvent(ev)  // 在box上发布look事件

我们同样在window上定义一个look事件,那么当我们发布事件后,在冒泡阶段,事件从目标元素一直向外传播,最终传递到window上:

image.png

cancelable

同样,我们可以将cancelable设置为true或者false,如果为true,那么意味着该事件是可以取消的:

let ev = new Event('look', { bubbles: true, cancelable: true })
        let box = document.getElementById("box")
        box.addEventListener('look', (event) => {
            // console.log(event);
            if(event.cancelable){
                event.preventDefault() // 取消事件默认行为
            }else{
                console.log('在box上触发了look事件');
            }
        })
      
        box.dispatchEvent(ev)  // 在box上发布look事件

image.png

其实还有第三个参数:

image.png

composed: 表示事件是否可以穿过 Shadow DOM 和常规 DOM 之间的边界进行冒泡。

这里我们就要先来讲解一下影子DOM shadow了。

影子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'});
    rootShadow.innerHTML = `
    <div class="title shadow">我是影子DOM提供的标题</div>
    `
  </script>
</body>
</html>

创建影子DOM

首先我们获取id为root的这个DOM结构,然后使用js自带的方法attachShadow去创建一个影子DOM:

image.png

然后我们去检查id为root的这个容器:

image.png

发现这个html结构被shadow-root所包括起来。并且我们发现,我们在影子DOM中的div标签也加了一个title类名,但是我们发现字体并没有变成红色

因为影子DOM最大的一个特点就是存在样式隔离!

外部的样式并不会去影响影子DOM

我们如果想要给影子DOM设置样式:

let root = document.getElementById("root");
    let rootShadow = root.attachShadow({ mode: 'closed'});
    rootShadow.innerHTML = `
    <div class="title shadow">我是影子DOM提供的标题</div>
    <style>
        :host{
            color: green
        }
    </style
    `

需要在影子DOM的内部写入样式:

image.png

那么attachShadow中的mode:closed是什么意思呢?

如果我们将它设置为closed,那么外界将不能获取到它的DOM结构,如果设置为open,则可以获取“

<script>
    let root = document.getElementById("root");
    let rootShadow = root.attachShadow({ mode: 'open'});
    rootShadow.innerHTML = `
    <div class="title shadow">我是影子DOM提供的标题</div>
    <style>
        :host{
            color: green
        }
    </style
    `
    console.log(root.shadowRoot);
  </script>

image.png

composed

我们将composed设置为true,并且创建一个支持冒泡且不可以取消的look事件:

<!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>
        #box{
            width: 100px;
            height: 100px;
            background: #000;
        }
    </style>
</head>
<body>
    <div id="box">
        
    </div>
    <script>
        // 创建一个支持冒泡且不能取消的look事件
        let ev = new Event('look', { bubbles: true, cancelable: false, composed: true })
        let box = document.getElementById("box")
        let boxShadow = box.attachShadow({mode: 'open'})
        boxShadow.innerHTML=`
            <div class = "title"> 我是影子DOM</div>
        `
        box.addEventListener('look', (event) => {
            // console.log(event);
            if(event.cancelable){
                event.preventDefault()
            }else{
                console.log('在box上触发了look事件');
            }
        })
        let boxChild = box.shadowRoot.querySelector('.title')
        // console.log(boxChild);
        boxChild.dispatchEvent(ev)
    </script>
</body>
</html>

我们通过获得idbox的DOM结构,在box下创造一个影子DOM,mode设置为open,这意味着我们可以获取到影子DOM的DOM结构。

并且在box上订阅一个look事件,获取到影子DOM,并且在它身上派发一个look事件。

我们来看看打印:

image.png

触发了box上的look事件。

如果将composed设置为fasle:

image.png

则不会打印。

因为composed控制着事件是否可以穿过影子DOM和常规DOM的边界进行冒泡。

总结一下

我们总结一下发布订阅模式,我们来举一个例子让你更好地去理解发布订阅:

假设我们想去买一套新房子,当我们看中了一套房子之后,就去找售楼部的小姐姐。而小姐姐说这套楼盘还没有开售,让我们等消息。同时也有很多人看中了这套房子,那么如果当该楼发售时,小姐姐需要一个一个地去告诉想买这套房子的人,这样是十分麻烦的。

我们想买房子就相当于订阅了一个事件,而这时候小姐姐将我们这些想买房子的人拉进一个群聊或者是公众号中。当楼盘开售时,小姐姐就可以直接在群聊或者公众号中发布该信息,这样所有订阅者就能第一时间得到消息。这就是发布订阅模式。

CustomEvent

上面我们讲了使用Event去声明一个事件,不过JS还提供了另一种方式去声明一个事件,它就是CustomEvent

CustomEvent继承于Event,并且它提供了一个新的参数detail,我们可以在发布的事件中加入一些东西,例如参数,当事件触发时可以得到:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<body>
    <div id="box"></div>
    <script>
        let myEvent = new CustomEvent('run', {detail: {name: 'running'}, 'bubble': true, cancelable:false})
        window.addEventListener('run', e => {
            console.log(`事件被${e.detail.name}触发`);
        })
        window.dispatchEvent(myEvent)
    </script>
</body>
</html>

当我们将此事件派发时,可以通过e.detail.name获取到参数。

image.png

使用发布订阅处理异步

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

这里我们想要先执行fnA,再执行fnB,我们可以通过发布订阅去处理异步。

这里我们通过fnB去订阅一个事件,当fnA执行完毕后发布事件,这样就可以先执行fnA再执行fnB

<script>
    let finish = new CustomEvent('finish', {detail: {name: 'ok'}})
    function fnA(){
        setTimeout(() => {
            console.log('请求A完成');
            window.dispatchEvent(finish)
        }, 1000)
    }
    function fnB(){
        setTimeout(() => {
            console.log('请求B完成');
        }, 500)
    }
    fnA()
    window.addEventListener('finish', () => {
        fnB()
    })
</script>

image.png

看到这里,相信小伙伴们应该大致了解了发布订阅这个模式,接下来我们上手写!

手写发布订阅

这里我们使用ES6去完成。

class EventEmitter {
    constructor() {
        
    }
    on() {
        
    }
    emit() {
        
    }
 
}
let ev = new EventEmitter()
const fn = (str) => {
    console.log(str);
}
ev.on('run', fn)
ev.emit('run', 'hello')
ev.on('say', fn)
ev.emit('say', 'world')

这里我们需要去实现两个方法,on去订阅一个事件,emit去发布一个事件,当订阅的事件发布了就需要去执行回调函数fn

首先,我们的on方法去接受两个参数,一个是事件,一个是回调函数。

emit方法也接受两个参数,一个为事件,一个是传给回调函数的参数。

我们先要明白一个点,怎么样做到事件一发布就执行回调函数呢?

回调函数的调用一定是需要放到emit里面去执行的,如果我们放到on中执行,那我们并不能知道什么时候这个事件能发布。

整体的一个思路就是我们先创建一个对象:对象中的属性为事件,值为回调函数。

但是由于一个事件不止可以执行一个回调函数,所以值应该为一个数组,数组中装着该事件触发时执行的所有回调函数。

那么当我们订阅一个事件,就将回调函数存入数组当中。

发布一个事件时,判断该事件在对象中是否能找到,如果有,就意味着有人订阅了该事件,就去执行数组中的每一个回调函数

constructor(){
    this.event = {}  // {'run': [func]'}
}
on(type, cb){
    if(!this.event[type]){
        this.event[type] = [cb]
    }else{
        this.event[type].push(cb)
    }
}
emit(type, ...args){
    if(!this.event[type]){
        return
    }else{
        this.event[type].forEach(cb => {
            cb(...args)
        })
    }
}

这里emit里面传入剩余参数...args,是因为我们并不确定回调函数中会有几个参数。

我们运行一下:

image.png

同时,如果我们一个事件订阅三次,那么它就触发三个回调函数:

const fn = (str) => {
    console.log(str, 0);
}
const fn1 = (str) => {
    console.log(str, 1);
}
const fn2 = (str) => {
    console.log(str, 2);
}
ev.on('run', fn)
ev.on('run', fn1)
ev.on('run', fn2)
ev.emit('run', 'hello')

image.png

扩展

在面试时面试官可能还会让我们再实现两个函数:

第一个为取消一个事件的订阅:

off(type, cb) {
        if (!this.event[type]) {
            return
        } else {
            this.event[type] = this.event[type].filter(item => item !== cb)
        }
    }

这个方法传入一个事件和一个回调函数,我们只需要将这个回调函数从数组中移除就可以了:

ev.on('run', fn)
ev.on('run', fn1)
ev.on('run', fn2)
ev.off('run', fn2)
ev.emit('run', 'hello')

我们将fn2的订阅取消了,所以只会执行fnfn1

image.png

第二个方法为只订阅一次, 也就说连续发布多个事件的话,只执行第一次:

该方法同样传入事件和回调函数

once(type, cb){
    const fn = (...args) => {
        cb(...args)
        this.off(type, fn)
    }
    this.on(type, cb) 
}

我们想要做到只订阅一次,那么只需在执行完第一次后将该事件的订阅取消就行了。

当我们再次发布时,就不会执行了。


let ev = new EventEmitter()
const fn = (str) => {
    console.log(str, 0);
}
const fn1 = (str) => {
    console.log(str, 1);
}
ev.on('run', fn)
ev.once('run', fn1)
ev.emit('run', 'hello')
ev.emit('run', 'world')

image.png

可以看到,发布了两次,而fn触发了两次,fn1只触发了一次

发布第二次时fn1不生效。

最后

发布订阅的代码并不难,而它在面试中也是一个常见的考题。

在此声明,代码并没有唯一的解。但是思路是相同的。

希望看到此的小伙伴们,对发布订阅的理解更为深刻了。

写文章不易,如果帮助到了小伙伴们,希望给本文点赞收藏评论三连。有不懂的地方欢迎到评论区留言,我会及时回复。


相关文章
|
5月前
|
设计模式
设计模式-观察者(发布订阅)模式
设计模式-观察者(发布订阅)模式
|
设计模式 XML C++
设计模式之单例、工厂、发布订阅者模式
保证一个类仅有一个实例,并提供一个该实例的全局访问点
|
设计模式 前端开发 JavaScript
JavaScript 设计模式之发布订阅者模式
前面在文章介绍《JavaScript 设计模式之策略模式》为什么需要设计模式及其一种常见的设计模式。本文继续介绍另一种常见的设计模式发布订阅者模式。
125 0
JavaScript 设计模式之发布订阅者模式
|
28天前
|
设计模式 数据库连接 PHP
PHP中的设计模式:提升代码的可维护性与扩展性在软件开发过程中,设计模式是开发者们经常用到的工具之一。它们提供了经过验证的解决方案,可以帮助我们解决常见的软件设计问题。本文将介绍PHP中常用的设计模式,以及如何利用这些模式来提高代码的可维护性和扩展性。我们将从基础的设计模式入手,逐步深入到更复杂的应用场景。通过实际案例分析,读者可以更好地理解如何在PHP开发中应用这些设计模式,从而写出更加高效、灵活和易于维护的代码。
本文探讨了PHP中常用的设计模式及其在实际项目中的应用。内容涵盖设计模式的基本概念、分类和具体使用场景,重点介绍了单例模式、工厂模式和观察者模式等常见模式。通过具体的代码示例,展示了如何在PHP项目中有效利用设计模式来提升代码的可维护性和扩展性。文章还讨论了设计模式的选择原则和注意事项,帮助开发者在不同情境下做出最佳决策。
|
8天前
|
设计模式 Java Kotlin
Kotlin教程笔记(51) - 改良设计模式 - 构建者模式
本教程详细讲解Kotlin语法,适合希望深入了解Kotlin的开发者。对于快速学习Kotlin语法,推荐查看“简洁”系列教程。本文重点介绍了构建者模式在Kotlin中的应用与改良,包括如何使用具名可选参数简化复杂对象的创建过程,以及如何在初始化代码块中对参数进行约束和校验。
12 3
|
1月前
|
设计模式 算法 安全
设计模式——模板模式
模板方法模式、钩子方法、Spring源码AbstractApplicationContext类用到的模板方法
设计模式——模板模式
|
1月前
|
设计模式 数据库连接 PHP
PHP中的设计模式:如何提高代码的可维护性与扩展性在软件开发领域,PHP 是一种广泛使用的服务器端脚本语言。随着项目规模的扩大和复杂性的增加,保持代码的可维护性和可扩展性变得越来越重要。本文将探讨 PHP 中的设计模式,并通过实例展示如何应用这些模式来提高代码质量。
设计模式是经过验证的解决软件设计问题的方法。它们不是具体的代码,而是一种编码和设计经验的总结。在PHP开发中,合理地使用设计模式可以显著提高代码的可维护性、复用性和扩展性。本文将介绍几种常见的设计模式,包括单例模式、工厂模式和观察者模式,并通过具体的例子展示如何在PHP项目中应用这些模式。
|
1月前
|
设计模式 Java Spring
spring源码设计模式分析-代理设计模式(二)
spring源码设计模式分析-代理设计模式(二)
|
10天前
|
设计模式 安全 Java
Kotlin教程笔记(51) - 改良设计模式 - 构建者模式
Kotlin教程笔记(51) - 改良设计模式 - 构建者模式
23 0
|
1月前
|
设计模式 Java
Java设计模式-工厂方法模式(4)
Java设计模式-工厂方法模式(4)