My take on Web Components
Table of contents
Lately, I’ve been trying to learn more JavaScript.
I wanted to explore web components. After checking out some frameworks and tools, mainly lit, tonic and facet, I wanted to build my own thing.
It ended up being heavily inspired by tonic, but way simpler and with less features. I also really liked lit-html, which you can use as a separate package without having to use lit. It solves the problem of re-rendering only the UI parts that have changed, and it’s awesome at doing it.
Anyway, here’s the code, explanations later:
import { html, render } from "https://cdn.jsdelivr.net/npm/lit-html@3/+esm";
let _states = {};
let _index = 0;
function createId() {
return `lemon${_index++}`;
}
export class Lemon extends window.HTMLElement {
// no shadow DOM
#root = this;
constructor() {
super();
this.internal_id = this.id || createId();
this.id = this.internal_id; // ensure all the elements have an id
const state = _states[this.internal_id];
delete _states[this.internal_id];
this._state = state || {};
this._events();
}
get isLemonComponent() {
return true;
}
get state() {
return this._state;
}
set state(newState) {
_states[this.internal_id] = newState;
this._state = newState;
}
_events() {
const ownProps = Object.getOwnPropertyNames(Object.getPrototypeOf(this));
for (const p of ownProps) {
if (!p.startsWith("on_")) continue;
const eventName = p.slice(3);
console.log("adding event listener for", eventName);
this.addEventListener(eventName, this);
}
}
static add(c, htmlName) {
if (window.customElements.get(htmlName)) {
throw new Error(`Cannot Lemon.add(${c.name}, '${htmlName}') twice`);
}
if (!c.prototype || !c.prototype.isLemonComponent) {
throw new Error(`${c.name} is not a Lemon component`);
}
window.customElements.define(htmlName, c);
return c;
}
dispatch(eventName, detail = null) {
const opts = { bubbles: true, detail };
this.dispatchEvent(new window.CustomEvent(eventName, opts));
}
handleEvent(e) {
console.log("calling handleEvent", e);
const handlerName = `on_${e.type}`;
this[handlerName](e);
}
html(strings, ...values) {
return html(strings, ...values);
}
scheduleReRender(oldState) {
if (this.pendingReRender) return this.pendingReRender;
this.pendingReRender = new Promise((resolve) =>
setTimeout(() => {
if (!this.isInDocument(this)) return;
this.renderContent();
this.pendingReRender = null;
this.updated && this.updated(oldState);
resolve(this);
}, 0)
);
return this.pendingReRender;
}
reRender(o = this.state) {
const oldState = { ...this.state };
this.state = typeof o === "function" ? o(oldState) : o;
return this.scheduleReRender(oldState);
}
renderContent() {
const content = this.render();
render(content, this.#root);
}
connectedCallback() {
this.willConnect && this.willConnect();
if (!this.isInDocument(this)) return;
this.renderContent();
this.connected && this.connected();
}
isInDocument(target) {
const root = target.getRootNode();
return root === document;
}
// this is called for example when the component is removed from the DOM
disconnectedCallback() {
console.log("disconnectedCallback", this.internal_id);
this.disconnected && this.disconnected();
delete _states[this.internal_id];
}
}
Notes and explanation
- No Shadow DOM: Too much complexity. I think it’s ok if you’re building a library, otherwise just use your regular CSS (TailwindCSS in my case)
- No props, just internal state as a JavaScript object.
Element properties/attributes can still be accessed and modified using getAttribute
and setAttribute
- No reactivity. You must call
reRender()
when needed. It’s less convenient, but more performant in the long-term. I don’t want to spend time debugging infinite-rerendering loops.
This can be made reactive but having custom setters/getters on the internal state. Or as a middle ground, you can create global event listeners for custom events, and call .reRender()
on the relevant components based on each event. Then you can just trigger the event without having to call .reRender()
on everything (only needed in the event handler).
- Each element MUST have an
id
. If it’s not provided, a (probably) unique one will be generated. - The
id
is used to store the state outside of the class. This is so that the state is persisted accross re-renders. - The state is cleaned-up when the element is removed (
disconnectedCallback
) - The functions that start with
on_
will add event handlers.on_change
, listens tochange
;on_MyCustomEvent
listens toMyCustomEvent
- You can read more about
handleEvent
here. - Re-rendering is scheduled, so you can chain multiple
reRender
calls, but only the last one will run (see more info at the end) - State must be set all at once.
this.state = {...this.state, key: value}
, notthis.state.key = value
this.html
is just an alias for thehtml
tagged template from lit-html
This is not a JavaScript library, it’s just a snippet meant to be copy-pasted. If you don’t want to use JS modules, you can also import lit-html
separately and attach the html
and render
functions to the global namespace, then use the Lemon
class in regular JS:
<script type="module">
import {
html,
render,
} from "https://cdn.jsdelivr.net/npm/lit-html@3/+esm";
Object.assign(window, {
html,
render,
});
</script>
Example usage
This code can be copy-pasted into an HTML file directly and opened in the browser. Play around with it!
<!DOCTYPE html>
<html lang="en">
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Hello</title>
<meta charset="UTF-8" />
<script type="module">
import {
html,
render,
} from "https://cdn.jsdelivr.net/npm/lit-html@3/+esm";
let _states = {};
let _index = 0;
// used for a re-rendering example later
async function sleep(ms, e) {
return await new Promise((resolve) =>
setTimeout(() => {
resolve(e);
}, ms)
);
}
function createId() {
return `lemon${_index++}`;
}
class Lemon extends window.HTMLElement {
// no shadow DOM
#root = this;
constructor() {
super();
this.internal_id = this.id || createId();
this.id = this.internal_id; // ensure all the elements have an id
const state = _states[this.internal_id];
delete _states[this.internal_id];
this._state = state || {};
this._events();
}
get isLemonComponent() {
return true;
}
get state() {
return this._state;
}
set state(newState) {
_states[this.internal_id] = newState;
this._state = newState;
}
_events() {
const ownProps = Object.getOwnPropertyNames(
Object.getPrototypeOf(this)
);
for (const p of ownProps) {
if (!p.startsWith("on_")) continue;
const eventName = p.slice(3);
this.addEventListener(eventName, this);
}
}
static add(c, htmlName) {
if (window.customElements.get(htmlName)) {
throw new Error(`Cannot Lemon.add(${c.name}, '${htmlName}') twice`);
}
if (!c.prototype || !c.prototype.isLemonComponent) {
throw new Error(`${c.name} is not a Lemon component`);
}
window.customElements.define(htmlName, c);
return c;
}
dispatch(eventName, detail = null) {
const opts = { bubbles: true, detail };
this.dispatchEvent(new window.CustomEvent(eventName, opts));
}
handleEvent(e) {
const handlerName = `on_${e.type}`;
this[handlerName](e);
}
html(strings, ...values) {
return html(strings, ...values);
}
scheduleReRender(oldState) {
if (this.pendingReRender) return this.pendingReRender;
this.pendingReRender = new Promise((resolve) =>
setTimeout(() => {
if (!this.isInDocument(this)) return;
this.renderContent();
this.pendingReRender = null;
this.updated && this.updated(oldState);
resolve(this);
}, 0)
);
return this.pendingReRender;
}
reRender(o = this.state) {
const oldState = { ...this.state };
this.state = typeof o === "function" ? o(oldState) : o;
return this.scheduleReRender(oldState);
}
renderContent() {
const content = this.render();
render(content, this.#root);
}
connectedCallback() {
this.willConnect && this.willConnect();
if (!this.isInDocument(this)) return;
this.renderContent();
this.connected && this.connected();
}
isInDocument(target) {
const root = target.getRootNode();
return root === document;
}
// this is called for example when the component is removed from the DOM
disconnectedCallback() {
console.log("disconnectedCallback", this.internal_id);
this.disconnected && this.disconnected();
delete _states[this.internal_id];
}
}
// COMPONENTS
class MyInputComponent extends Lemon {
constructor() {
super();
// set base state in constructor
this.state = { label: "default initial label" };
}
// this will be called when the input changes (onchange / press enter)
on_change(e) {
this.state = { ...this.state, input_value: e.target.value };
this.reRender();
}
// the render method will add a value to the <input> based on the state
// (if `input_value` is set)
render() {
console.log("render input");
return this
.html`<label><span>${this.state.label}</span> some more text <input type="text" value=${this.state.input_value}/></label>`;
}
}
class MyComponent extends Lemon {
get val() {
return this.state.val || "omg";
}
// this will be called when the input changes (onchange / press enter)
// it's triggered because the event bubbles up to the parent from my-input-component
//
// This will render the input multiple times. Overall logic is:
// 1. Render with `change label` text
// 2. Sleep for 1 second
// 3. Render with the new `value` as the text
// 4. Render when calling `this.reRender()`. The .render() method of this class
// will be called again, and it re-generates the <my-input-component>
async on_change(e) {
this.state = { ...this.state, val: e.target.value };
document
.querySelector("my-input-component")
.reRender((state) => ({ ...state, label: "change label" }));
await sleep(1000);
document
.querySelector("my-input-component")
.reRender((state) => ({ ...state, label: e.target.value }));
this.reRender();
}
on_MyCustomEvent(e) {
console.log("onMyCustomEvent", e);
}
render() {
return this
.html`<div>Hello world. <my-input-component></my-input-component> more text! ${this.val}</div>`;
}
}
Lemon.add(MyInputComponent, "my-input-component");
Lemon.add(MyComponent, "my-component");
console.log("added");
</script>
</head>
<body>
<p>Lemon Web Components Example</p>
<my-component></my-component>
</body>
</html>
Run any of these in the console to trigger the custom event handler:
// trigger on child element, handled by parent element
document.querySelector("my-input-component").dispatch("MyCustomEvent")
// trigger on parent element, handled by itself
document.querySelector("my-component").dispatch("MyCustomEvent")
Since lit-html
is amazing, only the necessary parts of the DOM will change. Also, due to the reRender
and scheduleReRender
logic, if we for example do:
const value = "sample"
document
.querySelector("my-input-component")
.reRender((state) => ({ ...state, label: value }));
document
.querySelector("my-input-component")
.reRender((state) => ({ ...state, label: value + "2" }));
document
.querySelector("my-input-component")
.reRender((state) => ({ ...state, label: value + "3" }));
document
.querySelector("my-input-component")
.reRender((state) => ({ ...state, label: value + "4" }));
You will see the console message render input
only once. The element will contain the last state.
If we made this change:
reRender(o = this.state) {
const oldState = { ...this.state };
this.state = typeof o === "function" ? o(oldState) : o;
- return this.scheduleReRender(oldState);
+ return this.renderContent();
}
The element would re-render 4 times.
Conclusion
With this simple snippet, I feel I have everything I want to build custom components. My focus is on building internal components, not a shared library. This ticks all my requirements:
- Explicit rendering
- Change only the DOM parts needed
- Scheduled re-renders to avoid extra work
- Simple code that’s easy to modify and adapt for each project