字节面试官:手搓一个发布订阅 【从影子dom谈到es6Class】

设计模式一般是你要成为中高级程序员需要去学习的东西,发布订阅是vue源码核心思想,有些人也称之为观察者模式,二者长得很像。

所谓设计模式就是代码设计的一种思想,设计的好可以非常灵活,高效

一个情景带你理解发布订阅:假设我们想要买房,由于当前的一期房子已经售空,售楼部就让我们去关注他们的公众号,二期在建,一旦建好就会在公众号及时发布消息。我们关注公众号这个行为就是订阅一个事件,公众号发消息就是发布一个事件,并且一个公众号一般会有很多人去订阅

一般字节面试可能会让你手写一个发布订阅模式

在认识发布订阅模式之前我们需要先认识下自定义事件

面试官有时候就会请你聊聊什么是自定义事件

自定义事件

此前我们应该清楚js一些内置的事件,比如点击事件,鼠标事件,键盘事件,焦点事件,滚动事件等。其实事件的本质就是模块对象之间的信息通信

一些复杂的情况就需要考虑一些js自定义的事件

Event构造函数

Event()构造函数创建一个事件(这里随便创建的事件,支持冒泡不可取消

可以看到官方文档里面解释的参数Event() - Web API 接口参考 | MDN (mozilla.org)

csharp 复制代码
let ev = new Event('look', { bubbles: true, cancelable: true })

创建完了这个事件就需要有人去订阅这个事件,addEventListener就是订阅事件。这里写法为了刻意体现冒泡和取消

javascript 复制代码
box.addEventListener("look", (e) => { 
    if (event.cancelable) {
        event.preventDefault();  // 如果事件可以取消就取消事件
    } else {
        console.log("在box上触发了look事件")  
        }
}) 

window.addEventListener("look", () => {
            console.log("在window上触发了look事件")   // 不需要在window上发布,它会冒泡出来,false就不行 
        })

在box身上发布该自定义事件

ini 复制代码
box.dispatchEvent(ev);

像是js自带的事件就是默认发布在全局中,因此不需要我们去发布,所以这里的自定义事件不需要发布在全局身上,因为这是默认就有的,所以window本身就订阅了这个事件

bubbles

  • "bubbles",可选,Boolean类型,默认值为 false,表示该事件是否冒泡。

如何理解这里的冒泡,前面单独出了期文件讲过:面试官:请问js事件触发过程是怎样的 - 掘金 (juejin.cn)

一段话概括就是js事件默认触发在冒泡阶段,其过程为先捕获,到达目标处后冒泡出来,冒泡是从里往外的,也就是从div到body......最后到window,因此我们这里设置成允许冒泡,所以window是可以订阅到这个事件的,如果false就无法打印。

cancelable

  • "cancelable",可选,Boolean类型,默认值为 false,表示该事件能否被取消。

我们可以自己去打印这个事件,里面就会有个event.cancelable属性以及event.preventDefault()函数,函数直接调用就会取消事件,if判断就是看创建事件的时候该事件能否被取消,所有的事件都是可以被取消的,包括点击事件,满足某种条件就让其取消掉

composed

  • "composed",可选,Boolean类型,默认值为 false,指示事件是否会在影子 DOM 根节点之外触发侦听器。

这个比较难理解,首先你需要理解什么是影子dom

影子dom

一个demo带你理解影子dom

xml 复制代码
<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: true});  
        rootShadow.innerHTML = `
            <input />
            <div class="title">我是影子dom设置的标题</div>
            <style>
                :host {
                    color: var(--color);
                }
            </style>
        `  
        console.log(root.shadowRoot)  
    </script>
</body>

这里可以看出影子dom也是会占据文档流的

任何节点都会有个attachShadow,使其成为影子dom,普通创建真实domdocument.createElement

影子dom是不会受到其他样式的影响,给它添加样式必须在innerHTML自行添加,:host专门用于获取影子dom,不想写死你也可以用原生css的变量,用变量定义就是--a,使用的时候需要用var(--a)

原生css也可以自定义变量?没错!只是这个功能出现的比较晚,在scss和less之后,因此css就不能和前面二者冲突,scss是$,less是@。于是原生css用的是两个横杠--

既然影子dom不会收到其他样式的影响,所以UI框架的封装写的样式就会用到它,以防止类名冲突,起一个样式隔离的作用,就像是vue里面的scoped

video

video标签也是用影子dom实现的

这里也分享一个小技能,当你在浏览器中复制不了某些网站的文本内容时,是因为它的事件监听器有个copy事件,我们可以把它移除掉然后就可以复制了。比如我这里并没有登录csdn,他不允许我去复制,直接把copy事件移除掉即可

好了,回到video标签身上来,当我们直接写src时

ini 复制代码
<video src="https://media.w3.org/2010/05/sintel/trailer.mp4"></video>

什么也展示不了,当我们加上controls时再看效果

ini 复制代码
<video src="https://media.w3.org/2010/05/sintel/trailer.mp4" controls></video>

多了这么多元素,播放,声音,进度条......这些东西你去检查也看不到,浏览器其实这里写的就是影子dom。有的公司自己封装了video标签,往video标签植入影子dom

回到影子dom身上来,创建的时候有个modedelegatesFocus是什么意思呢

ini 复制代码
let rootShadow = root.attachShadow({mode: 'closed', delegatesFocus: true});  
mode

控制别人能否拿到影子dom。假设我们用的open参数,那么就可以获取到影子dom

arduino 复制代码
console.log(root.shadowRoot)
delegatesFocus

焦点委托,可以减轻影子dom聚焦的性能问题

好了,现在清楚了影子dom,我们回到自定义事件的第三个参数composed的身上来,他表示的是是否触发影子dom之外的事件,假设我们现在给一个节点添加一个影子dom,并且只给影子dom订阅事件,并且发布事件,如果composed为true,那么这个节点也会订阅到,false就是只能影子dom订阅到

xml 复制代码
<div id="box"></div>

<script>

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


let box = document.getElementById("box");
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')  // 获取影子dom中类名为title的这个容器

boxChild.dispatchEvent(ev)
</script>

比如我这里创建的事件,第三个参数为false,并且给box添加了一个影子dom,我们给box本身以及box身上的影子dom都订阅发布的事件,我们运行看看,false就代表无法让真实dom触发事件,所以打印不出内容,如果为true才可以

Event构造函数还有个同级的构造函数,为CustomEvent这个东西可以携带name参数

CustomEvent构造函数

CustomEventEvent的一个子类,用于出于某个目的,给事件一个参数,并且可以用上这个参数

xml 复制代码
  <script>
    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>

面试官:如何不使用promise处理下面的异步,使其A先执行,B再执行

xml 复制代码
<script>
  function fnA() {
    setTimeout(() => {
      console.log('请求A完成')
    }, 1000)
  }

  function fnB() {
    setTimeout(() => {
      console.log('请求B完成')
    }, 500)
  }
</Script>

这个方法处理异步虽然没得promise优雅,但是非常高级,阮一峰老师也指明可以这样处理异步

没错!我们可以用发布订阅保证A先执行完再执行B,A调用完发布一个事件,让B去订阅这个事件。

就是直接在A函数体中发布一个事件,然后调用A,之后让B去订阅这个事件,肯定大家会疑惑了,B又不是个节点怎么去订阅,笨蛋!任何事件都是默认发布在window上的,所以我们用window去订阅,回调直接写B函数即可

xml 复制代码
<script>
  let ev = new Event('ahead')

  function fnA() {
    setTimeout(() => {
      console.log('请求A完成')
      window.dispatchEvent(ev)
    }, 1000)
  }
  
  fnA()   // 让A执行,也就是开始发布事件

  window.addEventListener('ahead', function fnB() {
    setTimeout(() => {
      console.log('请求B完成')
    }, 500)
  })
</Script>

当然,我们不会这样去处理异步,都是用promise,或者说syncawait。当你给面试官讲这个方法去处理异步,面试官一定会惊叹!

好了,现在你对发布订阅有了更深层的认知了,最后就手搓一个发布订阅吧,这属于js手写系列的一个手写题

面试官:手搓一个发布订阅

面试官:完成下面的类开发

其实有点难度,还请大家耐心来看

javascript 复制代码
class EventEmitter {
	constructor() {
	
	}
	on() {  // 订阅
	
	}
	once() {  // 订阅一次
	
	}
	emit() {  // 发布
	
	}
	off() {  // 关闭
	
	}
}

es6的class语法还没讲过,这里先讲解下

es6的class

js中一般生成实例都是用的构造函数,也就是es5的写法

ini 复制代码
function Point(x, y) {
  this.x = x;
  this.y = y;
}

let p = new Point(1, 2)

假设我想让p实例对象去继承一些方法,我们就会往构造函数原型上面挂属性

javascript 复制代码
Point.prototype.toString = function () { // 转为字符串
  return `(${this.x},${this.y})`
}

这个写法和传统的面向对象的语言差异很大,容易让那些原java,C/C++的人搞不明白,于是es6官方就也打造了类这个关键字。

如果往把构造函数当成对象去挂属性,实例对象是无法继承到的

javascript 复制代码
Point.foo = function () {  // 给构造函数添加属性方法,实例对象访问不到,只能挂到构造函数的显示原型,对象的隐式原型就是函数的显示原型
  return 'foo'
}

console.log(p.foo()); // error: p.foo is not function

不知道大家还记得原型这个概念吗,很早之前就写过一篇原型文章:面试官真烦,问我这么简单的js原型问题(小心有坑) - 掘金 (juejin.cn)

实例对象找属性,先去找自己的显示属性,也就是构造函数体内显示具有的东西里面找,比如p.x。找不到就去自己的隐式原型身上找,而这个隐式原型就是构造函数的显示原型,里面只有个toString。一直顺着对象的隐式原型去找目标的这个链就是原型链。因此找不到foo这个属性,foo并没有挂到构造函数的显示原型中,这种写法的foo只能被构造函数自己访问到

类就是构造函数的变种,我们看看es6的类的写法

javascript 复制代码
class Point {
	constructor(x, y) {
		this.x = x;
		this.y = y;
	}
}

let p = new Point(1, 2)
console.log(p) // Point { x: 1, y: 2 }

类不是一个函数体,如何接收参数呢?于是里面放了个构造器去接收参数

类里面往构造函数添加方法直接写在类里面,如下

javascript 复制代码
class Point {
	constructor(x, y) {
		this.x = x;
		this.y = y;
	}
	toString() {  
    	return `(${this.x},${this.y})`
  	}
}

和构造函数没有区别,也是用this去拿东西,在类中,this就是指向了类本身

es5构造函数写法下的foo换成class去写就是如下这样,和toString一样的

javascript 复制代码
class Point {
	constructor(x, y) {
		this.x = x;
		this.y = y;
	}
	toString() {  
    	return `(${this.x},${this.y})`
  	}
  	foo() {
  		return 'foo'
  	}
}

如果想让foo不被实例对象访问到,就在foo前面加一个关键字static,使其成为静态方法

csharp 复制代码
static foo() {  // 静态方法:不被实例对象访问到
	return 'foo'
}

另外class还有get关键字,在类中的函数前面加一个get,实例对象访问就直接将其当成属性,toString()写成toString。方法名当成变量去用,就像是vue里面的计算属性computed

javascript 复制代码
class Point {
  constructor(x, y) {
    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.foo()) // p.foo is not a function
console.log(p.toString);  // (1, 2)

其实这种写法也比较累,consturctor敲起来费劲我们也可以省去不写,当然前提你不需要用上什么额外的初始逻辑

kotlin 复制代码
class Point {
    _count = 0 // 一定要写前面 
  
    get value() {  
      return this._count
    }
  
    set value(val) {  // 赋值语句触发set 读值语句触发get
      console.log(val, '----');
      this._count = val
    }
  }
  let p = new Point()
  console.log(p.value);  //  0  读值的value是来自get
  p.value = 1  //  1 ----  赋值的value是来自set

拿值直接.value即可,其实vue3中CompositionAPIref源码就是这个。定义变量推荐写法前面加一个_,表示私有化。

set关键字会在复制的时候触发,因此p.value = 1触发setget会在读值的时候触发

好了,现在你也已经明白了class类这个东西,其实就是为了简化构造函数的写法,回到题目。

开搓

onemit

我们先去完成onemit。on就是去订阅,emit就是去发布。我们先写下将会如何使用,先创建一个事件,这里既然手搓我肯定用到题目给的构造函数去创建一个Event。这里还要考虑到多个订阅者,所以我这里订阅两个模拟一下

csharp 复制代码
let ev = new EventEmitter();

const fn = (...args) => {
	console.log(...args)
}

ev.on('run', fn)
ev.emit('run', 123)

ev.on('say', fn)
ev.emit('say', 'hello')

fn被触发两次,这里run和say是两个事件,on的职责是触发事件,但是它的触发条件是emit了才触发

既然这样使用,那么on的形参就是(事件,回调),接下来就要判断事件是否存在,也就是说emit发布了才会有这个事件,emit执行了才会触发on的回调

首先要判断是否有这个事件存在,也就是在constructor中定义全局变量,去放置一个对象去存事件。

javascript 复制代码
constructor() {
	this.event = {}  // 'run': [function]
}

回到on,然后去看这个事件是否存在,如果不存在,我们就要存入到event对象中去,人家都订阅了你就得存下来。这里我们将其存成一个数组的形式,因为一个事件可以被多个人订阅,多个订阅就有多个回调需要去执行。如果事件已经存在,我们就把事件加进去push。这个逻辑就是多个on('run')对应相同数量的run,满足同一事件被多人订阅

typescript 复制代码
on(type, cb) {
	if(!this.event[type]) {
		this.event[type] = [cb] 
	} else {
		this.event[type].push(cb)
	}
}

来到emit,先看形参,第一个形参就是事件,第二个形参是回调的参数,由于你无法判断人家传几个参数,因此我们用上arguments类数组,去解构它,emit的作用就是触发回调,当然得是on了才会触发,on已经干了添加event对象这事情,因此就是只要对象event中有这个事件,我们就是挨个触发它,没有就直接return

typescript 复制代码
emit(type, ...args) {
	if(!this.event[type]) {
		return 
	} else {
		this.event[type].forEach(cb => {
			cb(...args)   // 这里不打...接受的就是数组
		})
	}
}

好了,到这里你就可以看看on和emit的效果了,目前的代码贴一份

typescript 复制代码
class EventEmitter {
    constructor() {
        this.event = {}  // 'run': function
    }
    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 = (...args) => {
    console.log(...args)
  }

  ev.on('run', fn)
  ev.emit('run', 1, 2)  // 1 2
  
  ev.on('say', fn)
  ev.emit('say', 'hello')  // hello

可以实现多个人订阅相同事件,一发布,一对多的关系

csharp 复制代码
  ev.on('run', fn)
  ev.on('run', fn)
  ev.on('run', fn)
  ev.emit('run', 1, 2)
  // 1 2
  // 1 2
  // 1 2

也可以实现多个对象订阅同一事件,执行各自的回调函数

javascript 复制代码
  const fn = (...args) => {
    console.log(...args, 1)
  }

  const fn2 = (...args) => {
    console.log(...args, 2)
  }

  const fn3 = (...args) => {
    console.log(...args, 3)
  }

  ev.on('run', fn)
  ev.on('run', fn2)
  ev.on('run', fn3)
  ev.emit('run', 1, 2)
  1 2 1
  1 2 2
  1 2 3

正常来讲这两个完成了,也就手搓完毕了,但是以防万一面试官威胁你,我们继续完成onceoff

onceoff

先看下我们会如何实现它,我们订阅得再多,只要在emit之前off掉就无法实现打印

csharp 复制代码
  ev.on('run', fn)
  ev.on('run', fn)
  ev.on('run', fn)
  ev.on('run', fn)
  ev.emit('run', 1, 2)

所以off的形参就是取消的事件,和哪个回调

依旧是先判断,如果事件本身就不存在,就不存在取消一说,否则就是有人订阅过,我们直接把这个回调从event对象中移除掉就可以,移除指定对象的指定位置,可以用filter过滤掉

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

测试下

csharp 复制代码
  ev.on('run', fn)
  ev.on('run', fn2)
  ev.on('run', fn3)
  ev.off('run', fn3)
  ev.emit('run', 1, 2)
  // 1 2 1
  // 1 2 2

没有问题~

最后来到once,once的使用场景就是订阅一次之后无法订阅了

once同样需要和on一样,但只认第一次on,所以订阅一次后取消掉就可以,取消就用off取消,我们可以拿on中的回调放到once里调用,然后取消即可,记得传参

typescript 复制代码
once(type, cb) {
	const fn = (...args) => {
		cb(...args)
		this.off(type, fn)
	}
	this.on(type, fn)
}

测试下

csharp 复制代码
  ev.on('run', fn)
  ev.once('run', fn2)
  ev.on('run', fn3)
  ev.emit('run', 1, 2)
  ev.emit('run', 1, 2)
  ev.emit('run', 1, 2)
  // 1 2 1
  // 1 2 2 
  // 1 2 3 
  // 1 2 1
  // 1 2 3 
  // 1 2 1
  // 1 2 3

好了,发布订阅手搓完毕,完整代码丢一份

typescript 复制代码
class EventEmitter {
    constructor() {
        this.event = {}  // 'run': function
    }
    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)
        }
    }
  }

这个代码需要做到闭着眼睛能写才行,才能过面试这关,它的考察频率非常高

最后

发布订阅其实我们一般情况下是用不上的,但是为了应对面试,我们最好还是要学会手搓,理解他的原理,如果说真的要用的话可能当你想封装高级库的时候会用上这个设计模式

本期文章为了讲解发布订阅带大家仔细认识了一下Event的构造函数以及影子dom

另外有不懂之处欢迎在评论区留言,如果觉得文章对你学习有所帮助,还请"点赞+评论+收藏"一键三连,感谢支持!

本次学习代码已上传至本人GitHub学习仓库:github.com/DolphinFeng...

相关推荐
崔庆才丨静觅5 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby60615 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了6 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅6 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅6 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅6 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment7 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅7 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊7 小时前
jwt介绍
前端