Anatomy of a Web Component: the Low-level Basics
Andy Jessop
Posted on November 8, 2023
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.
Posted on November 8, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
October 31, 2024
October 30, 2024
October 19, 2024
October 15, 2024