面试官:聊聊JS设计模式的发布订阅者模式 + 手写一个发布订阅
前言
发布订阅者模式(Publish-Subscribe Pattern)是一种常见的设计模式,用于实现对象间的一对多通信。在这种模式中,一个对象(称为发布者)维护了一个订阅者列表,并在状态变化时向所有订阅者广播通知,而订阅者(也称为观察者)则可以自由地订阅或取消订阅这些通知。
我们在现实生活中遇到过的发布订阅模式:
比如我们是一个狂热的健身爱好者,同时也是一个健身俱乐部的会员,会员们可以在俱乐部有特殊的健身课程,但具体的课程时间表和内容尚未确定。这时我们就会加前台小姐姐的手机号码,希望在课程正式推出时得到通知。
在这个场景中
-
发布者(健身俱乐部) :健身俱乐部充当发布者的角色,负责管理课程信息,并在新的课程推出时通知所有的订阅者。
-
订阅者(会员) :会员是订阅者,他们希望在新课程推出时得到通知,以便及时参加。
-
通知方式(短信通知) : 健身俱乐部可以通过短信来通知会员新的课程信息。
正文
如果你在 DOM 上绑定过事件处理函数 addEventListener,那么你已经使用过发布订阅模式了。
window.addEventListener('load', () => {
console.log('load事件触发');
})
这个场景中,我们向 window
对象添加了一个事件监听器。它监听的是 load
事件,即页面加载完成事件。当整个页面(包括所有资源)加载完成后,会触发这个回调函数,其中load
是 JS 自带的事件。
事件的本质就是模块对象之间的一个信息通信,而有些复杂模块之间的通信不是基本的事件可以完成的,这时我们就需要自定义一些事件,Event()
构造函数可以帮助我们。
Event() 构造函数
语法:
let ev = new Event(typeArg, eventInit);
MND对于其中参数的解释:
来个场景应用下:
<!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>
<style>
#box{
width: 100px;
height: 100px;
background: #000;
}
</style>
<div id="box"></div>
<script>
// 创建一个支持冒泡且不能取消的look事件
let ev = new Event('look', { bubbles: true, cancelable: false })
let box = document.getElementById("box");
box.addEventListener("look", (event) => {
if (event.cancelable) {
event.preventDefault()
} else {
console.log('在box上触发了look事件');
}
})
box.dispatchEvent(ev) // 在box上发布look事件
</script>
</body>
</html>
- 首先我们使用
Event()
构造函数创建了一个名为'look'
的自定义事件,并设置了其属性bubbles
为true
和cancelable
为false
。 - 当
bubbles
属性值为true
时,表示事件会进行冒泡,即从触发事件的目标元素开始,逐级向上传播到祖先元素,直到根节点为止。在冒泡过程中,每个祖先元素都有机会捕获这个事件,响应相应的事件处理程序。 cancelable
属性为true
,表示事件可以被取消。我们通常会调用事件对象的preventDefault()
方法,告诉浏览器不要执行事件的默认行为。- 用
addEventListener
给box
元素添加了一个事件监听器,监听look
自定义事件。 - 使用
dispatchEvent
手动分派自定义事件到box
元素上,触发了事件监听器。
接下来我们来聊聊Event()
中的第二个参数的 composed
属性中的 影子DOM
。
拓展(影子DOM)
我们可以使用影子DOM(Shadow DOM)创建封闭的、独立的 DOM 子树,这样我们可以将一个元素及其子元素的样式和行为封装起来,使其与页面上的其他内容隔离开来,防止外部样式和脚本的影响。比如我们天天看的视频播放器中的播放、暂停等按钮就可以使用影子 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;
}
</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 = `
<div class="title">我是影子DOM提供的标题</div>
<style>
:host{
color: green
}
</style>
`
</script>
</body>
</html>
我们使用 attachShadow()
方法创建了一个影子 DOM,并将其附加到 root
元素上。其中的 mode: 'closed'
表示影子 DOM 是封闭的,不允许外部访问;delegatesFocus: false
表示不委托焦点。
在这个场景中,我们可以发现影子DOM
的样式是不受外界影响的,而是使用 :host
选择器在影子 DOM 中为影子宿主元素(也就是附加了影子 DOM 的元素)设置样式。
手写一个发布订阅🔥🔥🔥🔥
话不多说,直接上手写代码:
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);
}
}
}
这里我们使用了 ES6 语法定义的 JS 类,这样写是更加简洁、清晰和高效。
constructor()
:初始化了一个空对象this.event
,用于存储事件类型和相应的回调函数数组。on(type, cb)
:用于添加事件监听器,接受两个参数,type
表示事件类型,cb
表示要执行的回调函数。如果该事件类型已经存在对应的回调函数数组,则将回调函数追加到数组中,否则创建一个新的数组并将回调函数存入。once(type, cb)
:用于添加一次性事件监听器。内部定义了一个新的函数fn
,它会调用给定的回调函数cb
,然后立即移除这个监听器。最后,调用on()
方法添加这个新定义的函数。emit(type, ...args)
:用于触发事件,接受一个参数列表args
。如果存在对应的事件类型,就依次执行该类型下的所有回调函数,并将参数传递给它们。off(type, cb)
:用于移除指定事件类型下的指定回调函数。如果存在对应的事件类型,则从事件类型对应的回调函数数组中过滤掉要移除的回调函数。
让我们来测试一下:
let ev = new EventEmitter();
const fn1 = (a, b) => {
console.log(a, b, 'fn1');
}
const fn2 = (a, b) => {
console.log(a, b, 'fn2');
}
const fn3 = (a, b) => {
console.log(a, b, 3);
}
ev.on('run', fn1)
ev.once('run', fn2)
ev.emit('run', 1, 1)
ev.emit('run', 2, 2)
// 1 1 fn1
// 1 1 fn2
// 2 2 fn1
这个 EventEmitter
类的实现很简单,但足以满足基本的事件管理需求。通过使用它,可以轻松地实现事件的订阅、发布和移除功能。
学习检验 🌸🌸🌸
看完本篇文章相信大家对于发布订阅这种设计模式已经有自己的认识了,这样子我们可以思考下如何使用发布订阅模式来解决一个异步,这在面试的时候可是一个加分项(虽然平常不会用到),可以给面试官眼前一亮的感觉。
上代码!!!
<script>
let finish = new Event('finish')
function fnA() {
setTimeout(() => {
console.log('请求A完成');
window.dispatchEvent(finish);
}, 1000)
}
function fnB() {
setTimeout(() => {
console.log('请求B完成');
}, 500)
}
fnA()
window.addEventListener('finish', () => {
fnB()
})
</script>
发布订阅的优缺点:
优点:
- 解耦性 :发布订阅模式可以有效地解耦发布者和订阅者之间的关系。发布者和订阅者之间不需要直接引用彼此,它们通过事件进行通信,从而降低了对象之间的依赖性。
- 灵活性 :发布订阅模式提供了一种灵活的机制,使得可以轻松地添加新的发布者和订阅者,或者移除现有的发布者和订阅者,而不会影响到其他部分的代码。
- 异步通信 :发布-订阅模式支持异步通信,发布者和订阅者可以在不同的时间、不同的线程或者不同的进程中进行通信。
缺点:
- 增加消耗 :创建结构和缓存订阅者这两个过程需要消耗计算和内存资源,即使订阅后始终没有触发,订阅者也会始终存在于内存。
- 增加复杂度 :订阅者被缓存在一起,如果多个订阅者和发布者层层嵌套,那么程序将变得难以追踪和调试。
转载自:https://juejin.cn/post/7352075810936274953