Solving the Trilingual Challenge
DOM components that follow the separation of concerns dictumby Joe Honton
Getting started with DOM components is straightforward. An afternoon of research reveals the basic landscape: custom elements, shadow DOM, HTML
As my own understanding of the DOM component landscape has progressed, my work has coalesced into a common pattern that meets that challenge, which I’ve charted here.
Separation of concerns pattern
A hypothetical component named
rwt-foo would be structured like this:
Notably, these components have no abstract classes or framework code. Everything that is needed by the browser to define, decorate, and interact with the component is self-contained. This makes it possible to distribute and use the component in new settings without dependencies.
To access the component, the three files are placed in a known location on the webserver. I like to put them in a directory named
node_modules so that I can take advantage of the easy publish/distribute/install tools provided by NPM. But this is just a convention. NPM's module loader is never invoked by the browser, so the component's files can be placed in any directory that the browser has access to.
The browser downloads, parses, and interprets the component when it encounters a script like this somewhere in the document:
<script src='/node_modules/rwt-foo/rwt-foo.js' type=module></script>
The component can be declared and placed within the document’s body using a statement like this:
The above two lines of code are all that’s needed within the document. But they only load the
The fetch and cache pattern
Here’s the fetch and cache pseudo-code used to get the component’s HTML template:
getHtmlFragmentfunction returns a Promise to an HTML fragment. Callers should await its fulfillment.
- This pattern provides a caching mechanism for the HTML fragment so that only the first component instance needs to make the network request to the server.
- Each instance begins by setting up a listener for the rwt-foo-html-template-ready message.
- On the first instance only, an asynchronous fetch is issued for the template located at
htmlURL. As the response is streamed to the browser, it is saved to the static cache
htmlText. When complete, it broadcasts a
- Each instance that is listening for the broadcast message calls an inner closure that creates an HTML
<template>element, copies the contents of the static cache into the element's
innerHTML, and finally resolves the outer promise with the template's content.
The same fetch and cache pattern is used by
getCssStyleElement to get the CSS stylesheet:
Async initialization pattern
DOM components do not block the execution of the main thread. This means that we must somehow initialize the component and put it into a stable state before sending it requests. It’s the component’s responsibility to let us know when it is fully loaded and ready for use. Here’s an outline of an asynchronous pattern that does that.
The browser does two things: 1) It calls the class constructor to create an object instance and declare all of the class properties. 2) It calls the object’s
connectedCallback function to initialize the component.
Here’s a walk-through of the four things I put in every
First are the two asynchronous calls to fetch the HTML template and the CSS stylesheet, using the pattern just laid out.
Second is the creation of the component’s shadow DOM element tree, which is disconnected from the document’s main DOM tree and separated by a firewall. The HTML fragment and the CSS stylesheet created in the first step are then added to the shadow DOM tree. From this point forward, all child element references and listeners use the
shadowRoot variable to access the component's HTML elements and CSS selectors.
Third is the identification of each named element that the component will be accessing or manipulating while performing its work. These are saved as properties of the
RwtFoo class so that they are available for use by any of its worker methods. Identification is carried out using the
getElementById method of shadowRoot. For example, an HTML element like
<button id='bar'> would be identified using a statement like:
this.bar = this.shadowRoot.getElementById('bar');
Any event listeners that need to be registered are setup using these class properties. When doing this it’s important to remember to bind the callback functions to the class instance, so that they have access to any properties they may need. For example:
Many components will have additional initialization work beyond these three essentials steps. Every component has different needs, so abstracting that work into a standard callback is counterproductive. Instead, simply insert any special functions where it makes the most sense between steps one, two, and three.
The final initialization step is to broadcast the
rwt-foo-component-loaded message so that any asynchronous awaits can be resolved. As a convenience, external code can call the
waitOnLoading function which returns a Promise that resolves when the broadcast is received.
Some popular component frameworks have ignored the importance of having separate languages for scripts, templates, and styles. Instead, they’ve regressed to the older all-in-one model that was popular twenty years ago.
It doesn’t have to be that way. The three patterns outlined here can get us back on the right road.
- The fetch and cache pattern: retrieval and local caching of templates and style sheets.
- The async initialization pattern: putting resources, properties, and listeners into a ready state.
I’ve used pseudo-code above to make it easier to follow. For those interested in studying the real code I’ve published a collection of open source DOM Components that follow these patterns.