Skip to content

JavaScript web component / custom element base class which automatically imports the component's stylesheet and defines it in the custom element browser registry.

Notifications You must be signed in to change notification settings

TomasHubelbauer/wc-base

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

2 Commits
 
 
 
 

Repository files navigation

Web Components Base Class

I'm playing around with web components in plain JavaScript recently and one thing that I wished I could achieve without using a bundler is the experience of defining a component and having its stylesheet associated with it as well as having it be defined in the customElements registry automatically, as is provided by UI frameworks and libraries which provide custom components built on top of the web components technology and achieved using a bundler/compiler.

The ideal experience is as follows:

// `MyComponent.css` is added to the shadow root
// `my-app-my-component` is defined in `customElements`
class MyComponent extends Component {

}

Initially I thought this would do the trick as it worked a charm in my single-file prototype:

/**
 * Provides a base class for all custom HTML elements (web components) which
 * automatically registers the component under a tag name based on the derived
 * class' name and places a stylesheet `link` DOM element referencing a CSS file
 * by the same name as that of the component into the component's shadow root.
 */
export default class Component extends HTMLElement {
  constructor() {
    // Obtain derived class name (caller function name) to be able to evaluate it into the derived class constructor
    // NOTE: this.construct.name cannot be called before super and we need to call customElements.defined before super
    // NOTE: arguments.callee.caller.name cannot be used in strict mode which is implied in ESM
    const derivedClassName = (new Error()).stack.split('\n')[1].match(/^\w+/)[0];

    // Evaluate the derived class name into the derived class constructor needed for the custom element definition
    const derivedClass = eval(derivedClassName);

    // Derive HTML custom element name by splitting the class name words, prefixing with `paper` and joining with dashes
    const customElementTagName = 'paper' + derivedClassName.replace(/[A-Z]/g, match => '-' + match[0].toLowerCase());

    // Define the custom element before calling super so that the `super` call succeeds
    // NOTE: This needs to be called prior to `super` otherwise the `super` call will fail with *Illegal constructor*
    customElements.define(customElementTagName, derivedClass);

    // Call the base HTMLElement class which will now succeed since the derived class constructor has a defined element
    super();

    // Attach shadow in the closed mode to isolate the component's styles
    this._shadowRoot = this.attachShadow({ mode: 'closed' });

    // Import the component's styles by appending a `link` element pointing to a CSS file by the name of the component
    const link = document.createElement('link');
    link.rel = 'stylesheet';
    link.href = derivedClassName + '.css';
    this._shadowRoot.append(link);
  }
}

However, it stopped working the moment I attempted to bring it to my application! Why? Because the eval is scoped to the context of the ESM module it is executed in so the derived class is not in the scope and therefore cannot be eval'd into a constructor by its name. You cannot use static import in eval so we cannot ad-hoc import it by that name either like we do with the stylesheet. We could use a dynamic import, but the constructor itself cannot be async, so this is a no-go, too.

I experimented with "importing" the portion of the code which evaluates the derived class name into the derived class constructor into the scope of the module where the derived class actually resides, so you would instead call something like super(hack()) in your component and it would evaluate the name there and provide the constructor to the base class, but this didn't work either, because while the top of the call site is in the derived class module, the eval itself is still in the base class module.

After mulling over this for a bit, I came up with something which deviates from the initial ideal call-site experience a little, but it's not too bad:

class MyComponent extends Component {
  constructor() {
    super(MyComponent);
  }
}

It is less elegant in that you always have to have the constructor, but I do have those in 100 % of my components, so it is not that bad in practice, albeit a little less cool. You also have to pass in the symbol name for the class, not just this, which is a shame and there is a risk of copy-paste errors, but I guard against those dynamically, so it's something I guess:

/**
 * Provides a base class for all custom HTML elements (web components) which
 * automatically registers the component under a tag name based on the derived
 * class' name and places a stylesheet `link` DOM element referencing a CSS file
 * by the same name as that of the component into the component's shadow root.
 */
export default class Component extends HTMLElement {
  constructor(/** @type {Component} */ constructor) {
    if (!constructor) {
      throw new Error('Constructor not defined! Pass the derived class name, e.g.: `super(MyComponent)` instead of `super()`!');
    }

    // Derive the component custom HTML element tag name from its constructor class name
    const name = 'paper' + constructor.name.replace(/[A-Z]/g, match => '-' + match[0].toLowerCase());

    // Define the custom element before calling super so that the `super` call succeeds
    if (!customElements.get(name)) {
      customElements.define(name, constructor);
    }

    // Call the base HTMLElement class which will succeed as the component class constructor has a defined element
    super();

    // Check that the provided constructor was correct to prevent against copy-paste errors and element misdefinitions
    if (constructor !== this.constructor) {
      throw new Error(`Incorrect constructor passed! Pass ${this.constructor.name} instead of ${constructor.name}!`);
    }

    // Attach shadow in the closed mode to isolate the component's styles
    this._shadowRoot = this.attachShadow({ mode: 'closed' });

    // Import the component's styles by appending a `link` element pointing to a CSS file by the name of the component
    const link = document.createElement('link');
    link.rel = 'stylesheet';
    link.href = constructor.name + '.css';
    this._shadowRoot.append(link);
  }
}

This works as expected and it's good enough for me to use in plain JavaScript to approximate the bundler/custom compiler experience without having to actually use either and introduce that complexity to a personal project where I'm not compensated for my time lost troubleshooting tooling and not my own bugs unlike at work, where I am happy to use tooling such as a bundler or a custom compiler (TypeScript, Svelte). So the trade-off is still good with this design.

This also opens up opportunities for attaching performance hooks to the components automatically, which I'm excited about.

About

JavaScript web component / custom element base class which automatically imports the component's stylesheet and defines it in the custom element browser registry.

Topics

Resources

Stars

Watchers

Forks