DEV Community

Tomasz Wegrzanowski
Tomasz Wegrzanowski

Posted on

Electron Adventures: Episode 40: Event Bus API with ES6 Proxies

Our components communicate through event bus, and it does everything we want, and implementation is very simple.

On the other hand, event calls look messy. For example here's handler for double clicking a file:

  function ondoubleclick() {
    eventBus.emit("app", "activatePanel", panelId)
    eventBus.emit(panelId, "focusOn", idx)
    eventBus.emit(panelId, "activateItem")
  }
Enter fullscreen mode Exit fullscreen mode

Why doesn't it look more like this?

  function ondoubleclick() {
    app.activatePanel(panelId)
    panel.focusOn(idx)
    panel.activateItem()
  }
Enter fullscreen mode Exit fullscreen mode

Let's try to make it so!

Proxy

In a language like Ruby this would be extremely simple to implement with method_missing. Javascript unfortunately doesn't have anything like that. Or at least it didn't use to.

ES6 created Proxy, which is a special kind of object, which basicaly has method_missing and other metaprogramming. The name is fairly stupid, as it has a lot of uses other than proxying stuff, such as creating nice APIs.

Most people never heard of it, as it's ES6-only, and unlike the rest of ES6, this one is impossible to transpile with Babel. So as long as you had to support IE (through Babel transpilation), there was no way to use them.

Nowadays, they're actually used by some frameworks behind the scene like Vue, but due to awkward way they're created, few people use them in apps directly.

Also their performance is not amazing, but we're just trying to make nice API here.

Original EventBus implementation

Here's our starting point:

export default class EventBus {
  constructor() {
    this.callbacks = {}
  }

  handle(target, map) {
    this.callbacks[target] = { ...(this.callbacks[target] || {}), ...map }
  }

  emit(target, event, ...details) {
    let handlers = this.callbacks[target]
    if (handlers) {
      if (handlers[event]) {
        handlers[event](...details)
      } else if (handlers["*"]) {
        handlers["*"](event, ...details)
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Proxy implementation

We want eventBus.target("app") or eventBus.target(panelId) to return something we can then use with regular function calls. First part is very easy, we just create EventTarget object, passing bus and target as arguments:

export default class EventBus {
  constructor() {
    this.callbacks = {}
  }

  handle(target, map) {
    this.callbacks[target] = { ...(this.callbacks[target] || {}), ...map }
  }

  emit(target, event, ...details) {
    let handlers = this.callbacks[target]
    if (handlers) {
      if (handlers[event]) {
        handlers[event](...details)
      } else if (handlers["*"]) {
        handlers["*"](event, ...details)
      }
    }
  }

  target(t) {
    return new EventTarget(this, t)
  }
}
Enter fullscreen mode Exit fullscreen mode

Now we need to fake a fake object that's basically one big method_missing. Whichever method we call on it, it will return a function for calling that event:

class EventTarget {
  constructor(bus, target) {
    this.bus = bus
    this.target = target
    return new Proxy(this, {
      get: (receiver, name) => {
        return (...args) => {
          bus.emit(target, name, ...args)
        }
      }
    })
  }
}
Enter fullscreen mode Exit fullscreen mode

There's a lot to unpack here. First we set this.bus and this.target even though we strictly speaking don't need to, as they're in closure scope. It just makes it easier to read debug output in console, if we ever needed to debug code using such proxies.

Then we return a value from constructor. Returning a value from constructor? If you're used to just about any other language, you might be confused, as pretty much none of them support it - and even in Javascript it's very rare to actually use this feature. But constructor for a class can absolutely return something else than just a fresh instance of the class. Well, as long as that other thing is an object too, for some reason you cannot just return strings or numbers.

This is somehow valid Javascript:

class Cat {
  constructor() {
    return {cat: "No Cat For You!"}
  }
  meow() {
    console.log("MEOW!")
  }
}
let cat = new Cat() // what we returned here is not a Cat
cat.meow() // TypeError: cat.meow is not a function
Enter fullscreen mode Exit fullscreen mode

We have one good use case for this feature, returning Proxy when we create EventTarget. We even pass the original unwrapped object as this. But really we don't use it for anything, all we'll ever use on this object is get.

And this:

eventBus.target("app").activatePanel(panelId)
Enter fullscreen mode Exit fullscreen mode

Translates to this:

(new EventTarget(eventBus, "app")).activatePanel(panelId)
Enter fullscreen mode Exit fullscreen mode

Which then gets bamboozled to:

(new Proxy(eventTarget, {get: ourGetFunction})).activatePanel(panelId)
Enter fullscreen mode Exit fullscreen mode

Which translates to:

proxy.get("activatePanel")(panelId)
Enter fullscreen mode Exit fullscreen mode

Which translates to:

((...args) => { eventBus.emit("app", name, ...args) })(panelId)
Enter fullscreen mode Exit fullscreen mode

Which finally runs as:

eventBus.emit("app", name, panelId)
Enter fullscreen mode Exit fullscreen mode

How to use this?

Implementation was complicated behind the scenes, but then we have nicer API:

  let app = eventBus.target("app")
  let panel = eventBus.target(panelId)

  function onclick() {
    app.activatePanel(panelId)
    panel.focusOn(idx)
  }
  function onrightclick() {
    app.activatePanel(panelId)
    panel.focusOn(idx)
    panel.flipSelected(idx)
  }
  function ondoubleclick() {
    app.activatePanel(panelId)
    panel.focusOn(idx)
    panel.activateItem()
  }
Enter fullscreen mode Exit fullscreen mode

That looks considirably more readable than:

  function onclick() {
    eventBus.emit("app", "activatePanel", panelId)
    eventBus.emit(panelId, "focusOn", idx)
  }
  function onrightclick() {
    eventBus.emit("app", "activatePanel", panelId)
    eventBus.emit(panelId, "focusOn", idx)
    eventBus.emit(panelId, "flipSelected", idx)
  }
  function ondoubleclick() {
    eventBus.emit("app", "activatePanel", panelId)
    eventBus.emit(panelId, "focusOn", idx)
    eventBus.emit(panelId, "activateItem")
  }
Enter fullscreen mode Exit fullscreen mode

More Proxies?

We could use second layer of proxies so instead of:

let app = eventBus.target("app")
let panel = eventBus.target(panelId)
Enter fullscreen mode Exit fullscreen mode

We could then say:

let app = eventBus.app
let panel = eventBus[panelId]
Enter fullscreen mode Exit fullscreen mode

To do that we'd need to return a Proxy from EventBus constructor, which would redirect get calls to this.target. I'll leave this as an exercise for the reader.

Why do we need this?

The obvious question is: why do we need this?

Why can't we just do this instead (in App.svelte):

  eventBus.app = {switchPanel, activatePanel, quit, openPalette, closePalette}
  eventBus.activePanel = eventBus[$activePanel]
Enter fullscreen mode Exit fullscreen mode

And then use it with code like this:

  let app = eventBus.app
  let panel = eventBus[panelId]
  let activePanel = eventBus.activePanel

  app.switchPanel(panelId)
Enter fullscreen mode Exit fullscreen mode

There are two issues with this. First components are created in some order. So if one component wants to do this when it's initialized, the other component might not have sen its events yet, so eventBus.something might be undefined at that point. This can be worked around with some delayed callback or reactivity, but that's adding boilerplate to save some other boilerplate.

The bigger problem is with let activePanel = eventBus.activePanel. If we do that, it will set activePanel to point at whichever panel was active when this code was run, and it will never update. So we'd need to make it reactive, but on what?

If we do this:

$ activePanel = eventBus[$activePanelId]
Enter fullscreen mode Exit fullscreen mode

Then every component needs to access some store with ID of the active panel. So, even more boilerplate all over.

EventBus based solutions don't have such problems, as they only lookup target when event is actually triggered.

Result

Here's the results, identical to what we had before:

Episode 40 Screenshot

In the next episodes, we'll try out a framework you probably never heard about.

As usual, all the code for the episode is here.

Discussion (0)