{"id":1955535,"date":"2023-02-13T10:10:41","date_gmt":"2023-02-13T15:10:41","guid":{"rendered":"https:\/\/wordpress-1016567-4521551.cloudwaysapps.com\/plato-data\/an-approach-to-lazy-loading-custom-elements\/"},"modified":"2023-02-13T10:10:41","modified_gmt":"2023-02-13T15:10:41","slug":"an-approach-to-lazy-loading-custom-elements","status":"publish","type":"station","link":"https:\/\/platodata.io\/plato-data\/an-approach-to-lazy-loading-custom-elements\/","title":{"rendered":"An Approach to Lazy Loading Custom Elements"},"content":{"rendered":"
<\/div>\n

We\u2019re fans of Custom Elements<\/a> around here. Their design makes them particularly amenable to lazy loading<\/a>, which can be a boon for performance.<\/p>\n

Inspired by a colleague\u2019s<\/a> 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\u2019s not available yet. The browser then takes care of upgrading such elements from there on out.<\/p>\n

<\/span> <\/p>\n

Chances are you won\u2019t actually need all this; there\u2019s usually a simpler approach. Used deliberately, the techniques shown here might still be a useful addition to your toolset.<\/p>\n

For consistency, we want our auto-loader to be a custom element as well \u2014 which also means we can easily configure it via HTML. But first, let\u2019s identify those unresolved custom elements, step by step:<\/p>\n

class AutoLoader extends HTMLElement { connectedCallback() { let scope = this.parentNode; this.discover(scope); }\n}\ncustomElements.define(\"ce-autoloader\", AutoLoader);<\/code><\/pre>\n

Assuming we\u2019ve loaded this module up-front (using async<\/code><\/a> is ideal), we can drop a <ce-autoloader><\/code> element into the <body><\/code> of our document. That will immediately start the discovery process for all child elements of <body><\/code>, which now constitutes our root element. We could limit discovery to a subtree of our document by adding <ce-autoloader><\/code> to the respective container element instead \u2014 indeed, we might even have multiple instances for different subtrees.<\/p>\n

Of course, we still have to implement that discover<\/code> method (as part of the AutoLoader<\/code> class above):<\/p>\n

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); } }\n}<\/code><\/pre>\n

Here we check our root element along with every single descendant (*<\/code>). If it\u2019s a custom element \u2014 as indicated by hyphenated tags \u2014 but not yet upgraded, we\u2019ll 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:<\/p>\n

connectedCallback() { let scope = this.parentNode; requestIdleCallback(() => { this.discover(scope); });\n}<\/code><\/pre>\n

requestIdleCallback<\/code><\/a> is not universally supported yet, but we can use requestAnimationFrame<\/code><\/a> as a fallback:<\/p>\n

let defer = window.requestIdleCallback || requestAnimationFrame; class AutoLoader extends HTMLElement { connectedCallback() { let scope = this.parentNode; defer(() => { this.discover(scope); }); } \/\/ ...\n}<\/code><\/pre>\n

Now we can move on to implementing the missing load<\/code> method to dynamically inject a <script><\/code> element:<\/p>\n

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;\n} elementURL(tag) { return `${this.rootDir}\/${tag}.js`;\n}<\/code><\/pre>\n

Note the hard-coded convention in elementURL<\/code>. The src<\/code> attribute\u2019s URL assumes there\u2019s a directory where all custom element definitions reside (e.g. <my-widget><\/code> \u2192 \/components\/my-widget.js<\/code>). 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:<\/p>\n

class FancyLoader extends AutoLoader { elementURL(tag) { \/\/ fancy logic }\n}<\/code><\/pre>\n

Either way, note that we\u2019re relying on this.rootDir<\/code>. This is where the aforementioned configurability comes in. Let\u2019s add a corresponding getter:<\/p>\n

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;\n}<\/code><\/pre>\n

You might be thinking of observedAttributes<\/code> now, but that doesn\u2019t really make things easier. Plus updating root-dir<\/code> at runtime seems like something we\u2019re never going to need.<\/p>\n

Now we can \u2014 and must \u2014 configure our elements directory: <ce-autoloader root-dir=\"\/components\"><\/code>.<\/p>\n

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\u2019ll probably want to account for dynamically added elements as well. That\u2019s where MutationObserver<\/code><\/a> comes into play:<\/p>\n

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 });\n} disconnectedCallback() { this._observer.disconnect();\n}<\/code><\/pre>\n

This way, the browser notifies us whenever a new element appears in the DOM \u2014 or rather, our respective subtree \u2014 which we then use to restart the discovery process. (You might argue we\u2019re re-inventing custom elements here, and you\u2019d be kind of correct.)<\/p>\n

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!<\/p>\n