javascriptweb-componentcustom-element

JS CustomElements - Self 'registration' upon first instantiation


so I was looking for a way to setup an ES6 'abstract' base class for custom elements, which self-registers (window.customElements.define) upon the first instantiation of a child.

The approach I came up with is the following:

class MyBase extends HTMLElement {
  constructor(tag, child) {
    MyBase.register(tag, child);
    super();
    
    if(this.constructor.name === MyBase) {
      throw new Error('Error: Do not instantiate base class!');
    }
  }
  
  static register(tag, child) {
    if(!window.customElements.get(tag)) {
      window.customElements.define(tag, child);
    }
  }
}

class MyChild extends MyBase {
  constructor() {
    super(MyChild.Tag(), MyChild);
    
    if(window.customElements.get(MyChild.Tag())) {
      console.log(`${this.constructor.name} registered!`)
    }
  }
  static Tag() {
    return "my-child";
  }
}

const NiceChild = new MyChild();

Is there a way to avoid the statics and the MyBase constructor parameters? I've tried various approaches but I wasn't able to figure out a more elegant solution due to the fact that the registration must be called before the HTMLElement constructor is called. Also, I of course don't want the registration to be performed if MyBase is instantiated directly.

Edit:

I've added another snippet to showcase that the registration in fact happens upon instantiation of MyChild.

class MyBase extends HTMLElement {
  constructor(tag, child) {
    MyBase.register(tag, child);
    super();
    
    if(this.constructor.name === MyBase) {
      throw new Error('Error: Do not instantiate base class!');
    }
  }
  
  static register(tag, child) {
    if(!window.customElements.get(tag)) {
      window.customElements.define(tag, child);
    }
  }
}

class MyChild extends MyBase {
  constructor() {
    super(MyChild.Tag(), MyChild);
  }
  static Tag() {
    return "my-child";
  }
}

if(window.customElements.get("my-child")) {
  console.log(`Before Constructor: my-child registered!`)
} else {
  console.log(`Before Constructor: my-child not registered!`)
}

const NiceChild = new MyChild();

if(window.customElements.get("my-child")) {
   console.log(`After Constructor: my-child registered!`)
} else {
   console.log(`After Constructor: my-child not registered!`)
}


Solution

  • The constructor is not where you register your custom element: you register it "immediately" with your class declaration, and then if you want your code to start running only after the browser has finished loading that in, wait for it.

    And note that, because we can call customElements.define statically inside a class, we can also register subclasses by calling the base class's register using a static assignment (but then you'll want to mark it as a private field because it'll be "useless" to anything else)

    other-thing {
      display: block;
    }
    <other-thing>from html source</other-thing>
    
    <!-- note that this is ESM -->
    <script type="module">
      /**
       * JS has no "abstract" classes. This is just a normal base class,
       * and it won't be something folks can use because we're not
       * going to register it as a usable custom element.
       */
      class Thing extends HTMLElement {
        static register(tag, child) {
          if(!customElements.get(tag)) {
            customElements.define(tag, child);
          } else {
            console.warn(`<${tag}> already exists, skipping registration`);
          }
        }
        // This class's constructor should basically "do nothing".
        // For custom elements, the function that matters is connectCallback()
      }
    
      /**
       * This class _will_ be usable, because we're going to register
       * it with its own custom tag "<other-thing>".
       */
      class OtherThing extends Thing {
        // Immediately Register this class's custom tag:
        static #register = Thing.register(`other-thing`, this);
    
        // And whenever instances are added to the DOM, do something:
        connectedCallback() {
          console.log(`text content = ${this.textContent}`);
        }
      }
    
      // Then wait for the browser to finish with that:
      await customElements.whenDefined(`other-thing`);
    
      // And then we can do whatever we want.
      const myOtherThing = new OtherThing();
      myOtherThing.textContent = `from the JS side`;
      document.body.appendChild(myOtherThing);
    </script>

    Also note that you don't want to do all that much of anything in your constructor: not only do you not want to register the custom element until an instance is created, when the constructor runs there isn't even a DOM node to work with yet. That only happens when the element gets inserted into the DOM, at which point the connectedCallback function gets called.