面试官:能不能手写一个发布订阅。从自定义事件->影子dom->ES6语法class->手写发布订阅

设计模式之发布订阅

这几天看小伙伴的面经的时候,看到一位小伙伴面试被问到设计模式发布订阅,所以今天我们就来学习一下设计模式之发布订阅!

发布订阅是vue源码里面非常核心的设计模式思想,也被人叫做观察者模式。

我们可以通过一个简单情景帮助大家理解发布订阅!就比如现在大家热议的比亚迪秦,作为一名顾客,你去到一家4s店想要购买一辆,然而,店中的现货已经售罄,接待员小姐姐告诉你,可以关注他们的官方公众号,一旦有进货就会提醒顾客,发布消息,而你关注公众号这样一个行为就是订阅,而这个公众号发布最新的消息就是发布了一个事件,而且这样一个公众号是大众型的,会有很多的订阅者!

一般呢,在面试当中不仅仅会聊一些发布订阅的概念,也有可能会让你手写一个发布订阅。

今天,我们就从自定义事件影子domclass再到手写发布订阅带大家详细学习一遍!

发布订阅使用

事件的本质就是模块对象之间的信息通信!

我们知道,在js当中有很多的事件包括:点击事件,鼠标事件,键盘事件和滚动事件等等,这些都是官方给我们打造好的一些事件,但是,有时候在一些情况下,我们需要自己手动写一个事件出来!

我们先介绍一下Event()

自定义事件Event()构造函数

Event()构造函数,创建一个新的事件对象Event,参考mdn:Event() – Web API 接口参考 | MDN (mozilla.org)

语法:

 event = new Event(typeArg, eventInit);

面试官:能不能手写一个发布订阅。从自定义事件->影子dom->ES6语法class->手写发布订阅

我拿到实践应用

<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

  1. 我们在页面上放上了一个100×100的小黑块盒子box
  2. let ev = new Event('look',{bubbles:true,cancelable:false,}):创建了一个名为look。允许冒泡不可取消的事件!
  3. 我们再拿到了box容器的dom,在通过addEventListener("look",()=>{})添加事件监听器订阅了这个事件!
  4. 订阅之后,我们还要发布事件! box.dispatchEvent(ev)就是在box上发布look事件。

这样,我们打开页面就可以看到!

面试官:能不能手写一个发布订阅。从自定义事件->影子dom->ES6语法class->手写发布订阅

因为我们没有规定触发的手段像是点击、滚动啊等等,所以这个事件一刷新就会自动触发。

就像是js官方的事件默认发布在全局中一样,不需要我们去发布,这里的自定义事件不需要发布在全局,这是因为全局本身默认就订阅了这个事件,一旦发布,就会默认存在于全局当中!

接下来,我们一一介绍自定义事件的相关参数!

bubbles

表示该事件是否可以冒泡!我之前也过一期文章:面试官:js中的事件触发过程是什么样子的? – 掘金 (juejin.cn)

里面有介绍到事件冒泡和捕获,大家感兴趣的可以去看一下!

这里我就简单介绍一下冒泡:在 JavaScript 中,事件冒泡是指当一个事件在一个元素上触发时,该事件会沿着该元素的父元素依次传播,直到传播到最顶层的文档元素。

cancelable

表示该事件是否可以被取消!

这是什么意思呢?在事件监听器中,我们接收了一个事件参数event,我们打印一下这个事件参数!

box.addEventListener("look", (event) => {
    console.log(event);
    console.log('在box上触发了look事件');
})

面试官:能不能手写一个发布订阅。从自定义事件->影子dom->ES6语法class->手写发布订阅

在事件参数上,有这样一个方法!当我们把cancelable设置为true时,我们这样修改一下demo

<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>

我们可以看到此时将不会有任何输出:

面试官:能不能手写一个发布订阅。从自定义事件->影子dom->ES6语法class->手写发布订阅

composed

指示事件是否会在影子 DOM 根节点之外触发侦听器。

要搞懂这个参数有何作用,我们先得知道影子dom是个什么东西?

影子dom

我们直接通过一个小demo来了解什么是影子dom

<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->ES6语法class->手写发布订阅

我们可以看到影子dom也就是占据文档流的!

任何节点都会有一个attachShadow,用于创建一个影子dom

在我们的demo中,我们有一个root容器,我可以拿到这个容器使用attachShadow创建一个影子dom

影子dom结构要使用innerHTML进行影子dom结构的设计

值得注意的是:影子dom是不受全局样式的影响,我们要在innerHTML中放入style为影子dom添加样式!

原生的css定义变量使用的是–符进行变量设置,调用的时候要使用var(--变量名)的形式调用

重要!!!因为影子dom不受全局样式的影响,一般我们会用到影子dom来封装组件,可以实现样式分离,类似vue里面的scoped,毕竟你也不能确保别人的样式名不会和你组件的样式名重名!

attachShadow可以接收一个对象!

对象中有两个参数modedelewgatesFocus

其中mode 有两个值openclosed,意思是代表影子dom在外部的js是否可以访问到。

delegatesFocus焦点委托,用于指定我们去减轻一个自定义元素的聚焦的性能问题。

我们再介绍一下这两个参数的具体效果!

mode

当我们设置modeopen

// 外部js访问影子dom
console.log(root.shadowRoot);

)

面试官:能不能手写一个发布订阅。从自定义事件->影子dom->ES6语法class->手写发布订阅
当我们设置为closed时,这个打印将拿不到值

面试官:能不能手写一个发布订阅。从自定义事件->影子dom->ES6语法class->手写发布订阅

delegatesFocus

设置的是影子dom的聚焦优化!为turefalse,例如我们加了一个输入框,需要聚焦的时候,当设置为false,我们点击文字也会聚焦到输入框当中,当为true,点击文字就不会聚焦到输入框当中,这是一种优化!

到这里影子dom我们就介绍完毕了!

我们再回到自定义事件的第三个参数composed事件是否会在影子 DOM 根节点之外触发侦听器,如果我们给一个节点添加一个影子dom,并且在影子dom上订阅我们的自定义事件!并且发布事件

如果composedtrue,影子dom发布的事件,那么那个节点也能订阅到这个事件。

如果composedfalse,影子dom发布的事件,只有影子dom能订阅到这个事件。

<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也订阅到这个事件!

面试官:能不能手写一个发布订阅。从自定义事件->影子dom->ES6语法class->手写发布订阅

我们将composed改为false

面试官:能不能手写一个发布订阅。从自定义事件->影子dom->ES6语法class->手写发布订阅

我们就可以看到节点box拿到不到影子dom发布的事件了!

CustomEvent()

CustomEventEvent的继承子类,继承了Event的内容

这个构造函数一般用于我们做某个程序出于某种目的来创建的事件!简单一点的语法

<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>

大家可以了解一下这个知识点!

接下来,我们来做一个小挑战!

发布订阅小挑战

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

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

我们如何不使用promise,让请求A先完成,再让请求B完成!

使用我们刚刚学的发布订阅手段!实现这样一个效果!

先说一下思路!我们可以自定义一个事件,设置一个事件监听器,订阅这个事件,当事件发布之后再调用请求B,而事件的发布我们可以写在请求A当中。

<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>

面试官:能不能手写一个发布订阅。从自定义事件->影子dom->ES6语法class->手写发布订阅

这样我们就完成了!!

手写发布订阅

接下来我们就可以开始手写发布订阅了!

在手写发布订阅之前,我们要先学习一下ES6中的class语法!

ES6之class语法

在ES5的语法,我们是这样定义构造函数的

// 普及一下构造函数方法
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出世拉!

//在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的语法

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的语法我们就介绍到这里啦!

面试题:手写发布订阅

在面试当中,一般我们会遇到这样的面试题,面试官会给你一个题目!

class EventEmitter {
    constructor() {
    }
    // 用于订阅
    on() {
    }
    // 订阅一次
    once() {
    }
    //用于发布事件
    emit() {
    }
    //用于取消订阅
    off() {
    }
}

叫你实现这样一个发布订阅的功能!

拿到这个面试题,我们首先要完成一下onemit,发布订阅!

on和emit

on负责订阅事件!emit负责发布事件。

我们要怎么实现这样一个逻辑功能呢?

思路:on负责接收回调函数,订阅事件,什么时候emit执行,这个回调函数就触发!这个时候,我们on的逻辑,就是拿到事件和事件的回调,并且将其存在一个对象当中。然后emit去负责去对象当中遍历对应的事件,如果没有,说明没有订阅,直接返回,如果存在,就将对象当中的回调函数数组解构出来,并且调用!(是回调函数数组的原因:因为可能有多个节点订阅同一个事件,存在多个回调函数!)注意:回调函数的调用也要用解构!因为回调函数可能接收的不止一个参数

先写构造器constructor

constructor() {
    // 用数组或者对象
    this.event = {}// 'run':[fun]
}

event就是使用key-value的形式存储事件和回调函数数组!

然后,我们实现一下on函数

on(type, cb) {
    // 如果读不到这个事件
    if (!this.event[type]) {
        this.event[type] = [cb];
    } else {
        // 如果曾经有人订阅过,我就把我的订阅事件的回调函数加进去
        this.event[type].push(cb);
    }
}

on函数接收一个type表示的是事件的名称,cb表示接收的回调函数

通过this访问event,如果有这个事件,就把订阅的回调函数加入到数组当中,如果没有,就新建一个事件和对应的回调数组!

再实现以下emit函数

emit(type, ...args) {
    if (!this.event[type]) {
        return
    } else {
        this.event[type].forEach(cb => {
            // 打...把数组解构出来
            cb(...args)
        })
    }
}

同样,发布事件,我们去event找对应的事件,如果没有则返回,如果有!则把该事件的回调数组通过forEach遍历,调用掉这个事件中的所有回调函数!

这个,订阅发布功能我们就实现了!

目前的全部代码:

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函数

off(type, cb) {
    if (!this.event[type]) {
        return
    }else
    {
        // 使用filter过滤一下,排除了cb的对象
        this.event[type] = this.event[type].filter(item=>item!==cb)
    }
}

once函数

once(type,cb) {
    // 订阅一次就得取消
    const fn = (...args) =>{
        cb(...args)
        this.off(type,fn)
    }
    this.on(type,fn)
}

我们再通过几个案例测试一下!

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

到这里我们的手写订阅发布就完成啦,完整代码如下:

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之手写发布订阅

原文链接:https://juejin.cn/post/7340883460981489716 作者:Aidan路修远i

(0)
上一篇 2024年3月1日 上午11:03
下一篇 2024年3月1日 上午11:14

相关推荐

发表回复

登录后才能评论