前言
在前端开发中,设计模式是一种解决常见问题的重要解决方案。例如以下常见的几种设计模式:
- 观察者模式 (Observer Pattern)
- 描述:一个对象(称为主体)维护其依赖项列表(观察者列表),当对象状态发生变化时,它会通知所有观察者。
- 应用场景:事件监听、数据绑定等。
- 单例模式 (Singleton Pattern)
- 描述:确保类只有一个实例,并提供全局访问点。
- 应用场景:全局状态管理、日志记录器等。
- 工厂模式 (Factory Pattern)
- 描述:定义一个创建对象的接口,但允许子类决定要实例化的类。
- 应用场景:组件或对象的创建。
而发布订阅模式也是一种非常常见的设计模式之一,也经常会使用到。
发布订阅模式允许一个对象(发布者或者称为主题)发布事件,而其他对象(订阅者或者称为观察者)订阅这些事件,当事件发生时,发布者会通知所有订阅者进行相应的处理。这种模式常被用于事件驱动的架构中,如前端开发中的事件处理、消息队列等。
该模式包含三个核心组件:发布者、订阅者、事件。
- 发布者:当发布者发布事件时,会通知所有订阅者,并调用订阅者的处理方法。
- 订阅者:订阅者负责订阅事件或者消息,并提供处理事件的方法。当发布者发布相关事件时,订阅者会接收到通知并执行相应的处理逻辑。
- 事件:事件是发布者和订阅者之间通信的载体,包含了事件类型和相关的数据。
订阅和发布
在js中有许多常见的事件,比如点击事件、鼠标事件、键盘事件。
<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中当中已经存在的事件,所以我们并不需要去订阅及发布它,那么当我们点击时就会执行里面的回调函数:
我们在box
上订阅一个look
事件:
<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事件
发布这个事件之后,那么订阅了该事件的订阅者就能执行回调函数中的代码.
<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>
所以会直接在控制台输出。
可能会有小伙伴们有疑问:为何我们不去进行任何操作就会执行回调函数,比如点击事件我们需要点击才会执行回调函数。这是因为该
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
上:
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事件
其实还有第三个参数:
composed: 表示事件是否可以穿过 Shadow DOM 和常规 DOM 之间的边界进行冒泡。
这里我们就要先来讲解一下影子DOM shadow
了。
影子DOM
<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:
然后我们去检查id为root
的这个容器:
发现这个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的内部写入样式:
那么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>
composed
我们将composed
设置为true
,并且创建一个支持冒泡且不可以取消的look
事件:
<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>
我们通过获得id
为box
的DOM结构,在box
下创造一个影子DOM,mode
设置为open
,这意味着我们可以获取到影子DOM的DOM
结构。
并且在box
上订阅一个look
事件,获取到影子DOM,并且在它身上派发一个look
事件。
我们来看看打印:
触发了box
上的look
事件。
如果将composed
设置为fasle
:
则不会打印。
因为composed
控制着事件是否可以穿过影子DOM和常规DOM的边界进行冒泡。
总结一下
我们总结一下发布订阅模式,我们来举一个例子让你更好地去理解发布订阅:
假设我们想去买一套新房子,当我们看中了一套房子之后,就去找售楼部的小姐姐。而小姐姐说这套楼盘还没有开售,让我们等消息。同时也有很多人看中了这套房子,那么如果当该楼发售时,小姐姐需要一个一个地去告诉想买这套房子的人,这样是十分麻烦的。
我们想买房子就相当于订阅了一个事件,而这时候小姐姐将我们这些想买房子的人拉进一个群聊或者是公众号中。当楼盘开售时,小姐姐就可以直接在群聊或者公众号中发布该信息,这样所有订阅者就能第一时间得到消息。这就是发布订阅模式。
CustomEvent
上面我们讲了使用Event
去声明一个事件,不过JS
还提供了另一种方式去声明一个事件,它就是CustomEvent
。
CustomEvent
继承于Event
,并且它提供了一个新的参数detail
,我们可以在发布的事件中加入一些东西,例如参数,当事件触发时可以得到:
<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
获取到参数。
使用发布订阅处理异步
<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>
看到这里,相信小伙伴们应该大致了解了发布订阅这个模式,接下来我们上手写!
手写发布订阅
这里我们使用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
,是因为我们并不确定回调函数中会有几个参数。
我们运行一下:
同时,如果我们一个事件订阅三次,那么它就触发三个回调函数:
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')
扩展
在面试时面试官可能还会让我们再实现两个函数:
第一个为取消一个事件的订阅:
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
的订阅取消了,所以只会执行fn
和fn1
第二个方法为只订阅一次, 也就说连续发布多个事件的话,只执行第一次:
该方法同样传入事件和回调函数
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')
可以看到,发布了两次,而fn
触发了两次,fn1
只触发了一次
发布第二次时fn1
不生效。
最后
发布订阅的代码并不难,而它在面试中也是一个常见的考题。
在此声明,代码并没有唯一的解。但是思路是相同的。
希望看到此的小伙伴们,对发布订阅的理解更为深刻了。
写文章不易,如果帮助到了小伙伴们,希望给本文点赞收藏评论三连。有不懂的地方欢迎到评论区留言,我会及时回复。