Custom Event System


I had this idea the other day of making a matrix of HTML elements that could share data among themselves through some kind of 'wire'. My idea was to click one of the elements in the matrix and have all elements tell me which element was clicked.


To accomplish that, my first thought was to use the default browser APIs (most likely something like the CustomEvent interface) but then decided that I wanted to create my own event handling system because reasons (just wanted to try doing it my own way) and for that I started looking into how the default event handling system work.


Whenever a JavaScript developers want to make an element listen for an event, it usually uses the default element.addEventListener() interface. This interface allows us to specify an event type (like 'click' or 'mouseover') and a callback function that will be executed when that event occurs on the element. The way I see it, there are four main things being handled by this method:


  1. Event: The event subscription system must be aware of the existance of a specific event;
  2. Subscription: An element opts in to listen for a specific event;
  3. Event broadcast: The system detects that an event has occurred and broadcasts it to all subscribed elements;
  4. Callback execution: The system executes the callback function from elements subscribed to the event. My system will be a little bit simpler than the browser's default event handling system in this regard: I'll add no scoping to the callback execution, meaning that if there are 5 elements subscribed to the 'click' event and any of them where clicked, all 5 elements will have their callbacks ran.

With this in mind, we are going to start with a simple system responsible for handling a single event that trigger a fixed callback function while the element simply subscribes to that event.


class EventBus {
  constructor({ eventName, callback }) {
    this.eventName = eventName
    this.callback = callback
    this.subscribedElements = []
  }

  broadcastEvent() {
    this.subscribedElements.forEach(el => {
      this.callback.call(this, el)
    })
  }

  subscribeElement(el) {
    this.subscribedElements.push(el)
  }
}

class EventBusElement {
  constructor({ eventBus }) {
    this.eventBus = eventBus
  }

  subscribe() {
    this.eventBus.subscribeElement(this)
  }
}

const eventBus = new EventBus({
  eventName: 'greet',
  callback: () => {
    console.log('Hey there!')
  }
})

const eventBusElement1 = new EventBusElement({ eventBus })
const eventBusElement2 = new EventBusElement({ eventBus })

// Subscribe the element to the event
eventBusElement1.subscribe()
eventBusElement2.subscribe()

// Make the event bus trigger the callback
eventBus.broadcastEvent()

  

And it works! Now we can modify this structure so it handles multiple events with different callback functions:


class EventBus {
  // Store events in the `events` object
  constructor() {
    this.events = {}
  }

  createEvent(eventName) {
    this.events[eventName] = {
      subscribedElements: [],
    }
  }

  // Modify the methods so they work with the new `events` object
  broadcastEvent(eventName) {
    const subscribedElements = this.events[eventName]?.subscribedElements

    if(!subscribedElements) return

    subscribedElements.forEach(subscription => {
      subscription.callback.call(subscription.element)
    })
  }

  subscribeElement(element, eventName, callback) {
    this.events[eventName]?.subscribedElements.push({
      element,
      callback
    })
  }
}

class EventBusElement {
  constructor({ eventBus }) {
    this.eventBus = eventBus
  }

  subscribe(eventName, callback) {
    this.eventBus.subscribeElement(this, eventName, callback)
  }
}

Then we can make the event declaration dynamic:


subscribeElement(element, eventName, callback) {
  // Creates the event before subscribing an element to it
  if(!this.events[eventName]) {
    this._createEvent(eventName)
  }

  this.events[eventName].subscribedElements.push({
    element,
    callback
  })
}

_createEvent(eventName) {
  this.events[eventName] = {
    subscribedElements: [],
  }
}

We can also can make it so elements can broadcast events to the event bus. And as a final touch, we can add a new data attribute to both the EventBusElement and EventBus classes so we can access data from both the subscribed element and from the element that broadcasted the event within the callback function:


class EventBus {
  constructor() {
    this.events = {}
  }

  broadcastEvent(eventName, eventData) {
    const subscribedElements = this.events[eventName]?.subscribedElements

    if(!subscribedElements) return

    subscribedElements.forEach(subscription => {
      subscription.callback.call(subscription.element, eventData)
    })
  }

  subscribeElement(element, eventName, callback) {
    if(!this.events[eventName]) {
      this._createEvent(eventName)
    }

    this.events[eventName].subscribedElements.push({
      element,
      callback
    })
  }

  _createEvent(eventName) {
    this.events[eventName] = {
      subscribedElements: [],
    }
  }
}

class EventBusElement {
  constructor({ eventBus, elementData }) {
    this.eventBus = eventBus
    this.elementData = elementData
  }

  subscribe(eventName, callback) {
    this.eventBus.subscribeElement(this, eventName, callback)
  }

  broadcastEventToEventBus(eventName, eventData) {
    this.eventBus.broadcastEvent(eventName, eventData)
  }
}

// Which allows us to do stuff like this:
const eventBus = new EventBus()
const eventBusElement1 = new EventBusElement({
  eventBus,
  elementData: {
    name: 'first element'
  }
})
const eventBusElement2 = new EventBusElement({
  eventBus,
  elementData: {
    name: 'second element'
  }
})

eventBusElement1.subscribe(
  'goodbye',
  function(eventData) {
    console.log(`Bye from ${this.elementData.name} triggered by ${eventData.elementName}`)
  }
)

eventBusElement2.subscribe(
  'goodbye',
  function(eventData) {
    console.log(`Goodbye from ${this.elementData.name} triggered by ${eventData.elementName}`)
  }
)

eventBusElement2.broadcastEventToEventBus('goodbye', { elementName: eventBusElement2.elementData.name })

  

👉

One thing i found out while working on this modification is that arrow functions don't have their own this context!


And thats it! Using this system, I was able to create my original idea