设计模式之发布订阅
这几天看小伙伴的面经的时候,看到一位小伙伴面试被问到设计模式发布订阅,所以今天我们就来学习一下设计模式之发布订阅!
发布订阅是vue源码里面非常核心的设计模式思想,也被人叫做观察者模式。
我们可以通过一个简单情景帮助大家理解发布订阅!就比如现在大家热议的比亚迪秦,作为一名顾客,你去到一家4s店想要购买一辆,然而,店中的现货已经售罄,接待员小姐姐告诉你,可以关注他们的官方公众号,一旦有进货就会提醒顾客,发布消息,而你关注公众号这样一个行为就是订阅 ,而这个公众号发布最新的消息就是发布了一个事件,而且这样一个公众号是大众型的,会有很多的订阅者!
一般呢,在面试当中不仅仅会聊一些发布订阅的概念,也有可能会让你手写一个发布订阅。
今天,我们就从自定义事件 到影子dom 到class再到手写发布订阅带大家详细学习一遍!
发布订阅使用
事件的本质就是模块对象之间的信息通信!
我们知道,在js当中有很多的事件包括:点击事件,鼠标事件,键盘事件和滚动事件等等,这些都是官方给我们打造好的一些事件,但是,有时候在一些情况下,我们需要自己手动写一个事件出来!
我们先介绍一下Event()
自定义事件Event()构造函数
Event()
构造函数,创建一个新的事件对象Event,参考mdn:Event() - Web API 接口参考 | MDN (mozilla.org)
语法:
js
event = new Event(typeArg, eventInit);
我拿到实践应用
html
<style>
#box{
width: 100px;
height: 100px;
background-color: #000;
}
</style>
<body>
<div id="box"></div>
<script>
// Event用于创建一个事件
let ev = new Event('look',{bubbles:true,cancelable:false,})
//这个行为被称为订阅了一个事件,或者注册了一个事件
let box = document.getElementById("box");
box.addEventListener("look", (event)=>{
console.log('在box上触发了look事件');
})
box.dispatchEvent(ev)//在box上发布look事件
</script>
</body>
我们写了这样一个简单的小demo
- 我们在页面上放上了一个100x100的小黑块盒子
box
let ev = new Event('look',{bubbles:true,cancelable:false,})
:创建了一个名为look。允许冒泡不可取消的事件!- 我们再拿到了
box
容器的dom,在通过addEventListener("look",()=>{})
添加事件监听器订阅了这个事件! - 订阅之后,我们还要发布事件!
box.dispatchEvent(ev)
就是在box
上发布look
事件。
这样,我们打开页面就可以看到!
因为我们没有规定触发的手段像是点击、滚动啊等等,所以这个事件一刷新就会自动触发。
就像是js官方的事件默认发布在全局中一样,不需要我们去发布,这里的自定义事件不需要发布在全局,这是因为全局本身默认就订阅了这个事件,一旦发布,就会默认存在于全局当中!
接下来,我们一一介绍自定义事件的相关参数!
bubbles
表示该事件是否可以冒泡!我之前也过一期文章:面试官:js中的事件触发过程是什么样子的? - 掘金 (juejin.cn)
里面有介绍到事件冒泡和捕获,大家感兴趣的可以去看一下!
这里我就简单介绍一下冒泡:在 JavaScript 中,事件冒泡是指当一个事件在一个元素上触发时,该事件会沿着该元素的父元素依次传播,直到传播到最顶层的文档元素。
cancelable
表示该事件是否可以被取消!
这是什么意思呢?在事件监听器中,我们接收了一个事件参数event
,我们打印一下这个事件参数!
js
box.addEventListener("look", (event) => {
console.log(event);
console.log('在box上触发了look事件');
})
在事件参数上,有这样一个方法!当我们把cancelable
设置为true
时,我们这样修改一下demo
html
<script>
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>
我们可以看到此时将不会有任何输出:
composed
指示事件是否会在影子 DOM 根节点之外触发侦听器。
要搞懂这个参数有何作用,我们先得知道影子dom是个什么东西?
影子dom
我们直接通过一个小demo来了解什么是影子dom
html
<style>
.title{
font-size: 26px;
color: red;
}
/* 定义了一个css变量 */
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:'open',delegatesFocus: true});
rootShadow.innerHTML = `
<div class="title shadow">我是影子dom标题</div>
<style>
:host{
font-size: 26px;
color:var(--color)
}
</style>
`
// 外部js访问影子dom
console.log(root.shadowRoot);
</script>
</body>
我们查看一下页面效果
我们可以看到影子dom也就是占据文档流的!
任何节点都会有一个attachShadow
,用于创建一个影子dom
在我们的demo中,我们有一个root
容器,我可以拿到这个容器使用attachShadow
创建一个影子dom
影子dom结构要使用innerHTML
进行影子dom结构的设计
值得注意的是:影子dom是不受全局样式的影响,我们要在innerHTML
中放入style
为影子dom添加样式!
原生的css定义变量使用的是--符进行变量设置,调用的时候要使用
var(--变量名)
的形式调用
重要!!!因为影子dom不受全局样式的影响,一般我们会用到影子dom来封装组件,可以实现样式分离,类似vue里面的scoped,毕竟你也不能确保别人的样式名不会和你组件的样式名重名!
attachShadow
可以接收一个对象!
对象中有两个参数mode
和delewgatesFocus
其中mode
有两个值open
和closed
,意思是代表影子dom在外部的js是否可以访问到。
delegatesFocus
焦点委托,用于指定我们去减轻一个自定义元素的聚焦的性能问题。
我们再介绍一下这两个参数的具体效果!
mode
当我们设置mode
为open
时
js
// 外部js访问影子dom
console.log(root.shadowRoot);
)
当我们设置为closed
时,这个打印将拿不到值
delegatesFocus
设置的是影子dom的聚焦优化!为ture
和false
,例如我们加了一个输入框,需要聚焦的时候,当设置为false
,我们点击文字也会聚焦到输入框当中,当为true
,点击文字就不会聚焦到输入框当中,这是一种优化!
到这里影子dom我们就介绍完毕了!
我们再回到自定义事件的第三个参数composed
事件是否会在影子 DOM 根节点之外触发侦听器,如果我们给一个节点添加一个影子dom,并且在影子dom上订阅我们的自定义事件!并且发布事件
如果composed
为true
,影子dom发布的事件,那么那个节点也能订阅到这个事件。
如果composed
为false
,影子dom发布的事件,只有影子dom能订阅到这个事件。
html
<style>
#box{
width: 100px;
height: 100px;
background-color: #000;
}
</style>
<body>
<div id="box"></div>
<script>
let ev = new Event('look',{
bubbles:true,
cancelable:false,
// 事件派发会不会影响影子dom外的节点
composed:ture
})
let box = document.getElementById("box");
// 添加一个影子dom
let boxShadow = box.attachShadow({ mode:'open',delegatesFocus:false })
boxShadow.innerHTML = `
<div class="title">我是影子dom</div>
`
box.addEventListener("look", (event)=>{
if(event.cancelable){
event.preventDefault();
}else{
console.log('在box上触发了look事件');
}
})
let boxChild = box.shadowRoot.querySelector('.title')
console.log(boxChild);
// 用影子dom派发事件
boxChild.dispatchEvent(ev)
</script>
我们可以看到box
也订阅到这个事件!
我们将composed
改为false
我们就可以看到节点box
拿到不到影子dom发布的事件了!
CustomEvent()
CustomEvent
是Event
的继承子类,继承了Event的内容
这个构造函数一般用于我们做某个程序出于某种目的来创建的事件!简单一点的语法
html
<div id="box">
</div>
<script>
// CustomEvent是Event的继承子类,继承了Event的内容
// 去做一个程序出于某种目的的来创建的事件
let myEvent = new CustomEvent('run',{detail:{name:'running'},'bubbles':true,'cancelable':false},)
window.addEventListener('run',e=>{
console.log(`事件被${e.detail.name}触发`);
})
window.dispatchEvent(myEvent);
</script>
大家可以了解一下这个知识点!
接下来,我们来做一个小挑战!
发布订阅小挑战
html
<script>
function fnA() {
setTimeout(() => {
console.log('请求A完成')
}, 1000)
}
function fnB() {
setTimeout(() => {
console.log('请求B完成')
}, 500)
}
</Script>
我们如何不使用promise,让请求A先完成,再让请求B完成!
使用我们刚刚学的发布订阅手段!实现这样一个效果!
先说一下思路!我们可以自定义一个事件,设置一个事件监听器,订阅这个事件,当事件发布之后再调用请求B,而事件的发布我们可以写在请求A当中。
html
<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)
}
// 先让a执行完,再让b执行
fnA()
window.addEventListener('finish',()=>{
fnB()
})
</script>
这样我们就完成了!!
手写发布订阅
接下来我们就可以开始手写发布订阅了!
在手写发布订阅之前,我们要先学习一下ES6中的class语法!
ES6之class语法
在ES5的语法,我们是这样定义构造函数的
js
// 普及一下构造函数方法
function Point(x,y){
this.x = x;
this.y = y;
}
// 让p继承一些方法,在构造方法的原型上添加属性
Point.prototype.toString = function(){
return `(${this.x},${this.y})`
}
// 特别的地方 foo不在构造函数的原型上,所有p继承不到foo 实例对象的隐式原型等于构造函数的显示原型
Point.foo = function(){
return 'foo'
}
let p = new Point(1,2)
我们是通过定义一个函数function Point
,我们可以通过往构造函数的原型上添加方法,来给这个构造函数添加方法,
但是这种语法有一个缺陷之处!Point.foo
这种方法去定义,我们的实例对象会拿不到这种方式定义的函数,因为这个函数不在构造函数的原型上!
这是ES5的写法 和java等面向对象语言差距比较大
现在ES6新语法class出世拉!
js
//在ES6增加了一个类的概念
class Point {
// 类就是构造函数的变种 在类里面所有的this都指向这个类本身
constructor(x,y){
this.x = x;
this.y = y;
}
fnn(){
return 'fnn'
}
// 加一个get关键字 可以直接p.toString 就可以当属性使用
get toString(){
return `(${this.x},${this.y})`
}
// 让实例对象访问不到foo 加一个static 静态方法
static foo(){
return 'foo'
}
}
let p = new Point(1,2)
console.log(p.toString());
在这种class语法当中,我们定义变量放在一个构造器constructor
当中,对于各种函数,我们直接定义在类体当中,直接定义成函数
我们也可以在函数前面加一些关键字:例如get、static等
get关键字:可以直接通过实例对象.函数名,进行调用,把函数当成属性来使用!
static关键字:静态方法,实例对象无法访问!
还有不使用构造器constructor
的语法
js
class Point{
_count = 0
get value(){
// _可以不加,但是官方推荐加_
return this._count
}
set value(val){
console.log(val,'----');
this._count = val
}
}
let p = new Point()
// console.log(p.value);
// 赋值语句会触发set 读值读get
p.value = 1
这个时候,我们定义变量,变量名前一定要"_"
set关键字 :赋值语句。例如:p.value = 1
,这种赋值的方法前面,我们要加set关键字。
好了!class的语法我们就介绍到这里啦!
面试题:手写发布订阅
在面试当中,一般我们会遇到这样的面试题,面试官会给你一个题目!
js
class EventEmitter {
constructor() {
}
// 用于订阅
on() {
}
// 订阅一次
once() {
}
//用于发布事件
emit() {
}
//用于取消订阅
off() {
}
}
叫你实现这样一个发布订阅的功能!
拿到这个面试题,我们首先要完成一下on
和emit
,发布订阅!
on和emit
on负责订阅事件!emit负责发布事件。
我们要怎么实现这样一个逻辑功能呢?
思路:on负责接收回调函数,订阅事件,什么时候emit执行,这个回调函数就触发!这个时候,我们on的逻辑,就是拿到事件和事件的回调,并且将其存在一个对象当中。然后emit去负责去对象当中遍历对应的事件,如果没有,说明没有订阅,直接返回,如果存在,就将对象当中的回调函数数组解构出来,并且调用!(是回调函数数组的原因:因为可能有多个节点订阅同一个事件,存在多个回调函数!)注意:回调函数的调用也要用解构!因为回调函数可能接收的不止一个参数
先写构造器constructor
js
constructor() {
// 用数组或者对象
this.event = {}// 'run':[fun]
}
event就是使用key-value的形式存储事件和回调函数数组!
然后,我们实现一下on函数
js
on(type, cb) {
// 如果读不到这个事件
if (!this.event[type]) {
this.event[type] = [cb];
} else {
// 如果曾经有人订阅过,我就把我的订阅事件的回调函数加进去
this.event[type].push(cb);
}
}
on函数接收一个type
表示的是事件的名称,cb
表示接收的回调函数
通过this
访问event
,如果有这个事件,就把订阅的回调函数加入到数组当中,如果没有,就新建一个事件和对应的回调数组!
再实现以下emit函数
js
emit(type, ...args) {
if (!this.event[type]) {
return
} else {
this.event[type].forEach(cb => {
// 打...把数组解构出来
cb(...args)
})
}
}
同样,发布事件,我们去event
找对应的事件,如果没有则返回,如果有!则把该事件的回调数组通过forEach
遍历,调用掉这个事件中的所有回调函数!
这个,订阅发布功能我们就实现了!
目前的全部代码:
js
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() {
}
//用于发布事件
emit(type, ...args) {
if (!this.event[type]) {
return
} else {
this.event[type].forEach(cb => {
// 打...把数组解构出来
cb(...args)
})
}
}
//用于取消订阅
off() {
}
}
// 用法
let ev = new EventEmitter();
const fn = (a, b) => {
console.log(a, b, 1);
}
const fn1 = (a, b) => {
console.log(a, b, 2);
}
// 多个对象对同一个事件进行了订阅
ev.on('run', fn)
ev.on('run', fn1)
// 发布一个run事件,还可以接收一个参数
ev.emit('run', 1, 1)
ev.emit('run', 3, 3)
//输出
1 1 1
1 1 2
off和once
off函数用于取消订阅,once函数负责只订阅一次就取消!
思路:off函数取消订阅,我们可以去event
找对应的事件,如果没有就不做处理,如果找到了就使用filter
过滤掉我们要取消订阅的事件,重新对event
赋值,排除掉要取消的事件对象!once函数的订阅一次就取消,我们可以定义一个函数fn
,接收到回调的参数,最后调用一遍这个回调函数,然后在通过off
取消订阅这个事件,然后再函数外面通过on
订阅一下这个事件,再把fn
作为回调执行掉!
off函数
js
off(type, cb) {
if (!this.event[type]) {
return
}else
{
// 使用filter过滤一下,排除了cb的对象
this.event[type] = this.event[type].filter(item=>item!==cb)
}
}
once函数
js
once(type,cb) {
// 订阅一次就得取消
const fn = (...args) =>{
cb(...args)
this.off(type,fn)
}
this.on(type,fn)
}
我们再通过几个案例测试一下!
js
let ev = new EventEmitter();
const fn = (a, b) => {
console.log(a, b, 1);
}
const fn1 = (a, b) => {
console.log(a, b, 2);
}
// 测试一次订阅
ev.once('run', fn)
ev.emit('run', 1, 1)
ev.emit('run', 1, 2)
//输出
1 1 1
// 测试取消订阅
ev.on('run', fn)
ev.on('run', fn1)
ev.off('run', fn1)
ev.emit('run', 1, 1)
//输出
1 1 1
到这里我们的手写订阅发布就完成啦,完整代码如下:
js
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
{
// 使用filter过滤一下,排除了cb的对象
this.event[type] = this.event[type].filter(item=>item!==cb)
}
}
}
最后
今天,我们从自定义事件Event构造函数,再到影子dom,再到ES6新语法class结合学习了发布订阅的使用,再到自己手写面试题发布订阅。
这其实是面试中一个比较难的考点,如果大家有不懂之处欢迎评论留言哦!
如果,你觉得这篇文章有帮助的话,可以帮博主点赞+评论+收藏,三连一波!感谢!
代码已上传至github: 修远君的github之手写发布订阅