We’re followers of Customized Components round right here. Their design makes them significantly amenable to lazy loading, which generally is a boon for efficiency.
Impressed by a colleague’s experiments, I not too long ago set about writing a easy auto-loader: At any time when a {custom} component seems within the DOM, we wanna load the corresponding implementation if it’s not obtainable but. The browser then takes care of upgrading such parts from there on out.
Likelihood is you gained’t really need all this; there’s often a less complicated method. Used intentionally, the methods proven right here may nonetheless be a helpful addition to your toolset.
For consistency, we would like our auto-loader to be a {custom} component as effectively — which additionally means we are able to simply configure it by way of HTML. However first, let’s establish these unresolved {custom} parts, step-by-step:
class AutoLoader extends HTMLElement {
connectedCallback() {
let scope = this.parentNode;
this.uncover(scope);
}
}
customElements.outline("ce-autoloader", AutoLoader);
Assuming we’ve loaded this module up-front (utilizing async
is right), we are able to drop a <ce-autoloader>
component into the <physique>
of our doc. That may instantly begin the invention course of for all baby parts of <physique>
, which now constitutes our root component. We may restrict discovery to a subtree of our doc by including <ce-autoloader>
to the respective container component as a substitute — certainly, we would even have a number of cases for various subtrees.
In fact, we nonetheless should implement that uncover
methodology (as a part of the AutoLoader
class above):
uncover(scope) {
let candidates = [scope, ...scope.querySelectorAll("*")];
for(let el of candidates) {
let tag = el.localName;
if(tag.contains("-") && !customElements.get(tag)) {
this.load(tag);
}
}
}
Right here we examine our root component together with each single descendant (*
). If it’s a {custom} component — as indicated by hyphenated tags — however not but upgraded, we’ll try to load the corresponding definition. Querying the DOM that approach could be costly, so we must be a little bit cautious. We will alleviate load on the primary thread by deferring this work:
connectedCallback() {
let scope = this.parentNode;
requestIdleCallback(() => {
this.uncover(scope);
});
}
requestIdleCallback
will not be universally supported but, however we are able to use requestAnimationFrame
as a fallback:
let defer = window.requestIdleCallback || requestAnimationFrame;
class AutoLoader extends HTMLElement {
connectedCallback() {
let scope = this.parentNode;
defer(() => {
this.uncover(scope);
});
}
// ...
}
Now we are able to transfer on to implementing the lacking load
methodology to dynamically inject a <script>
component:
load(tag) {
let el = doc.createElement("script");
let res = new Promise((resolve, reject) => {
el.addEventListener("load", ev => {
resolve(null);
});
el.addEventListener("error", ev => {
reject(new Error("did not find custom-element definition"));
});
});
el.src = this.elementURL(tag);
doc.head.appendChild(el);
return res;
}
elementURL(tag) {
return `${this.rootDir}/${tag}.js`;
}
Notice the hard-coded conference in elementURL
. The src
attribute’s URL assumes there’s a listing the place all {custom} component definitions reside (e.g. <my-widget>
→ /parts/my-widget.js
). We may provide you with extra elaborate methods, however that is ok for our functions. Relegating this URL to a separate methodology permits for project-specific subclassing when wanted:
class FancyLoader extends AutoLoader {
elementURL(tag) {
// fancy logic
}
}
Both approach, observe that we’re counting on this.rootDir
. That is the place the aforementioned configurability is available in. Let’s add a corresponding getter:
get rootDir() {
let uri = this.getAttribute("root-dir");
if(!uri) {
throw new Error("can not auto-load {custom} parts: lacking `root-dir`");
}
if(uri.endsWith("https://css-tricks.com/")) { // take away trailing slash
return uri.substring(0, uri.size - 1);
}
return uri;
}
You could be considering of observedAttributes
now, however that doesn’t actually make issues simpler. Plus updating root-dir
at runtime looks like one thing we’re by no means going to want.
Now we are able to — and should — configure our parts listing: <ce-autoloader root-dir="/parts">
.
With this, our auto-loader can do its job. Besides it solely works as soon as, for parts that exist already when the auto-loader is initialized. We’ll most likely wish to account for dynamically added parts as effectively. That’s the place MutationObserver
comes into play:
connectedCallback() {
let scope = this.parentNode;
defer(() => {
this.uncover(scope);
});
let observer = this._observer = new MutationObserver(mutations => {
for(let { addedNodes } of mutations) {
for(let node of addedNodes) {
defer(() => {
this.uncover(node);
});
}
}
});
observer.observe(scope, { subtree: true, childList: true });
}
disconnectedCallback() {
this._observer.disconnect();
}
This fashion, the browser notifies us each time a brand new component seems within the DOM — or somewhat, our respective subtree — which we then use to restart the invention course of. (You may argue we’re re-inventing {custom} parts right here, and also you’d be form of appropriate.)
Our auto-loader is now totally purposeful. Future enhancements may look into potential race situations and examine optimizations. However likelihood is that is ok for many situations. Let me know within the feedback you probably have a distinct method and we are able to evaluate notes!