An Approach to Lazy Loading Custom Elements

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();
}

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:

Of course, we still have to implement that discover method (as part of the AutoLoader class above):

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;
}

class AutoLoader extends HTMLElement {
connectedCallback() {
let scope = this.parentNode;
this.discover(scope);
}
}
customElements.define("ce-autoloader", AutoLoader);

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.

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);
}
}
}

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.

class FancyLoader extends AutoLoader {
elementURL(tag) {
// fancy logic
}
}

connectedCallback() {
let scope = this.parentNode;
requestIdleCallback(() => {
this.discover(scope);
});
}

let defer = window.requestIdleCallback || requestAnimationFrame;

class AutoLoader extends HTMLElement {
connectedCallback() {
let scope = this.parentNode;
defer(() => {
this.discover(scope);
});
}
// ...
}

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!

We’re fans of Custom Elements around here. Their design makes them particularly amenable to lazy loading, which can be a boon for performance.

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:

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`;
}

Now we can — and must — configure our elements directory: <ce-autoloader root-dir="/components">.

Similar Posts