Some Web Components Hint
--
Another day on the Web, another library based on Web Components (ahem, Custom Elements) … but there’s something we need to talk about …
About bundling the library
Every single library getting started, including mines, suggest we should import the library in order to define what supposed to be a “portable Custom Element”.
import {LitElement, html} from 'lit-element';
import { FASTElement, customElement } from '@microsoft/fast-element';
import { Component, Prop, h } from '@stencil/core';
import HyperHTMLElement from 'hyperhtml-element';
The list goes on and on, and it became the standard way to create Custom Elements, implying, most of the time, the following:
- no way we can ship a Custom Element without having a toolchain
- no way 3rd parts can adopt/use a single component without bringing in its library size per each component they need
- if we impose our library/framework way to use our Custom Elements, we need to impose our toolchain to 3rd parts/teams
Apparently these problems are often ignored by libraries authors, but consumers will always need to understand these fundamentals “gotchas” when they make a choice.
Why is a toolchain needed?
Code split, so that the library can hopefully land once in the bundle entry point, is just one of the reasons. Tree shaking might be another, but here there’s another issue: tree-shaking means all possible components must be available upfront, likely ending up having the entirety of the library in the main bundle, so that all features used by all components will be available.
The issue with this approach is that the bundled library is repeatedly present in every page of our Web application, but as each page might be slightly different, as well as the amount of components used in it, each main bundle entry point could be slightly different too, implicitly ditching the ability to share the same library across all sections of the site.
This might be not such an issue for Single Page Applications … but!
Previously …
To have a better understanding of why this is not ideal, imagine if every site of the Web, for the last 10 years, had this line on top of each JS file:
import $ from 'jquery';$(document).ready(...);
Instead, jQuery has been proven to work as shared global dependency across million of sites and different pages, and the only extra cost for these pages was the code related to such page or its jQuery driven components.
Accordingly, while last years surely brought extremely advanced techniques to deliver code in an optimized way, we all did a step backward, forcing the inclusion of our libraries everywhere.
Side note: the global $
entry point has been historically swapped between libraries, making it convenient to upgrade, or change, that entry point for a whole site at once. This has been accidentally handy in various occasions (see Zepto and others).
How to solve this problem
There are at least few things to consider, when it comes to Custom Elements:
- the
customElements
registry is already globally available - if the chosen library is small enough, tree-shaking doesn’t really bring in real-world benefits, at least not for our components
- the
customElements
registry already has its primitive way to wait for dependencies
And that’s exactly the µce approach.
customElements.whenDefined('uce-lib').then(({define, html}) => {
define('my-component', {...});
});
The library registers itself as a special Custom Element class that carries all exported utilities, after reserving a name with its own prefix, to avoid bothering other projects and components.
The advantage of this approach, which every other library could follow, is that each component can be shipped without ever including the library code in it: one component, 10 components, no need to tree-shake, no need to toolchain, they all can land whenever and wherever they are needed, and the whole site can have an asynchronous, pre-fetched, µce script on top, cached across all pages, without forbidding anyone to also use, in the main bundle, just this:
import 'uce'; // it self register once
… but that’s an ugly boilerplate …
I completely agree, and the reason is that .whenDefined(...)
does not resolve what we’re waiting for, and I’ve written a proposal to fix this already, but we all know how proposals in standards work.
Update
The issue got merged, so .whenDefined(name).then(Class => {})
will be the standard 🎉
But fear not! The once-defined helper brings in exactly just that, so that we can still use our “module based approach”, without bloating the code:
import when from 'once-defined';when('uce-lib').then(
({define, html}) => { ... }
);
This helper can be used to await one or more dependency, it’s not something ad-hoc for my libraries, it’s just a boilerplate wrapper that weights half a tweet once minified.
About bundling dependencies
Now that we are familiar with the platform way to await dependencies, as opposite of importing these all over, it’s a good moment to talk about extending, or needing, other Custom Elements in the registry.
Let’s categorize the kind of dependencies a single component might have:
- a library, and this has been discussed in the previous part of this post
- an abstract class/component to extend
- some component that must be already defined when our current component is bootstrapped
The abstract component case
This is when we have defined some class we’d like to simply extend, without having ever an instance of this class in the live DOM.
In these cases, we don’t even need to register such class, as it’s just a regular helper for our current component, hence it should be imported a part.
import AbstractElement from './abstract-element.js';customElements.define(
'actual-element',
class extends AbstractElement { ... }
);
That’s it: nothing to wait for, the code gets included. Hopefully the code in abstract-element.js
is not huge, but if that’s the case, then it’d be a wise choice to ship all components using it in bulk, so that one single import would bring in AbstractElement
once, and all components size will be less relevant.
Of course, in this very specific case, toolchains can be a great help.
The 3rd parts component case
Here we need to solve a chicken-egg issue:
- is the needed component a wrapper of this one?
- is this component wrapping the needed component?
The former case is a great candidate for .whenDefined
, ’cause in order to have the current component working, the other one has to be already known.
The latter case though, could branch into two sub-categories:
- we don’t want our current component to be responsible for its inner components, hence dependencies
- we want to grant inner components are already known in the registry
In the former case, we don’t need to await for anything, because it’s the inner component that should await for its wrapper dependency.
In the latter case though, I honestly don’t have any valid use case, but if there is one, then we go back to the previous AbstractElement
situation: we need to import the inner dependencies regardless … but do we?
About bundling components
If we have followed all previous steps, we’re now capable of shipping our components in isolation, as stand-alone, without worrying at all about surrounding dependencies, where each component can be a file a part, that can be pre-fetched, cached, stored in a Service Worker, and so on and so forth.
We also should know by know that the customElements
registry is unique and global, meaning that it’s also name-clashing prone.
If library x registers x-form
, no other library in that page can register that very same name. An x-form
component would likely be named … 🥁🥁🥁 x-form.js
, or even x-form.mjs
or whatever extension flavor we prefer (even if a Custom Element is not really a module, isn’t it?).
Accordingly, the common real-world case is that our custom elements will likely be all saved in a single directory that contains all our components.
This also means that we can automatically load Custom Elements only once these land live on the DOM, and µce-loader is an extremely minimalistic utility to solve this case, but the good old lazytag could work for others too.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<script type="module">
import loader from '//unpkg.com/uce-loader?module';
loader('js/components');
</script>
</head>
<body>
<compo-nent></compo-nent>
<hr>
<what-ever></what-ever>
</body>
</html>
Check the live demo out, and see how no-brainer it is to have outer, or inner, components, landing just once, and as soon as these are discovered live.
The loader size is less than 0.5K, so that the total amount of bandwidth needed to have a whole site driven by our Custom Elements would be less than 4K, if we’re using µce, and each Custom Elements will weight exactly the minimum amount it needs to weight, and it can also be upgraded a part, without needing changes in the whole bundle, library, or site: we change the custom-element.js
file, and we’re done 🎉
Please also note that despite its name, µce-loader stands for “Micro Custom Elements Loader”, so it’s not coupled with the µce library, hence we could use it for any other project we like.
About Shadow DOM
One of the biggest misconceptions about Custom Elements is that you need Shadow DOM to create robust components, but after shipping Custom Elements to million users for about 3 years, I can assure you that’s likely never the case.
Here some fact regarding Shadow DOM
- hard, if not impossible, to reliably polyfill across browsers
- it’s not Server Side Render friendly
- AMP HTML has been shipped for years without using Shadow DOM
- every custom element can have its own CSS by simply using
custom-element { ... }
selectors - having one CSS that styles all Custom Elements is better than parsing, or attaching, N times the same style per each component
Sure thing, many libraries out there ship with Shadow DOM per each component, so that many users give this technique for granted, but while also my libraries are Shadow DOM compatible/friendly, we should stop thinking that’s the way to create Custom Elements: that is just one way, but it’s also most of the time overrated.
As summary
The benefits of shipping already splitted Web Components are quite obvious to me, but the status-quo instead demands lot of tools to produce something that is actually less efficient, and not easier to maintain, or update, than having separated components that just work, can be tested a part, and where loading these automatically can be beneficial for every team setup, or project environment, or library around, as all they need to care about is defining where these components can be retrieved, and literally nothing else 😉
What about CSS?
Well, latest µce provides a style: () => css...
utility out of the box, but if components have a dedicated external CSS file, then lazytag can tackle that too. In few words, it often depends on the library capabilities, or its architecture, but the CSS bit is definitively something a part any component could have differently implemented, so the hint here is: do the best thing for your use case, but please keep these hints in mind 👋