rand[om]

rand[om]

med ∩ ml

My take on Web Components

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 to change; on_MyCustomEvent listens to MyCustomEvent
  • 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}, not this.state.key = value
  • this.html is just an alias for the html 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