Why Ringa Part 2: The DOM Tree
Over the lifecycle of any new framework new patterns evolve in how components can communicate to each other. Like cells evolving into multicellular organisms and multicellular organisms evolving into animals so each framework tends to repeat history.
We decided to learn from the past and started with the basics of component communication.
Applications need component communication. Due to the way CPUs work and the way that most languages are constructed, the most performant and simplest way to do this is via a method:
// Inside component2
component1.doSomething()
Unfortunately, this means that component2
needs access to component1
. In order to tell component1
what to do, component2
has to know about component1
. As the program grows, the program becomes brittle. Changing one thing requires changing a ton of code. Bugs pop up everywhere. What if someone changes how component1
works? What if we need to reorganize the view? Refactoring becomes a nightmare when the structure of the application or interfaces between the components change.
Clearly we need a better way.
Let's invent some form of an event bus, like this:
eventBus.register(component1)
eventBus.register(component2)
...
eventBus.register(componentN)
...
eventBus.emit('someMessageForGodKnowsWho')
Every component registered with the event bus gets every message. And each component can decide what it wants to do with that message.
Once again, the downside is performance at scale. For example, a DOM might have upwards of thousands of elements. If each emitted event (like 'mousemove'
) has to be handled by every other DOM node in the DOM tree, then this means we have to do at least 10,000 operations for every event. This is slow.
eventBus.on('someMessage', component1.handleSomeMessage)
eventBus.on('someMessage', component2.handleSomeMessage)
eventBus.on('someOtherMessage', component3.handleSomeOtherMessage)
...
eventBus.emit('someMessage')
This is a huge step up. Now component3
does not have to worry about emitted messages it does not care about.
There is one big problem with this approach. What if component2 needs to get the message first? component1
has priority because it added a listener first!
eventBus.on('someMessage', component1.handleSomeMessage, 0)
eventBus.on('someMessage', component2.handleSomeMessage, 1)
...
eventBus.emit('someMessage')
Now in this case, component2
has a priority of 1 for 'someMessage'
and the eventBus
can intelligently hand the message to that component first even though `component`` asked for the message first.
But what if component2
handles the message and decides the message needs to be cancelled so nobody else can hear it?
eventBus.on('someMessage', component1.handleSomeMessage, 0)
eventBus.on('someMessage', component2.handleSomeMessage, 1)
...
component2.handleSomeMessage('someMessage', (event) => {
event.cancel();
});
...
eventBus.emit(new Event('someMessage'))
Excellent, now component1
does not get the message.
Unfortunately, now we have a bigger problem. What if some rogue developer decides they just want to get all priority for all messages? They could just pass in a super high priority for everything and screw everyone over. What if someone gives themselves a super high priority and then cancels the event? Another developer might sit there scratching their head wondering why in the world their event listener is not working. This brings up our next problem: how is component2
supposed to know what component1
's priority is so that it can set the priority properly?
Clearly, event priorities started with a good intention but do not work well at scale, so they do not answer Question 3 or Question 4.
Computer science tells us that all structures with at least 3 items can take the form of a tree or a graph. Graphs are inherently unruly when it comes to management. After all, if everyone can talk to everyone else and nobody has priority, who is in charge?
Most programming structures that involve communication tend to organize into a tree. Your file system, operating system processes, and almost all GUI display structures are based on trees. In a tree, parents have one or more children, and every child only has only one parent. The parent always has complete control over the child and must have priority in handling all events. If you delete a directory, all its children are deleted, for example. If you changes permissions on a directory you naturally change the permissions on all of its descendants.
One reason this tree structure works so well for communication is that all communication can be accomplished in an ordered manner if we say that each node can only communicate to:
- Its parent
- All its children
If a child needs to communicate to another child, it should do so through its parent. This allows everything to talk to everything else, but with a natural order of prioritization.
Heck, even our eventBus
examples above are a tree structure with one root node (the eventBus
) and N leaf nodes (the component
s) with a depth of 1. The guideline was that all leaves in the tree can only communicate to each other if they communicate through the root node. So component1
can only communicate to component2
through the eventBus
.
Combining everything we have learned so far, we can communicate with our parent and all our children like so:
class Component {
constructor(parent) {
this.children = [];
this.parent = parent;
}
addChild(child) {
this.children.push(child);
}
emit(event) {...}
}
...
component.parent.emit(message);
component.children.forEach(child => child.emit(message);
What if something really important happens in a child and it needs to tell all of its ancestors (parent, parent's parent, etc.) about the event?
We can do this:
let parent = component.parent;
while (parent !== undefined) { // root node doesn't have a parent!
parent.emit('someMessage');
parent = parent.parent;
}
This is nice, but it is also requires 5 lines of code. It's not very practical. Coming back to Question 2, this is not very fun to write every time. Also more code means more bugs, so it is also not very scalable.
So what if we do this instead:
class Component {
emit(event) {...}
emitToAncestors(event) {
let parent = this.parent;
while (parent !== undefined) { // root node doesn't have a parent
parent.emit('someMessage');
parent = parent.parent;
}
}
emitToChildren(event) {
this.children.forEach(child => child.emit('someMessage');
}
}
Now we are getting somewhere. Here we can make this better by employing a principle:
When a method name is duplicated with a different qualifier, refactor the qualifier into a parameter
So let's change it up:
class Component {
// emitToAncestors(event) {...} DELETE
// emitToChildren(event) {...} DELETE
emit(event, toAncestors, toChildren) {...}
}
Much Better!
Unfortunately, this means that each instance of Component
is now responsible for stepping through each ancestor and emitting the event on each one. What if an ancestor wants to stop the message from going further? With a little bit of refactoring we move bubblesToAncestors
and propagatesToDescendents
into the Event
:
class Event {
constructor(type, details, bubblesToAncestors, propagatesToDescendants) {...}
cancel() {
this.cancelled = true;
}
}
...
class Component {
addListener(eventType, handler) {
this.listeners[eventType].push(handler);
}
emit(event) {
if (event.cancelled) {
return;
}
// 1st Priority: Parent
if (event.bubblesToAncestors && this.parent) {
this.parent.emit(event);
}
// 2nd Priority: Ourself
if (this.listeners[event.type] && this.children) {
this.listeners[event.type].forEach(listener => listener(event));
}
// 3rd Priority: Children
if (event.propagatesToDescendants) {
this.children.forEach(child => child.emit(event);
}
}
}
...
// Send a message to everything in the entire tree!
component1.emit(new Event('someMessage', {}, true, true);
Note: some of you might notice that this is a combination of a parent walk and a breadth-first search algorithm.
Now each item that receives the event can check to see if event.bubblesToAncestors
or event.propagatesToDescendants
is set and then emit it appropriately only if the event has not been cancelled.
But now we have a scaling issue again. Unfortunately propagatesToDescendants
is going to be ridiculously slow if it is dispatched from the root node and there are 10,000 descendants. When a program is small and only has 100 descendants, it might be tempting to dispatch lots of events from the root node but once the program scales this is going to get exponentially slower.
To be on the safe side, we remove propagatesToDescendants
to protect developers from ourselves.
Now we still need a way for parents to communicate to descendants. We know that children can easily look through their ancestors and listen for events.
What if we invert control and let child components listen to the root directly, similar to employees in an organization subscribing to their boss' blog:
class Event {
constructor(type, detail, bubbles);
}
...
class Component {
emit(event) {...}
on(eventType, handler) {...}
get parent() {
return parent;
}
get root() {
return this.parent ? this.parent.root : this;
}
}
...
class MyComponent {
constructor() {
// SUBSCRIBE TO ROOT MESSAGE
this.root.on('someImportantMessage', () => {
console.log('Yeah, so, the president just sent me an email...');
});
}
}
Excellent, now if the root node has to say something super important, every single component has the ability to listen directly to root without root having to emit to every descendant in the application.
Now we can take all of the principles we have discovered and build out a basic event system that works well with the natural tree structure of a browser client.
Let's start by organizing our naming to match the way this is implemented in the DOM according to the W3C and specifications:
-
on()
becomesaddEventListener()
-
emit()
becomesdispatchEvent()
-
get root()
becomesdocument
orwindow
-
cancel()
is calledstopPropagation()
-
Component
isEventTarget
-
parent
isparentNode
Here is the example code with renaming:
class Event {
constructor(type, detail, bubbles);
stopPropagation() {...}; // cancel
}
...
class EventTarget {
dispatchEvent(event) {...}
addEventListener(eventType, handler) {...}
get children()
get parentNode()
}
Now any listener for the event in the ancestor tree can cancel the event any time it wants!
At this point you can take some time to explore the entire event lifecycle of the DOM. Things I have not covered include:
-
stopPropagation()
vs.stopImmediatePropagation()
- 'capture' vs. 'bubble' phases of an events life
Needless to say the DOM event system across all browsers is powerful and is used for every 'click'
, 'keydown'
, 'mousemove'
, etc. event handled by the browser. By using CustomEvent
anyone can do all communication in your program while ensuring high performance, a natural tree-structured prioritization, and separation of concerns.
Ringa uses the built-in browser event system instead of its own event bus. The reasons for this are numerous, but here are a few that are super useful:
- Reuse of concepts developers are already forced to learn
- Ringa controllers can be easily given an area of responsibility depending on where on the view they are attached
- Ringa controllers can respond to view events (like listening for all clicks in the capture phase to run metrics on user interaction)
- Ringa controllers can communicate with each other easily through the DOM and bubbling or capture events
- By forcing Ringa controllers to be attached to the DOM we avoid the pitfalls of singletons
- By avoiding singletons we can easily create libraries that have both view and controllers and they can easily integrate with other versions of Ringa or even an application that does not use Ringa at all
Numerous problems of program communication are solved elegantly in the tree structure provided by the DOM.
In Part 2, we will discuss why Ringa has chosen to adopt using this event dispatching system for all of its communication.