{"id":376991,"date":"2023-02-13T07:10:41","date_gmt":"2023-02-13T15:10:41","guid":{"rendered":"https:\/\/css-tricks.com\/?p=376991"},"modified":"2023-02-13T07:10:44","modified_gmt":"2023-02-13T15:10:44","slug":"an-approach-to-lazy-loading-custom-elements","status":"publish","type":"post","link":"https:\/\/css-tricks.com\/an-approach-to-lazy-loading-custom-elements\/","title":{"rendered":"An Approach to Lazy Loading Custom Elements"},"content":{"rendered":"\n
We’re 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\n\n\n Inspired by a colleague’s<\/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’s not available yet. The browser then takes care of upgrading such elements from there on out.<\/p>\n\n\n\n\n\n\n\n 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.<\/p>\n\n\n\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’s identify those unresolved custom elements, step by step:<\/p>\n\n\n\n Assuming we’ve loaded this module up-front (using Of course, we still have to implement that Here we check our root element along with every single descendant ( Now we can move on to implementing the missing Note the hard-coded convention in Either way, note that we’re relying on class AutoLoader extends HTMLElement {\n connectedCallback() {\n let scope = this.parentNode;\n this.discover(scope);\n }\n}\ncustomElements.define(\"ce-autoloader\", AutoLoader);<\/code><\/pre>\n\n\n\n
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\n\n\n
discover<\/code> method (as part of the
AutoLoader<\/code> class above):<\/p>\n\n\n\n
discover(scope) {\n let candidates = [scope, ...scope.querySelectorAll(\"*\")];\n for(let el of candidates) {\n let tag = el.localName;\n if(tag.includes(\"-\") && !customElements.get(tag)) {\n this.load(tag);\n }\n }\n}<\/code><\/pre>\n\n\n\n
*<\/code>). If it’s a custom element \u2014 as indicated by hyphenated tags \u2014 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:<\/p>\n\n\n\n
connectedCallback() {\n let scope = this.parentNode;\n requestIdleCallback(() => {\n this.discover(scope);\n });\n}<\/code><\/pre>\n\n\n\n
requestIdleCallback<\/code><\/a> is not universally supported yet, but we can use
requestAnimationFrame<\/code><\/a> as a fallback:<\/p>\n\n\n\n
let defer = window.requestIdleCallback || requestAnimationFrame;\n\nclass AutoLoader extends HTMLElement {\n connectedCallback() {\n let scope = this.parentNode;\n defer(() => {\n this.discover(scope);\n });\n }\n \/\/ ...\n}<\/code><\/pre>\n\n\n\n
load<\/code> method to dynamically inject a
<script><\/code> element:<\/p>\n\n\n\n
load(tag) {\n let el = document.createElement(\"script\");\n let res = new Promise((resolve, reject) => {\n el.addEventListener(\"load\", ev => {\n resolve(null);\n });\n el.addEventListener(\"error\", ev => {\n reject(new Error(\"failed to locate custom-element definition\"));\n });\n });\n el.src = this.elementURL(tag);\n document.head.appendChild(el);\n return res;\n}\n\nelementURL(tag) {\n return `${this.rootDir}\/${tag}.js`;\n}<\/code><\/pre>\n\n\n\n
elementURL<\/code>. The
src<\/code> attribute’s URL assumes there’s 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\n\n\n
class FancyLoader extends AutoLoader {\n elementURL(tag) {\n \/\/ fancy logic\n }\n}<\/code><\/pre>\n\n\n\n
this.rootDir<\/code>. This is where the aforementioned configurability comes in. Let’s add a corresponding getter:<\/p>\n\n\n\n
get rootDir() {\n let uri = this.getAttribute(\"root-dir\");\n if(!uri) {\n throw new Error(\"cannot auto-load custom elements: missing `root-dir`\");\n }\n if(uri.endsWith(\"\/\")) { \/\/ remove trailing slash\n return uri.substring(0, uri.length - 1);\n }\n return uri;\n}<\/code><\/pre>\n\n\n\n