How to create a frontend with de-coupled HTML Custom Components wasn't the most obvious thing to me. Did they communicate by a parent component calling a function on a child component, or by sending a custom event from one to the other? What if the two components weren't on the same ancestor-descendant line? What role do attributes play? How to avoid hard-coding function names or event names in both components?
Before I understood the intended way, I created a "message bus" script that caught custom events and re-sent them directly to the subscribed components. This was a mess. Not only are events hard to work with due to all the readonly properties and the still-amazing inability for vanilla javascript to deep-clone something, but I still had one component's custom event names hard-coded into all receiving components.
The correct solution is actually asymmetrical. One component publishes a custom event, which unlike element attributes, accepts complex payloads like arrays and objects. The receiver component exposes a javascript method that accepts a payload through ordinary parameters. Receiver components do NOT addEventListener and sender components do NOT call methods on other elements.
This setup is perfectly de-coupled, so it requires glue code at the top level. I went through a few iterations of this glue code, starting with the most minimal amount of code that would make things work, then immediately found trade-offs no matter what is done.
Here's the absolute minimal code I could come up with to make the components work together. It begins with an array of all custom event types, because there is no way to query a browser for a list nor any way to add a listener for "all custom events". (I'll come back to that little problem shortly.)
['drilldown', 'pieChartInit', 'showTip', 'hideTip']
.forEach(et =>
document.addEventListener(et, ev =>
document.querySelectorAll(`[${ev.type}]`).forEach(el =>
el?.[el.getAttribute(ev.type)]?.bind(el)(ev))));
It reads, "Add an event listener to the top-level for every custom event type. When one of these events happens, the handler will find all elements with an attribute of the same name as the event type, and the string value which that attribute is equal to shall be the method name to call on that element. The parameters to the method will always be the event itself, which includes its payload."
For example, for custom event type "drilldown", the code looks for all elements with a "drilldown" attribute, <some-component drilldown="method">
, and will use the value of that attribute, "method", as the method to call: someComponent.method(event)
.
I previously had a backup plan when getAttribute found an empty string, as in the case of <some-component drilldown>
, where it would use "drilldown" as the method to call as well, but that again couples the two components too tightly. Plus, attributes are always lowercased / case-insensitive while javascript is not, meaning method "drillDown" won't be found, leading to confusion.
For a component to use the code, it had to create a custom event which bubbles. This is extra typing at every dispatch call, which is tiresome. So I added the third parameter "true" to addEventListener so it catches during the Capture phase of an event, which tidies up all dispatch code everywhere.
['drilldown', 'pieChartInit', 'showTip', 'hideTip']
.forEach(et =>
document.addEventListener(et, ev =>
document.querySelectorAll(`[${ev.type}]`).forEach(el =>
el?.[el.getAttribute(ev.type)]?.bind(el)(ev)), true));
The other tack I tried was not going with minimal code at all, and putting said code into a "library.js" file. With less emphasis on minimalism, I added a few quality-of-life features. I prepended "on" to the event type, so the event "drilldown" would be caught with attribute "onDrilldown", leading to more readable HTML: <some-component onDrilldown="method">
. I added a "register" function which avoided the need to list all possible custom event types in the app in the initial string array. Finally, I added a "say" function which the components could use to dispatch a custom event more succinctly than the native syntax, which also registered the event type if it hadn't been already, avoiding the need for the components to call register at startup.
const registeredCustomEventTypes = {};
export function say(element, customEventType, detail) {
if (!registeredCustomEventTypes[customEventType])
register(customEventType);
element.dispatchEvent(new CustomEvent(customEventType, { detail }));
}
export function register(customEventType) {
document.addEventListener(customEventType, go, true);
registeredCustomEventTypes[customEventType] = true;
}
function go(event) {
const onEventType = 'on' + event.type;
document.querySelectorAll(`[${onEventType}]`).forEach(element =>
element?.[element.getAttribute(onEventType) || onEventType]?.bind(element)(event));
}
I think using this library code may be a bad idea. One advantage of HTML Custom Elements is creating a true single-file component: zero dependencies, not even on a library. We lose that advantage by including the library. Secondly, it requires each component that is used with it to be aware of this library code and interact with at least its register function, which starts coupling components together again, albeit through a common intermediary. Although that could be avoided by putting all the require calls in the top-level glue code, that's just a more verbose version of the string array in my first code sample.
I find it odd that browsers have no built-in Event Registry for custom events. Even the minimal code still slightly couples components together by passing the raw event to the handler, instead of picking out the necessary information from the event's payload and giving the method only what it needs in whatever format it wants it. That kind of minor data transform, which perfectly preserves the components' individuality, makes every event-to-method tie unique.
And if every event-to-method tie is unique, then every handler is unique, which means every addEventListener on the root is unique, which means there can be no general message bus at all. Whatever form such a bus would take, it implicitly demands all components conform to its interface, just as my library.js did. There's no way to win here.
document.addEventListener('drilldown', e => {
document.querySelectorAll('chart-legend').repopulate(e.detail.fieldName, e.detail.newData);
});
document.addEventListener('drilldown', e => {
document.querySelectorAll('field-selector').refresh({
Old: e.detail.fieldName,
New: e.detail.meta.fields.map(f=>f.name),
});
});
// etc., forever
Top comments (2)
I don't get it why you want to 'drilldown' and not make every 'drilldown' element listen at a parent/document element.
Yes, you will have hardcoded Custom Event names (or just one); but you have the same hardcoding now with your 'drilldown' attribute
Plus,
querySelectorAll
can NEVER reach elements inside shadowDOMAn Event bus via (grand)parentNode/document is the way to go.
Also note the Event payload isn't restricted to data. You CAN send Function references.
See: jsfiddle.net/CustomElementsExample...
Um, how to explain this? The event attributes like onClick or onMyCustomEvent don't "belong" to the components on which they appear.
doesn't "own" or "implement" an onClick attribute; likewise doesn't hardcode anything about onDrilldown or any other event. Don't confuse the component's invocation with its definition.I don't understand your comment about queryselectorall. Custom Events work like built-in event regarding it. What of it?