面试题, 实现一个Event类(发布订阅模式)

jianshu.com/p/594f018b6题目:使用 ECMAScript(JS)代码实现一个事件类Event ,包含下面功能:绑定事件、解绑事件和派发事件。

  • on(eventName, func): 监听 eventName事件, 事件触发的时候调用 func函数
  • emit(eventName, arg1, arg2, arg3,arg4...) : 触发eventName 事件, 并且把参数 arg1, arg2, arg3,arg4...传给事件处理函数
  • off(eventName, func) : 停止监听某个事件

在稍微复杂点的页面中,比如组件化开发的页面,同一个页面由两三个人来开发,为了保证组件的独立性和降低组件间耦合度,我们往往使用「订阅发布模式」,即组件间通信使用事件监听和派发的方式,而不是直接相互调用组件方法,这就是题目要求写的Event 类。

这个题目的核心是一个事件类型对应回调函数的数据设计。为了实现绑定事件,我们需要一个_cache对象来记录绑定了哪些事件。而事件发生的时候,我们需要从_cache 中读取出来事件回调,依次执行它们。一般页面中事件派发(读)要比事件绑定(写)多。所以我们设计的数据结构应该尽量地能够在事件发生时,更加快速地找到对应事件的回调函数们,然后执行。

class Event {
  constructor () {
    // 储存事件的数据结构
    // 为查找迅速, 使用对象(字典)
    this._cache = {}
  }

  // 绑定
  on(type, callback) {
    // 为了按类查找方便和节省空间
    // 将同一类型事件放到一个数组中
    // 这里的数组是队列, 遵循先进先出
    // 即新绑定的事件先触发
    let fns = (this._cache[type] = this._cache[type] || [])
    if(fns.indexOf(callback) === -1) {
      fns.push(callback)
    }
    return this
  }

  // 触发
  // emit
  trigger(type, data) {
    let fns = this._cache[type]
    if(Array.isArray(fns)) {
      fns.forEach((fn) => {
        fn(data)
      })
    }
    return this
  }
  
  // 解绑
  off (type, callback) {
    let fns = this._cache[type]
    if(Array.isArray(fns)) {
      if(callback) {
        let index = fns.indexOf(callback)
        if(index !== -1) {
          fns.splice(index, 1)
        }
      } else {
        // 全部清空
        fns.length = 0
      }
    }
    return this
  }
}
  • 这种事件【订阅发布模式】管理兄弟页面传值交互的,我们在vue里面用的就是中央事件总线,与此一样:

具体使用戳这里:

前端技术:开发一个vue中央事件总线插件vue-busbaijiahao.baidu.com图标
  • 我们这里不具体说明使用,而是来看看vue源码中的实现
vue数据双向绑定也用到了发布订阅模式
  • vue早期的有api dispatch和 brocast也是这种设计模式的实现。

我们可以自己实现一个 ---> 戳这篇文章

vue 2.x 仿dispatch和 brocastwww.jianshu.com图标

这个实现其实在iview的ui框架中有用:

https://github.com/iview/iview/blob/2.0/src/mixins/emitter.jsgithub.com
// https://www.jianshu.com/p/afb644a82bc3
// https://github.com/iview/iview/blob/2.0/src/mixins/emitter.js
function broadcast(componentName, eventName, params) {
  this.$children.forEach(child => {
    // 当前 Vue 实例的初始化选项
    const name = child.$options.name

    if (name === componentName) {
      // child.$emit.apply(child, [eventName].concat(params))
      // Suggest using the spread operator instead of .apply()
      // http://www.uedsc.com/eslint-rules-prefer-spread.html
      const newParams = [eventName, ...params]
      child.$emit(...newParams)
    } else {
      // todo 如果 params 是空数组,接收到的会是 undefined
      // broadcast.apply(child, [componentName, eventName].concat([params]));
      const componentNewParams = [componentName, eventName, ...params]
      broadcast(...componentNewParams)
    }
  })
}

export default {
  methods: {
    dispatch(componentName, eventName, params) {
      let parent = this.$parent || this.$root
      let name = parent.$options.name

      while (parent && (!name || name !== componentName)) {
        parent = parent.$parent

        if (parent) {
          name = parent.$options.name
        }
      }   
      if (parent) {
        // parent.$emit.apply(parent, [eventName].concat(params));
        parent.$emit(...[eventName, ...params])
      }
    },
    broadcast(componentName, eventName, params) {
      broadcast.call(this, componentName, eventName, params)
    }
  }
}

EventEmitter

上面的实现其实就是EventEmitter, 本质上是一个观察者模式的实现,所谓观察者模式:

它定义了对象间的一种一对多的关系,让多个观察者对象同时监听某一个主题对象,当一个对象发生改变时,所有依赖于它的对象都将得到通知。
观察者模式在前端开发中非常常用, 我们经常用的事件就是观察者模式的一种体现(上文已经介绍过vue中使用的事件总线), 它对我们解耦模块, 开发基于消息的业务起着非常重要的作用。

Node.js原生自带EventEmitter模块, 可见其重要性。

EventEmitter类允许我们注册一个或者多个函数作为监听者,当对应的事件触发后,它们就会被触发

因此最基本的EventEmitter功能,包含了一个观察者和一个被监听的对象,对应的实现就是EventEmitter中的on和emit:

// node 中的event模块使用
var events=require('events');
var eventEmitter=new events.EventEmitter();
eventEmitter.on('say',function(name){
    console.log('Hello',name);
})
eventEmitter.emit('say','Jony yu');

eventEmitter是EventEmitter模块的一个实例,eventEmitter的emit方法,发出say事件,通过eventEmitter的on方法监听,从而执行相应的函数

观察者模式发布/订阅模式区别

  • 从图中可以看出,观察者模式中观察者和目标直接进行交互,而发布订阅模式中统一由调度中心进行处理,订阅者和发布者互不干扰。
  • 观察者模式的订阅者与发布者之间是存在依赖的,而发布/订阅模式则不会。

发布/订阅模式优势在于, 这样一方面实现了解耦,还有就是可以实现更细粒度的一些控制。比如发布者发布了很多消息,但是不想所有的订阅者都接收到,就可以在调度中心做一些处理,类似于权限控制之类的。还可以做一些节流操作。


观察者模式:

 // 观察者
class Observer {
    constructor() {

    }
    update(val) {

    }
}
// 观察者列表
class ObserverList {
    constructor() {
        this.observerList = []
    }
    add(observer) {
        return this.observerList.push(observer);
    }
    remove(observer) {
        this.observerList = this.observerList.filter(ob => ob !== observer);
    }
    count() {
        return this.observerList.length;
    }
    get(index) {
        return this.observerList(index);
    }
}
// 目标
class Subject {
    constructor() {
        this.observers = new ObserverList();
    }
    addObserver(observer) {
        this.observers.add(observer);
    }
    removeObserver(observer) {
        this.observers.remove(observer);
    }
    notify(...args) {
        let obCount = this.observers.count();
        for (let index = 0; index < obCount; index++) {
            this.observers.get(i).update(...args);
        }
    }
}

发布订阅模式:


class PubSub {
    constructor() {
        this.subscribers = {}
    }
    subscribe(type, fn) {
        let listeners = this.subscribers[type] || [];
        listeners.push(fn);
    }
    unsubscribe(type, fn) {
        let listeners = this.subscribers[type];
        if (!listeners || !listeners.length) return;
        this.subscribers[type] = listeners.filter(v => v !== fn);
    }
    publish(type, ...args) {
        let listeners = this.subscribers[type];
        if (!listeners || !listeners.length) return;
        listeners.forEach(fn => fn(...args));        
    }
}

let ob = new PubSub();
ob.subscribe('add', (val) => console.log(val));
ob.publish('add', 1);

从上面代码可以看出,观察者模式由具体目标调度,每个被订阅的目标里面都需要有对观察者的处理,会造成代码的冗余。而发布订阅模式则统一由调度中心处理,消除了发布者和订阅者之间的依赖。

观察者模式跟我们平时用的事件也有一定的关系,比如:

ele.addEventListener('click', () => {});

addEventListener就相当于注册了一个观察者,当观察到‘click’事件的时候,作出一些处理。


export default class
{
    /**
     * Constructor
     */
    constructor()
    {
        this.callbacks = {}
        this.callbacks.base = {}
    }

    /**
     * On
     */
    on(_names, callback)
    {
        const that = this

        // Errors
        if(typeof _names === 'undefined' || _names === '')
        {
            console.warn('wrong names')
            return false
        }

        if(typeof callback === 'undefined')
        {
            console.warn('wrong callback')
            return false
        }

        // Resolve names
        const names = this.resolveNames(_names)

        // Each name
        names.forEach(function(_name)
        {
            // Resolve name
            const name = that.resolveName(_name)

            // Create namespace if not exist
            if(!(that.callbacks[ name.namespace ] instanceof Object))
                that.callbacks[ name.namespace ] = {}

            // Create callback if not exist
            if(!(that.callbacks[ name.namespace ][ name.value ] instanceof Array))
                that.callbacks[ name.namespace ][ name.value ] = []

            // Add callback
            that.callbacks[ name.namespace ][ name.value ].push(callback)
        })

        return this
    }

    /**
     * Off
     */
    off(_names)
    {
        const that = this

        // Errors
        if(typeof _names === 'undefined' || _names === '')
        {
            console.warn('wrong name')
            return false
        }

        // Resolve names
        const names = this.resolveNames(_names)

        // Each name
        names.forEach(function(_name)
        {
            // Resolve name
            const name = that.resolveName(_name)

            // Remove namespace
            if(name.namespace !== 'base' && name.value === '')
            {
                delete that.callbacks[ name.namespace ]
            }

            // Remove specific callback in namespace
            else
            {
                // Default
                if(name.namespace === 'base')
                {
                    // Try to remove from each namespace
                    for(const namespace in that.callbacks)
                    {
                        if(that.callbacks[ namespace ] instanceof Object && that.callbacks[ namespace ][ name.value ] instanceof Array)
                        {
                            delete that.callbacks[ namespace ][ name.value ]

                            // Remove namespace if empty
                            if(Object.keys(that.callbacks[ namespace ]).length === 0)
                                delete that.callbacks[ namespace ]
                        }
                    }
                }

                // Specified namespace
                else if(that.callbacks[ name.namespace ] instanceof Object && that.callbacks[ name.namespace ][ name.value ] instanceof Array)
                {
                    delete that.callbacks[ name.namespace ][ name.value ]

                    // Remove namespace if empty
                    if(Object.keys(that.callbacks[ name.namespace ]).length === 0)
                        delete that.callbacks[ name.namespace ]
                }
            }
        })

        return this
    }

    /**
     * Trigger
     */
    trigger(_name, _args)
    {
        // Errors
        if(typeof _name === 'undefined' || _name === '')
        {
            console.warn('wrong name')
            return false
        }

        const that = this
        let finalResult = null
        let result = null

        // Default args
        const args = !(_args instanceof Array) ? [] : _args

        // Resolve names (should on have one event)
        let name = this.resolveNames(_name)

        // Resolve name
        name = this.resolveName(name[ 0 ])

        // Default namespace
        if(name.namespace === 'base')
        {
            // Try to find callback in each namespace
            for(const namespace in that.callbacks)
            {
                if(that.callbacks[ namespace ] instanceof Object && that.callbacks[ namespace ][ name.value ] instanceof Array)
                {
                    that.callbacks[ namespace ][ name.value ].forEach(function(callback)
                    {
                        result = callback.apply(that, args)

                        if(typeof finalResult === 'undefined')
                        {
                            finalResult = result
                        }
                    })
                }
            }
        }

        // Specified namespace
        else if(this.callbacks[ name.namespace ] instanceof Object)
        {
            if(name.value === '')
            {
                console.warn('wrong name')
                return this
            }

            that.callbacks[ name.namespace ][ name.value ].forEach(function(callback)
            {
                result = callback.apply(that, args)

                if(typeof finalResult === 'undefined')
                    finalResult = result
            })
        }

        return finalResult
    }

    /**
     * Resolve names
     */
    resolveNames(_names)
    {
        let names = _names
        names = names.replace(/[^a-zA-Z0-9 ,/.]/g, '')
        names = names.replace(/[,/]+/g, ' ')
        names = names.split(' ')

        return names
    }

    /**
     * Resolve name
     */
    resolveName(name)
    {
        const newName = {}
        const parts = name.split('.')

        newName.original  = name
        newName.value     = parts[ 0 ]
        newName.namespace = 'base' // Base namespace

        // Specified namespace
        if(parts.length > 1 && parts[ 1 ] !== '')
        {
            newName.namespace = parts[ 1 ]
        }

        return newName
    }
}
export class EventBus {
  constructor() {
    this._listenerMap = {}
  }

  on(event, handler, context, ...args) {
    const listenerObj = {
      handler,
      context,
      args
    }
    if (this._listenerMap[event]) {
      this._listenerMap[event].push(listenerObj)
    } else {
      this._listenerMap[event] = [
        listenerObj
      ]
    }
  }

  once(event, handler, context, ...args) {
    const onceHandler = (combinedArgs) => {
      this.off(event, onceHandler, context)
      handler.apply(context, ...combinedArgs)
    }
    this.on(event, onceHandler, context, ...args)
  }

  off(event, handler, context) {
    if (event) {
      const listenerList = this._listenerMap[event] || []
      if (listenerList.length > 0) {
        if (handler) {
          const newListenerList = listenerList.filter((listenerObj) => {
            if (listenerObj.handler === handler && listenerObj.context === context) {
              return false
            }
            return true
          })
          this._listenerMap[event] = newListenerList
        } else {
          this._listenerMap[event] = []
        }
      }
    } else {
      this._listenerMap = {}
    }
  }

  emit(event, ...args) {
    const listenerList = this._listenerMap[event] || []
    if (listenerList.length > 0) {
      listenerList.forEach((listenerObj) => {
        listenerObj.handler.apply(listenerObj.context, args.concat(listenerObj.args))
      })
    }
  }
}

export default new EventBus()

参考

用es6方式的写的订阅发布的模式 - focus_yaya - 博客园www.cnblogs.com
循序渐进教你实现一个完整的node的EventEmitter模块 · Issue #21 · forthealllight/bloggithub.com图标https://github.com/Gozala/eventsgithub.com
观察者模式和发布订阅模式的区别www.jianshu.com图标EventEmitter classwww.jianshu.com图标https://github.com/fffixed/vue-bus/blob/master/vue-bus.jsgithub.com
[译] JavaScript 的发布者/订阅者(Publisher/Subscriber)模式juejin.im

weixin.qq.com/r/hElmfgP (二维码自动识别)

编辑于 14 小时前

文章被以下专栏收录