Introduction
I've been learning about web components from Rob Eisenberg's Web Component Engineering course, and thought I'd crystallise some of my learning by writing about it. Here, therefore, is a very basic web component that demonstrates some fundamental characteristics of web components that we'll build on in the future (I'm only a few lessons in, and there's a LOT to go).
What we're going to do here is to create a my-counter element, which is really very simple. All it does is render the value contained in the count property into the DOM. So this:
<my-counter count="3"></my-counter>
will render this:
3
If you don't provide a count it will output 0.
Here's the full code to start with, then we'll dive into each line and dissect.
First, the HTML:
<template id="my-counter">
<div id="count"></div>
</template>
<my-counter count="0"></my-counter>
And now the JS:
class MyCounter extends HTMLElement {
static observedAttributes = ["count"];
static #fragment = null;
#view = null;
#count;
constructor() {
super();
this.attachShadow({ mode: "open" });
}
connectedCallback() {
if (this.#view === null) {
this.#view = this.#createView();
this.shadowRoot.appendChild(this.#view);
this.#count = this.shadowRoot.getElementById("count");
}
this.countChanged();
}
attributeChangedCallback() {
this.countChanged();
}
countChanged() {
if (this.#count) {
const value = this.getAttribute("count") ?? 0;
this.#count.innerText = value;
}
}
#createView() {
if (MyCounter.#fragment === null) {
const template = document.getElementById("my-counter");
MyCounter.#fragment = document.adoptNode(template.content);
}
return MyCounter.#fragment.cloneNode(true);
}
}
customElements.define("my-counter", MyCounter);
Declaring the MyCounter class.
class MyCounter extends HTMLElement {
MyCounter extends the base HTMLElement, making it a new kind of HTML element. We can now use this MyCounter class to add our own behaviours whilst keeping all the properties and methods of a normal HTMLElement.
Observed Attributes
static observedAttributes = ["count"];
The static observedAttributes property defines a list of attributes that the component will monitor. When the count attribute changes, MyCounter responds by updating the component's displayed value.
Efficient Template Handling
static #fragment = null;
By declaring a static #fragment property and storing the template fragment there when the first instance of the MyCounter HTMLElement is created, MyCounter avoids redundant template adoptions for multiple instances. This shared fragment serves as the blueprint for each instance's view.
Instance-specific DOM
#view = null;
Once the template has been adopted, we can clone it for each instance of the counter. The #view property holds the cloned template for the component instance. Unlike the shared fragment, this property is instance-specific, creating a unique view for each counter on the page.
Private Element References
#count;
MyCounter uses a private #count field to hold a reference to the specific DOM element displaying the count. This reference is set in connectedCallback, ensuring that each component instance updates its own display independently.
Lifecycle Callbacks
Component Connection
connectedCallback() {
// If the view has not yet been initialised, we need to
// create it by adopting the template into the DOM, then cloning.
if (this.#view === null) {
this.#view = this.#createView();
this.shadowRoot.appendChild(this.#view);
this.#count = this.shadowRoot.getElementById("count");
}
this.countChanged();
}
When an instance is added to the DOM, connectedCallback is triggered. This callback is responsible for initialising the instance's view if it hasn't been already, appending it to the shadow DOM, and caching the count element.
The countChanged method also needs to be called here, because the attributeChangedCallback may not have fired yet (i.e. the count property might not have a value) and and so we need to display the default value of 0.
Responding to Attribute Changes
attributeChangedCallback() {
this.countChanged();
}
countChanged() {
// If the id is present it means the element has been connected to the DOM.
if (this.#count) {
const value = this.getAttribute("count") ?? 0;
this.#count.innerText = value;
}
}
The attributeChangedCallback and countChanged methods work hand in hand to update the component's display whenever the count attribute changes. This ensures the UI is always in sync with the component's state.
Template Adoption and Cloning
#createView() {
if (MyCounter.#fragment === null) {
const template = document.getElementById("my-counter");
MyCounter.#fragment = document.adoptNode(template.content);
}
return MyCounter.#fragment.cloneNode(true);
}
The #createView method checks if the template has been adopted by the class. If not, it adopts the template into the document. Then, it clones this template to create a view for the instance. This process is a critical performance optimisation, avoiding unnecessary operations on the DOM.
Component Registration
customElements.define("my-counter", MyCounter);
Finally, MyCounter is registered as a custom element with customElements.define, allowing developers to use <my-counter> tags directly in their HTML, just like any other standard element.
Conclusion
MyCounter demonstrates the very basic and low-level characteristics of creating and updating a custom element. I hope you enjoyed reading this - I'll be posting more as I continue learning.
Top comments (0)