The latest Elix 8.0 release now lets you control how the Elix components are registered as custom elements. This post provides a summary of the complex topic of registering elements, then describes how Elix 8.0 addresses those complexities.
The browser standard for Custom Elements lets you create your own HTML elements in two steps: 1) create a class that inherits from HTMLElement
, and 2) register that class with the browser using a unique tag name. You can then instantiate the class:
class MyElement extends HTMLElement {} // Step 1: create class
customElements.define('my-element', MyElement); // Step 2: register class
const myElement = new MyElement(); // Ready for use
The registered tag name (above, my-element
) gives the browser a way to represent instances of the element in the DOM, where all element nodes must have a tag (a.k.a. localName
). That tag lets the browser know how it should represent the node in HTML representations, such as in the innerHTML
of some containing element. The requirement that a class only gets registered once ensures a clear mapping from DOM to HTML.
That said, the fact that each class must be registered once — and only once — creates a burden for component users. It would be great if, instead, you could define components like the ones in the Elix library in one step:
import Carousel from 'elix/src/Carousel.js'; // Import class
const carousel = new Carousel(); // Use class
but this will throw if the element class hasn’t been registered.
Aside: the thrown exception is "Illegal constructor” in Chrome/Edge/Firefox, and "new.target is not a valid custom element constructor” in Safari. I find both of those wordings to be extremely unhelpful. The problem has nothing to do with your constructor, but with your failure to invoke customElements.define
. I don't hit that exception very often, but every time I do, I waste time looking for the problem in the wrong place before I finally remember why that exception occurs.
Registration can be particularly bothersome if you yourself instantiate components using only constructors. Most of the code we've written that creates components happens to do so through their constructors. We're never actually using the components' tags, so it's a chore to have to worry about them. We wish the browser would just generate a unique tag for any component class that's instantiated without registration. (Someone else has proposed support for anonymous custom elements, and while I think that proposal is very likely to be shot down, I've come to think it would be nice to have.)
To avoid the hassle of registering every component, Elix components in releases prior to 8.0 followed a common auto-registration pattern. Each component class was defined in a separate JavaScript module; the default export of each module was the corresponding component class. When you imported one of those modules, you obtained a reference to that class and as a side effect that class was registered with the browser.
For example, the Carousel
class in Elix 7.0 was defined in a module /src/Carousel.js
that conceptually looked like this:
// Define and export the class.
export default class Carousel extends HTMLElement { ... }
// Register the class as a side-effect.
customElements.define('elix-carousel', Carousel);
So if you imported that module like this:
import Carousel from 'elix/src/Carousel.js';
the import
would return the Carousel component class and as a side effect register the Carousel
class with the tag elix-carousel
.
That was rather convenient, especially as it let you load a module with a script
tag and then immediately use that component entirely in HTML, without having to write any JavaScript:
<script type="module" src="./node_modules/elix/src/Carousel.js"></script>
<elix-carousel>
<!-- Carousel items such as img elements go here. -->
<img src="image1.jpg">
<img src="image2.jpg">
<img src="image3.jpg">
</elix-carousel>
But while auto-registering components are convenient, they lead to some problems:
customElements
DOM API is forcing high-level constraints on how you build your application.One complicating factor with duplicate element registration is that there's bad locality of reference. Imagine you're working on a big project, and manage to trigger a situation in which a component is trying to register itself twice. The second attempt to register the class will throw an exception — but depending on the load order of the modules, that new code might happen to get loaded first. If that happens, the exception will be thrown by the old code when it tries to load later. That's really surprising! “This old code worked fine before. I changed something else far away in this new file, and yet I somehow managed to break the previously-working old code.”
The proposal for scoped custom element registries will let you register a class with a tag that's local to your own code. That will definitely be a huge help for the versioning/bundling conflicts described above.
When that feature arrives, auto-registering components could be a minor nuisance, because an auto-registered component might get registered twice: once when the module auto-registers in the global custom element namespace, and a second time when your code registers the class in a scoped custom element registry. If you consistently use scoped registries, registrations in the global registry are unnecessary, and just present an opportunity for potential problems.
If a component library like Elix wants to be ready for scoped custom element registries, it's worth figuring out how to move away from having all components auto-register themselves.
Given the wide variety of situations and architectures in which web components may be useful, Elix 8.0 supports both the convenience of auto-registration and the freedom to control registration yourself. To this end, all Elix component modules now come in two flavors:
/src
folder now only export a component class, and do not register that class as a custom element. You have to register it yourself. These /src
modules are intended for use in apps that have some complexity, and where you want complete control over your components./define
folder export the corresponding class and register that class as a custom element. Example: elix/define/Carousel.js
exports the Carousel
class and registers it with the tag elix-carousel
. The tag name is always the prefix elix-
followed by the class name in kebab case, so ComboBox
becomes elix-combo-box
. These /define
modules are a convenient way to use components in straightforward apps where you're more concerned about getting things done than having complete control, and the constraints of auto-registration are acceptable.This is, unfortunately, a breaking change for people that use Elix components in their projects. Generally speaking, if they want to preserve the previous auto-registering behavior, they need to replace /src
in their component import
paths with /define
. The other modules in the library — for the extensive set of component mixins and helpers — aren't implicated in component registration, so still exist only in the /src
folder as before. If you are migrating an Elix project, see the release notes for details on migrating to 8.0.
Likewise, the pure HTML use of an Elix component should now reference the /define
modules, like so:
<script type="module" src="./node_modules/elix/define/Carousel.js"></script>
<elix-carousel>
<!-- Carousel items such as img elements go here. -->
</elix-carousel>
These /define
modules each simply import the corresponding /src
module, derive a trivial subclass, export that, and register it. So the source for /define/Carousel.js
is:
import Carousel from '../src/Carousel.js';
export default class ElixCarousel extends Carousel {}
customElements.define('elix-carousel', ElixCarousel);
Why does this code derive a trivial subclass before registering it? Read on...
In any case where you are importing a component from a module, it seems like a good practice to not assume you are the only one who will ever want to register that component. If you try to do the obvious thing:
// Naive approach
import Carousel from 'elix/src/Carousel.js';
customElements.define('my-carousel', Carousel);
that will run — but then you are effectively declaring that you will always be the only one who will ever want to register that class.
That assumption could someday cause problems. If someone working in a different part of your project (or maybe you yourself, later) also tries to register Carousel
as a component class, then one of you will lose the registration race, and end up trying to register a class that's already been registered. As noted earlier, that will throw an exception whose poor locality of reference may make it hard to diagnose.
So a reasonable defensive pattern might be to always define a trivial subclass and register that:
// Defensive approach, lets other people register Carousel too
import Carousel from 'elix/src/Carousel.js';
class MyCarousel extends Carousel {}
customElements.define('my-carousel', MyCarousel);
If you compare this with the code in the previous section, you'll see this is, in fact, the technique used by the Elix auto-registering components. That means you can decide to register the Elix Carousel
as my-carousel
and still let someone else import the elix-carousel
auto-registering component from the Elix /define
folder. Since both are registering trivial subclasses, those two subclasses can be registered in the global custom element registry without triggering exceptions.
If everyone on your project does the same with the components they import, you should always be able register a custom element class using the tag name you want.
We can use the same technique to load different versions of the same Elix component. We've posted a sample showing an Elix 7.0 component and an Elix 8.0 component running side-by-side.