How to create custom elements without Web Components
UIScript DevBlog, Part 21
Intro
In the past couple of weeks I’ve been posting about a lot of things but let’s go back to hardcore UIScript.
My goal is to create a library to enable JS-first web app development, without using even a single line of HTML or CSS code.
One of the biggest issues I’m facing is to create a new inheritance tree for my new Views in UIScript, without clashing with existing stuff already in the browser.
Prototypes
JS solves the problem of inheritance by using a prototype chain. If you don’t know how prototypes work, take a look at this article on MDN before you continue.
Since every HTML element is an object (hence the DOM), they are represented in the prototype chain just like any other object.
This is roughly how they look:
HTMLElement: abbr, address, article, aside, b, bdi, bdo, cite, code, dd, dfn, dt, em, figcaption, figure, footer, header hgroup, i, kbd, main, mark, nav, noscript, rp, rt, ruby, s, samp, search, section, small, strong, sub, summary, sup, u, var, wbr
- HTMLAnchorElement: a
- HTMLAreaElement: area
- HTMLBaseElement: base
- HTMLBodyElement: body
- HTMLBRElement: br
- HTMLButtonElement: button
- HTMLCanvasElement: canvas
- HTMLDataElement: data
- HTMLDataListElement: datalist
- HTMLDetailsElement: details
- HTMLDialogElement: dialog
- HTMLDivElement: div
- HTMLDListElement: dl
- HTMLEmbedElement: embed
- HTMLFieldSetElement: fieldset
- HTMLFormElement: form
- HTMLHeadElement: head
- HTMLHeadingElement: h1, h2, h3, h4, h5, h6
- HTMLHRElement: hr
- HTMLHtmlElement: html
- HTMLIFrameElement: iframe
- HTMLImageElement: img
- HTMLInputElement: input
- HTMLLabelElement: label
- HTMLLegendElement: legend
- HTMLLIElement: li
- HTMLLinkElement: link
- HTMLMapElement: map
- HTMLMediaElement
- HTMLAudioElement: audio
- HTMLVideoElement: video
- HTMLMenuElement: menu
- HTMLMetaElement: meta
- HTMLMeterElement: meter
- HTMLModElement: del, ins
- HTMLObjectElement: object
- HTMLOListElement: ol
- HTMLOptGroupElement: optgroup
- HTMLOptionElement: option
- HTMLOutputElement: output
- HTMLParagraphElement: p
- HTMLPictureElement: picture
- HTMLPreElement: pre
- HTMLProgressElement: progress
- HTMLQuoteElement: blockquote, q
- HTMLScriptElement: script
- HTMLSelectElement: select
- HTMLSlotElement: slot
- HTMLSourceElement: source
- HTMLSpanElement: span
- HTMLStyleElement: style
- HTMLTableCaptionElement: caption
- HTMLTableCellElement: td, th
- HTMLTableColElement: col, colgroup
- HTMLTableElement: table
- HTMLTableRowElement: tr
- HTMLTableSectionElement: tbody, tfoot, thead
- HTMLTemplateElement: template
- HTMLTextAreaElement: textarea
- HTMLTimeElement: time
- HTMLTitleElement: title
- HTMLTrackElement: track
- HTMLUListElement: ul
- HTMLUnknownElement: fencedframe, portal (deprecated tags, unknown/custom tags)
MathMLElement
- MathMLMathElement: math
SVGElement
- SVGGraphicsElement
- SVGSVGElement: svg
There are a couple of things to note here.
First, there are officially 116 HTML elements (currently) but browsers define them using only around 66 constructors. This means, that several HTML elements share significant amount of characteristics: the base
HTMLElement
constructor directly defines 39,HTMLHeadingElement
defines 6,HTMLTableSectionElement
defines 3, andHTMLModElement
,HTMLQuoteElement
,HTMLTableCellElement
,HTMLTableColElement
andHTMLUnknownElement
each define 2 elements.Second,
<audio>
and<video>
elements introduce an intermediateHTMLMediaElement
that cannot be instantiated directly (i.e. there is no<media>
element in HTML), but extremely helpful in deduplicating features and already shows the power of inheritance. In this case, using simplyHTMLMedia
would make much more sense.And third, as discussed in a previous article, the input element shouldn’t be a single element. This could be implemented the same way as multimedia elements using a shared, intermediate parent but we won’t go into details here.
The power of prototypes
The best thing about the prototype chain is that it is extensible: we can add any new children prototype to any existing prototype and inherit its functionality right away.
Unfortunately, even though the prototype chain of HTML elements looks promising — after all HTML doesn’t even have a type system, let alone inheritance — it has a lot of problems we need to address.
We need to also discuss why Web Components cannot solve these issues, even though this is one of the main reasons they were introduced in the first place.
Let’s dive in.
The problems
First off, HTML constructors cannot be used directly, unlike other regular constructors in JS.
This might make sense for intermediate constructors like HTMLElement
, HTMLMediaElement
, or even HTMLUnknownElement
that don’t have a direct HTML counterpart, but sadly, this is true for all HTML constructors as well:
HTMLElement(); //TypeError: Illegal constructor
new HTMLElement(); //TypeError: Illegal constructor
HTMLMediaElement(); //TypeError: Illegal constructor
new HTMLMediaElement(); //TypeError: Illegal constructor
HTMLDivElement(); //TypeError: Illegal constructor
new HTMLDivElement(); //TypeError: Illegal constructor
Luckily, their prototypes work without issues:
HTMLDivElement.prototype.foo = "foo";
const div = document.createElement("div");
div.foo; //"foo"
Modifying existing prototypes can be considered a way to implement custom elements, but this is a dirty and cheap approach that has obscure pitfalls all over the place.
Web Components
Instead, the recommended way is to define a new class that extends, rather than modifies, an existing prototype.
This would look something like this:
class Stack extends HTMLDivElement {
constructor() {
super();
}
}
The first issue with this is that we cannot call a class without the new
keyword:
Stack(); //TypeError: Cannot call a class constructor without |new|
Sure, no big deal, let’s call it with new
:
new Stack(); //TypeError: Illegal constructor
Oops, it still doesn’t work, because we also need to register this class before using it:
customElements.define("stack", Stack); //SyntaxError: Custom element name must contain a hyphen
Ah, yes, we also need a fucking hyphen:
customElements.define("ui-stack", Stack);
new Stack(); //TypeError: Illegal constructor
Nope, it still doesn’t work, because custom built-in elements need a third argument, which is an object, even though it has only one key, extends
, which is the name of the element we already said we are extending.
Never mind, let’s do it:
customElements.define("ui-stack", Stack, {extends: "div"});
new Stack(); //TypeError: Illegal constructor
Aaand it still doesn’t work, because I am in Safari, and it turns out Safari doesn’t support customized built-in elements, only autonomous custom elements.
OK, let’s try everything in Firefox, then:
class Stack extends HTMLDivElement {
constructor() {
super();
}
}
customElements.define("ui-stack", Stack, {extends: "div"});
new Stack(); //<div></div>
Everything seems to work, except the tag name is div
. The class name is Stack
, it is registered as ui-stack
, and yet, the tag name of the element is somehow still div
.
Are you kidding me?
All right, so, how about if we try the classical instantiation? Would that work?
class Stack extends HTMLDivElement {
constructor() {
super();
}
}
customElements.define("ui-stack", Stack, {extends: "div"});
const stack = document.createElement("ui-stack"); //<ui-stack></ui-stack>
stack instanceof Stack; //false
stack.constructor; //HTMLElement
Well, the tag name is correct, but the element is not a Stack
anymore, and its constructor became HTMLElement
, not even HTMLDivElement
.
Wonderful.
This is because document.createElement()
is pretty quirky, too. Even though we manually registered our new element and made sure not to clash with any default HTML element, we still cannot use its tag name as the first argument.
Instead, we need to create the parent element and supply the new element as the second argument:
document.createElement("div"); //HTMLDivElement (all valid tags)
document.createElement("stack"); //HTMLUnknownElement (all custom tags without a hyphen)
document.createElement("ui-stack"); //HTMLElement (all custom tags with a hyphen, registered or not)
document.createElement("div", {is: "ui-stack"}); //HTMLDivElement (all custom tags, not registered, second argument is ignored)
document.createElement("div", {is: "ui-stack"}); //Stack but <div> (all custom tags, registered)
In essence, since there is no inheritance in HTML whatsoever, customized built-in elements (custom elements that extend for example HTMLDivElement
) cannot have custom tag names. Custom tag names are only available for autonomous custom elements (custom elements that extend HTMLElement
).
If you cannot wrap your head around all this, don’t worry, nobody does.
So, considering all of this, here’s our final, fully compliant Web Components example (just remember, that it doesn’t work in Safari):
//define the new class that has to extend the HTML element
class Stack extends HTMLDivElement {
constructor() {
super();
}
}
//define the customized built-in element, make sure to add the third argument
customElements.define("ui-stack", Stack, {extends: "div"});
//instantiate the object, make sure to add the second argument
const stack = document.createElement("div", {is: "ui-stack"}); //<div></div>
At this point I seriously think that the Web Components committee is just trolling everyone. There is no way in hell someone thought that this is how extending HTML should work.
Prototype Injection
I was pretty disappointed by how poorly custom elements performed but I really needed to find a solution for UIScript.
Luckily, after experimenting a ton with HTML prototypes, I stumbled upon a pretty clever solution that I think might work. I like to call this prototype injection.
In short, the basic idea is to insert one or several prototypes between existing ones.
Here is an example of how prototype injection works using the same class:
function Stack() {
const element = document.createElement("div"); //step 2
Object.setPrototypeOf(element, Stack.prototype); //step 3
return element;
}
Object.setPrototypeOf(Stack.prototype, HTMLDivElement.prototype); //step 1
How does it work?
First, we create a constructor that extends HTMLDivElement
. Then, we create a regular div
element. Finally, we switch its prototype to Stack.prototype
.
With this approach almost all problems have been eliminated:
- we can call the constructor with or without
new
- we don’t need to register anything
- we don’t need to use a hyphen, we can namespace however we like
- we don’t touch anything default
- we honor the prototype-based nature of JS
The only drawback is that the tag name is still div
, but since we are 100% in JS, this is the least of our concerns (and as we saw earlier, a custom tag name isn’t even possible anyway).
You can test this concept yourself:
let stack = Stack(); //or new Stack() if you prefer
stack instanceof Stack; //true
stack instanceof HTMLDivElement; //true
stack instanceof HTMLElement; //true
We are essentially achieved customized built-in elements without Web Components. The best part is that this approach works in any browser that adheres to the prototype chain of HTML elements, even in Safari.
A new Tree of Life
With this single mechanism, we can build a new inheritance tree from HTML elements, completely independent from, but fully compatible with the existing one.
We can create fully custom elements based on any HTML element, define arbitrary behavior and add them to shared prototypes to optimize performance.
We can essentially rebuild the DOM API in any way we like.
And this is exactly what I did with UIScript.
The tree of UIScript
The inheritance tree in UIScript starts with the View prototype, the root class, that stores the most common functionalities:
function View() {} //abstract constructor (we just need its prototype, we don't want to instantiate)
View.prototype.opacity = function (value) { //add some shared behavior
this.style.opacity = value;
};
Object.setPrototypeOf(View.prototype, HTMLElement.prototype);
It then defines various Views that inherit from this root class, for example like this:
function Image() {
const image = document.createElement("img");
Object.setPrototypeOf(image, Image.prototype);
return image;
}
Object.setPrototypeOf(Image.prototype, View.prototype);
Now, this Image
class cannot fetch, much less display any images yet, because we severed its connection to HTMLImageElement.prototype
.
But here comes the best part.
All we need to do is to mix in the original behavior from HTMLImageElement.prototype
into the new Image.prototype
and we are good to go. You can even rename any property or method, if you like.
This is the full example with some minor adjustments:
function View() {
throw new TypeError("Illegal constructor");
}
Object.defineProperties(View.prototype, {
value: function (value) {
this.style.opacity = value;
}
});
Object.setPrototypeOf(View.prototype, HTMLElement.prototype);
function Image() {
const image = document.createElement("img");
Object.setPrototypeOf(image, Image.prototype);
return image;
}
Object.defineProperties(Image.prototype, {
url: Object.getOwnPropertyDescriptor(HTMLImageElement.prototype, "src")
});
Object.setPrototypeOf(Image.prototype, View.prototype);
With this, we just created a brand new, fully functional and fully custom branch in the prototype chain:
HTMLElement
- View
- Image
We just injected two prototypes between the img
instance and HTMLElement
.
You can go ahead and test this new class:
const image = Image();
image.url = "fun.jpg";
document.body.append(image); //loads and displays the image
Here are some additional tests to demonstrate how this new Image
instance behaves:
image instanceof HTMLImageElement; //false
image instanceof Image; //true
image instanceof View; //true
image instanceof HTMLElement; //true
image.url; //"http://example.com/fun.jpg";
image.src; //undefined
image.getAttribute("src"); //"fun.jpg"
image.tagName; //"IMG" (the only thing we cannot change)
This new Image
instance has access to everything on HTMLElement.prototype
and beyond, making it fully compatible with the DOM and any other HTML element.
And if you still prefer the old <img>
element or load a regular HTML document that has this element, it will function just like it did before.
But why do we need all this?
UIScript is a proof of concept project.
It is a way to demonstrate solutions to many long standing and fundamental issues in HTML and CSS that their respective communities are refusing to fix.
Many bad concepts are entrenched in these languages because they keep pushing backwards compatibility above all else, essentially killing all innovation and forward thinking in the field.
With a clean slate we could go far beyond fixing current problems — we could open a new chapter in web development.
So, what do you think?
What would you like to be fixed in web dev if you had the opportunity?