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")
}
Why doesn't it look more like this?
function ondoubleclick() {
app.activatePanel(panelId)
panel.focusOn(idx)
panel.activateItem()
}
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)
}
}
}
}
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)
}
}
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)
}
}
})
}
}
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
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)
Translates to this:
(new EventTarget(eventBus, "app")).activatePanel(panelId)
Which then gets bamboozled to:
(new Proxy(eventTarget, {get: ourGetFunction})).activatePanel(panelId)
Which translates to:
proxy.get("activatePanel")(panelId)
Which translates to:
((...args) => { eventBus.emit("app", name, ...args) })(panelId)
Which finally runs as:
eventBus.emit("app", name, panelId)
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()
}
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")
}
More Proxies?
We could use second layer of proxies so instead of:
let app = eventBus.target("app")
let panel = eventBus.target(panelId)
We could then say:
let app = eventBus.app
let panel = eventBus[panelId]
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]
And then use it with code like this:
let app = eventBus.app
let panel = eventBus[panelId]
let activePanel = eventBus.activePanel
app.switchPanel(panelId)
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]
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:
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.
Top comments (0)