Anatomy of a Web Component: the Low-level Basics

andyjessop

Andy Jessop

Posted on November 8, 2023

Anatomy of a Web Component: the Low-level Basics

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>
Enter fullscreen mode Exit fullscreen mode

will render this:

3
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

Declaring the MyCounter class.

class MyCounter extends HTMLElement {
Enter fullscreen mode Exit fullscreen mode

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"];
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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();
  }
Enter fullscreen mode Exit fullscreen mode

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;
    }
  }
Enter fullscreen mode Exit fullscreen mode

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);
  }
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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.

💖 💪 🙅 🚩
andyjessop
Andy Jessop

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