We’re fans of Custom Elements around here. Their design makes them particularly amenable to lazy loading, which can be a boon for performance.
Inspired by a colleague’s experiments, I recently set about writing a simple auto-loader: Whenever a custom element appears in the DOM, we wanna load the corresponding implementation if it’s not available yet. The browser then takes care of upgrading such elements from there on out.
Chances are you won’t actually need all this; there’s usually a simpler approach. Used deliberately, the techniques shown here might still be a useful addition to your toolset.
For consistency, we want our auto-loader to be a custom element as well — which also means we can easily configure it via HTML. But first, let’s identify those unresolved custom elements, step by step:
class AutoLoader extends HTMLElement {
connectedCallback() {
let scope = this.parentNode;
this.discover(scope);
}
}
customElements.define("ce-autoloader", AutoLoader);
Assuming we’ve loaded this module up-front (using async
is ideal), we can drop a <ce-autoloader>
element into the <body>
of our document. That will immediately start the discovery process for all child elements of <body>
, which now constitutes our root element. We could limit discovery to a subtree of our document by adding <ce-autoloader>
to the respective container element instead — indeed, we might even have multiple instances for different subtrees.
Of course, we still have to implement that discover
method (as part of the AutoLoader
class above):
discover(scope) {
let candidates = [scope, ...scope.querySelectorAll("*")];
for(let el of candidates) {
let tag = el.localName;
if(tag.includes("-") && !customElements.get(tag)) {
this.load(tag);
}
}
}
Here we check our root element along with every single descendant (*
). If it’s a custom element — as indicated by hyphenated tags — but not yet upgraded, we’ll attempt to load the corresponding definition. Querying the DOM that way might be expensive, so we should be a little careful. We can alleviate load on the main thread by deferring this work:
connectedCallback() {
let scope = this.parentNode;
requestIdleCallback(() => {
this.discover(scope);
});
}
requestIdleCallback
is not universally supported yet, but we can use requestAnimationFrame
as a fallback:
let defer = window.requestIdleCallback || requestAnimationFrame;
class AutoLoader extends HTMLElement {
connectedCallback() {
let scope = this.parentNode;
defer(() => {
this.discover(scope);
});
}
// ...
}
Now we can move on to implementing the missing load
method to dynamically inject a <script>
element:
load(tag) {
let el = document.createElement("script");
let res = new Promise((resolve, reject) => {
el.addEventListener("load", ev => {
resolve(null);
});
el.addEventListener("error", ev => {
reject(new Error("failed to locate custom-element definition"));
});
});
el.src = this.elementURL(tag);
document.head.appendChild(el);
return res;
}
elementURL(tag) {
return `${this.rootDir}/${tag}.js`;
}
Note the hard-coded convention in elementURL
. The src
attribute’s URL assumes there’s a directory where all custom element definitions reside (e.g. <my-widget>
→ /components/my-widget.js
). We could come up with more elaborate strategies, but this is good enough for our purposes. Relegating this URL to a separate method allows for project-specific subclassing when needed:
class FancyLoader extends AutoLoader {
elementURL(tag) {
// fancy logic
}
}
Either way, note that we’re relying on this.rootDir
. This is where the aforementioned configurability comes in. Let’s add a corresponding getter:
get rootDir() {
let uri = this.getAttribute("root-dir");
if(!uri) {
throw new Error("cannot auto-load custom elements: missing `root-dir`");
}
if(uri.endsWith("/")) { // remove trailing slash
return uri.substring(0, uri.length - 1);
}
return uri;
}
You might be thinking of observedAttributes
now, but that doesn’t really make things easier. Plus updating root-dir
at runtime seems like something we’re never going to need.
Now we can — and must — configure our elements directory: <ce-autoloader root-dir="/components">
.
With this, our auto-loader can do its job. Except it only works once, for elements that already exist when the auto-loader is initialized. We’ll probably want to account for dynamically added elements as well. That’s where MutationObserver
comes into play:
connectedCallback() {
let scope = this.parentNode;
defer(() => {
this.discover(scope);
});
let observer = this._observer = new MutationObserver(mutations => {
for(let { addedNodes } of mutations) {
for(let node of addedNodes) {
defer(() => {
this.discover(node);
});
}
}
});
observer.observe(scope, { subtree: true, childList: true });
}
disconnectedCallback() {
this._observer.disconnect();
}
This way, the browser notifies us whenever a new element appears in the DOM — or rather, our respective subtree — which we then use to restart the discovery process. (You might argue we’re re-inventing custom elements here, and you’d be kind of correct.)
Our auto-loader is now fully functional. Future enhancements might look into potential race conditions and investigate optimizations. But chances are this is good enough for most scenarios. Let me know in the comments if you have a different approach and we can compare notes!
Why not use
:not(:defined)
to query for unloaded elements.I would also use a different sort of autoloader which is more easily WebPackable. Something like
const elems = {
'my-elem': () => import('./my-elem.js')
};
// …
customElements.define(elem, await elemselem. default);`
Oh, I hadn’t thought of
:defined
in this context; that’s an excellent idea and much more elegant, thank you!I agree that an approach like that
elems
object can be advantageous in some scenarios (thus the cautionary aside in the introduction). In my case, I specifically didn’t want to rely on bundlers or similar tooling though.I am still dreaming of html imports. Think of any landing sections in separate html files. The important thing for me is I want for them to be shown in Sources tab along with others. So fetch is not helpful. So I tried a dedicated custom element with iframes loading separate html files in it, and that element is grabbing their documents contents and move them into our main one. That is solving my issue with showing them in Sources tab. But races are killing me… It works like 15 times out of 16.(
I’d be interested to see an example of your iframes approach, Michael; care to put it in a CodePen or Gist? (The latter would allow for dedicated discussions.)
I sympathize with the HTML Import mourning, though I go back and forth on what it could or should have been.