Already having a couple years each in React and Angular I decided to learn HTML Custom Elements, the browser's native component framework. "This should be easy," I thought, "Just tell me where to put the {{substitutions}}
and I'll go from there."
Ha.
Point #1: such substitutions are part of a templating engine, which the native framework lacks. In this article I'll detail many choices in handling the HTML portion of a component, such as what format to keep it in, how to parameterize it, when to use the <template>
tag, how to render and then re-render it, and how to know when to re-render it.
In these examples I'll be using single-file components with zero dependencies, so no fetch
ing HTML separately from the import
ed code. I'll use a little bit of Typescript for documentation purposes. I'll also hold the preference of always using the Shadow DOM, and always keeping the HTML/CSS at the bottom of the code file, not because it is easy but because it is hard.
Our example is a <flex-row>
component, which merely styles its children in a flexbox's row alignment. Since it accepts child nodes as input, we'll see the <slot>
tag instructing the native framework where to put those children. Example usage:
<flex-row>
<div>Left sidebar.</div>
<div>Main content, containing a very long text that wraps around the viewport multiple times. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.</div>
</flex-row>
Simplest Example
customElements.define('flex-row', class extends HTMLElement {
connectedCallback() {
this.attachShadow({ mode: 'open' }).innerHTML = /*html*/`<slot style="display: flex; flex-direction: row; flex: 1 1 auto; align-items: flex-start;"></slot>`;
}
})
By inlining the class
definition right inside the final .define
call, we don't actually need to name the class. .attachShadow
creates the Shadow DOM, and returns a reference to it which we can immediately .innerHTML
it with a string which is our initial template.
The /*html*/
comment is a hint to our IDE that the backtic string should be color-syntax highlighted as HTML. CSS is inlined but a <style></style>
after the </slot>
works just as well. But if our template is large we'd probably move it out of the code to the bottom of the file...
Put Template At Bottom
customElements.define('flex-row', class extends HTMLElement {
connectedCallback() {
this.attachShadow({ mode: 'open' }).innerHTML = template();
}
})
function template() {
return /*html*/`<slot style="display: flex; flex-direction: row; flex: 1 1 auto; align-items: flex-start;"></slot>`;
}
Moving the template to the end of the file looks cleaner. But, calling .define
so early can error with template() is undefined
if we used const template = () => ...
. So here I must use function
so hoisting happens. Or I could name the class and move just the .define
call to the very bottom... where it's easy to forget it.
Anyway, let's add a parameter <flex-box wrap='nowrap'>
for our template to wrangle.
Add Parameter for flex-wrap
customElements.define('flex-row', class extends HTMLElement {
connectedCallback() {
const wrap = this.getAttribute('wrap');
this.attachShadow({ mode: 'open' }).innerHTML = template(wrap);
}
);
function template(flexWrap: string) {
return /*html*/`<slot style="display: flex; flex-direction: row; flex: 1 1 auto; align-items: flex-start; ${flexWrap ? `flex-wrap: ${flexWrap};` : ''}"></slot>`;
}
One difference in attaching the Shadow in connectedCallback
instead of the constructor
is that the element's attributes are ready. Our template function is parameterized like any function.
Now, using this feature is weird. Why <flex-box wrap='nowrap'>
instead of just <flex-box nowrap>
or <flex-box wrap>
? It also never re-renders. Let's add both of these features.
Render Method and Property Watching
customElements.define('flex-row', class FlexRow extends HTMLElement {
connectedCallback() {
this.wrap = typeof this.getAttribute('wrap') === 'string' ? 'wrap' : typeof this.getAttribute('nowrap') === 'string' ? 'nowrap' : '';
this.attachShadow({ mode: 'open' }).innerHTML = template(this.wrap);
}
#wrap = '';
get wrap() { return this.#wrap; }
set wrap(v) { this.#wrap = v; FlexRow.render(this); };
static render({ shadowRoot, wrap }: FlexRow) {
if (shadowRoot) shadowRoot.innerHTML = template(wrap);
}
});
function template(flexWrap: string) {
return /*html*/`<slot style="display: flex; flex-direction: row; flex: 1 1 auto; align-items: flex-start; ${flexWrap ? `flex-wrap: ${flexWrap};` : ''}"></slot>`;
}
There's a lot to unpack here.
1) We check both attributes wrap
and nowrap
on init.
2) We store the appropriate string in a javascript private variable this.#wrap
. (Typescript shims this since browser support isn't yet complete, but my later examples won't use it anyway.)
3) We create a getter/setter pair so when outside code changes the property we can react.
4) We declare a static render
function for the re-render.
Now the consumer can initialize our component with short attributes like <flex-row wrap>
and <flex-row nowrap>
but then change them anytime with the property ourElement.wrap = 'nowrap';
.
If we didn't make the backing field #wrap
a private member, then the consumer could just hit it directly with ourElement._wrap = ...
and be left wondering why nothing works, and why is there both a wrap
and a _wrap
property visible in the browser devtools.
Similarly, we hide our render function by making it static. This does require naming the class, but in return we can destructure the parameter to avoid a lot of this.
prefixes.
Point #2: attributes and properties don't automatically mirror each other in HTML Custom Elements. Think of the .value
of <input type=text value='initialVal' />
: the attribute has the initial value and is effectively a constant, while only the property updates according to the user. Everything works that way with HTML Custom Elements.
But what if we wanted some mirroring, especially so CSS could alter itself based on whether the flex-row was currently wrapping or not? CSS only looks at attributes not properties...
Render Method and Property Watching II
class FlexRow extends HTMLElement {
connectedCallback() {
this.wrap = typeof this.getAttribute('wrap') === 'string' ? 'wrap' : typeof this.getAttribute('nowrap') === 'string' ? 'nowrap' : '';
this.attachShadow({ mode: 'open' }).innerHTML = template(this.wrap);
}
get wrap() { return this.getAttribute('wrap'); }
set wrap(v) { this.setAttribute('wrap', v || ''); render(this); };
}
const render = ({ shadowRoot, wrap }: FlexRow) => shadowRoot ? shadowRoot.innerHTML = template(wrap) : null
const template = (flexWrap: string | null) =>
/*html*/`<slot style="display: flex; flex-direction: row; flex: 1 1 auto; align-items: flex-start; ${flexWrap ? `flex-wrap: ${flexWrap};` : ''}"></slot>`;
customElements.define('flex-row', FlexRow);
A couple of things here.
Since we need to update the attribute whenever the property changes, the attribute might as well be the backing field.
For the sake of example I moved .define
to the very bottom, allowing us to use arrow functions for template()
. Since that's a pretty one-liner we'll do render()
the same way as an alternative to the static.
The new features come with a couple of issues.
The nowrap
attribute lacks a matching property, and the wrap
property accepts the string "nowrap", which can confuse our users. I'll defer on this design issue since it's unrelated to templates but did want to call it out.
More importantly, later updates to the attributes via ourElement.attributes['wrap'] = 'wrap'
or ourElement.setAttribute('wrap','wrap')
will do nothing, even though the attributes worked initially, potentially disappointing our users.
Property AND Attribute Watching
class FlexRow extends HTMLElement {
connectedCallback() {
this.wrap = typeof this.getAttribute('wrap') === 'string' ? 'wrap' : typeof this.getAttribute('nowrap') === 'string' ? 'nowrap' : '';
this.attachShadow({ mode: 'open' }).innerHTML = template(this.wrap);
}
get wrap() { return this.getAttribute('wrap'); }
set wrap(v) { this.setAttribute('wrap', v || ''); render(this); };
static observedAttributes = ['wrap', 'nowrap'];
attributeChangedCallback(name: string, oldValue: string, newValue: string) {
if (oldValue !== newValue) render(this);
}
}
const render = ({ shadowRoot, wrap }: FlexRow) => shadowRoot ? shadowRoot.innerHTML = template(wrap) : null
const template = (flexWrap: string | null) =>
/*html*/`<slot style="display: flex; flex-direction: row; flex: 1 1 auto; align-items: flex-start; ${flexWrap ? `flex-wrap: ${flexWrap};` : ''}"></slot>`;
customElements.define('flex-row', FlexRow);
Adding attributeChangedCallback
, and listing the possible values for its first argument in the static (and required) observedAttributes
allows us to react to attribute changes. Normally we need a this[name] = newValue;
in that function to copy the value from the attribute to the property, but since our property uses a getter/setter already pointed to the attribute, there's no need. We of course must check for infinite loop.
I said at the start of this article there's no simple substitutions like in React and Angular. We've been using template string literals and .innerHTML
to parse the string for us. This is fine if the component is never re-rendered, being both permanent and one-use, like a main-menu bar. But when you introduce re-rendering, the re-parsing of strings has a hefty performance hit.
Let's fix our performance problem with a single string parse for the initial render, and a more surgical update for re-rendering. We'll need the <template>
HTML tag and the .cloneNode
method for this one.
Precompile Template, Minimal Re-render
const template = /*html*/ `
<template>
<slot style="display: flex; flex-direction: row; flex: 1 1 auto; align-items: flex-start;"></slot>
</template>`;
let fragment: DocumentFragment;
class FlexRow extends HTMLElement {
static observedAttributes = ["wrap", "reverse"] as const;
#elementsCache: {
rootElement?: CSSStyleDeclaration;
} = {};
connectedCallback() {
if (!fragment) fragment = new DOMParser().parseFromString(template, "text/html").head.querySelector("template")!.content;
const myFragment = fragment.cloneNode(true) as DocumentFragment;
this.#elementsCache.rootElement = (myFragment.firstElementChild as HTMLElement).style;
for (const attribute of FlexRow.observedAttributes) this.attributeChangedCallback(attribute, "", this.getAttribute(attribute));
this.attachShadow({ mode: "closed" }).appendChild(myFragment);
}
// reflect properties to attributes
get wrap() {
return this.getAttribute("wrap");
}
set wrap(value: string | null) {
this.setAttribute("wrap", value || "");
}
get reverse() {
return this.getAttribute("reverse");
}
set reverse(value: string | null) {
if (value == null) this.removeAttribute("reverse");
else this.setAttribute("reverse", value);
}
// reflect attributes to properties
attributeChangedCallback(name: (typeof FlexRow.observedAttributes)[number], oldValue: string | null, newValue: string | null) {
if (oldValue === newValue) return; // prevent infinite loop
//console.log("name", name, "old", oldValue, "new", newValue);
switch (name) {
case "wrap":
this.#elementsCache.rootElement!.setProperty(name, newValue);
return;
case "reverse":
// newValue = null means attr is now missing; newValue == "" means attribute there
this.#elementsCache.rootElement!.setProperty("flex-direction", newValue == "" || (!!newValue && newValue != "false") ? "row-reverse" : "row");
return;
}
}
}
customElements.define("flex-row", FlexRow);
Our template()
function is now just a variable no longer accepting parameters because it's only going to be used once. Its contents are wrapped in a <template>
tag because now the DOMParser parses the text and returns the disconnected node tree. We then immediately querySelector it for the <template>
tag, and put the content
into a variable for use by every instance of <flex-row>
afterward. It will never be re-parsed again.
Given the template that this particular instance of flex-row will use, we then find and cache each element or piece that changes at runtime in response to attribute/property changes. Here it's just the .style
of the root element.
When a property changes, we reflect its new value into the corresponding attribute. When an attribute changes, the standard attributeChangedCallback
is called. It's here where we update our component's HTML. We also call this from a loop in connectedCallback
on initialization to remove all dummy values that were cloned from the static template.
Note that if oldValue
or newValue
are null, this means the attribute didn't exist at all, which is a distinct case from it existing but with an empty string.
This way is a bit more typing and complexity, but on re-render, that sole setProperty
is astronomically faster than creating and parsing strings, especially if said string invokes other custom components.
Top comments (0)