back

BYOBC: Bring your own base class

May 20, 2024 – @hawkticehurst

Last December I started a series of blog posts that introduced and explored the world of web components. Holiday obligations followed by several months of wedding planning, a move, and an upcoming grad school application meant the series has had to take a pause, but today we’re finally back with the seventh installment!

You can thank Chris Coyier for this post. A discussion on Mastodon reminded me of the topic I wanted to talk about and so despite looming deadlines and better judgement I’ve been spurred to put pen to paper.

The basic idea of Chris’s post is that web components stand out as a frontend technology that may finally break the curse of framework dependent componentry always eventually ending up in the proverbial tech junkyard. Aka, web components have the potential to last a life time. But there’s one major caveat: you must write dependency-free web components (read: without a framework).

Many often lament creating dependency-free web components because, for lack of better words, it’s a pain. in. the. butt.

But this doesn’t have to be the case. Building dependency-free web components that last a life time does not have to mean a bad developer experience.

Today we’re talking about web component base classes.

Web components are built on inheritance

Before we jump in we need to do some quick context setting.

When I say “base class” it’s really just a casual (and slightly wrong) way of saying class inheritance and is used everywhere in the world of web components. Class inheritance is used to create layers of abstraction where new functionality can be built on top of existing functionality.

Every single web component framework today uses a base class to expose it’s behavior. In fact, even vanilla web components use class inheritance.

class MyComponent extends HTMLElement {}
class MyComponent extends LitElement {}
class MyComponent extends FASTElement {}

In the above examples HTMLElement, LitElement, and FASTElement could all be casually referred to as web component base classes.

Note: If we're being precise, a base class is actually defined as a class that doesn't extend anything else. So in the above example HTMLElement itself is the only true "base class." Under the hood classes like LitElement and FASTElement all eventually extend HTMLElement.

It’s common to see more mature web component frameworks using a few layers of class inheritance and a few thousand lines of code to implement their functionality. For example, Lit’s inheritance chain currently looks like this: LitElement extends ReactiveElement extends HTMLElement.

While it’s certainly daunting to look at a wall of code like that and think you have no choice but to use a web component framework to get a good component authoring experience I’m hear to say that is not at all the case.

Creating good dependency-free web component DX

Creating your own productive web component base class is actually pretty easy. Below are a few examples of how very little code can enable a desirable web component authoring experience.

Clean up the boilerplate!

The most basic example of the usefulness of a base class is to clean up all that boilerplate we try to avoid by using frameworks.

In under 20 lines of code we can abstract away some of the most annoying details of building web components –– namely configuring Shadow DOM and simplifying markup creation by enforcing the use a render method, similar to Lit.

class BaseElement extends HTMLElement {
  constructor() {
    super();
    this.shadow = this.attachShadow({ mode: 'open' });
  }
  connectedCallback() {
    this._render();
  }
	_render() {
    if (!this.render) {
      throw new Error('Web components extending BaseElement must implement a `render` method.');
    }
    const markup = this.render();
    const template = document.createElement('template');
    template.innerHTML = `${markup}`;
    this.shadow.appendChild(template.content.cloneNode(true));
  }
}

Here it is in action. Pretty nice right?

class MyComponent extends BaseElement {
	render() {
		return `<h1>Hello! I'm in the Shadow DOM!</h1>`;
	}
}

Make it pop

How about convenient styling? Several more lines of code adds the ability to use the Constructable Stylesheets Web API with some nice fallback functionality for browsers that don’t support this API.

class BaseElement extends HTMLElement {
  constructor() {
    super();
    this.shadow = this.attachShadow({ mode: 'open' });
  }
  connectedCallback() {
    this._render();
  }
  _render() {
    if (!this.render) {
      throw new Error('Web components extending BaseElement must implement a `render` method.');
    }
    const markup = this.render();
    const styles = this.constructor.styles || '';
    const template = document.createElement('template');
    const useConstructableStyleSheets = this.shadow.adoptedStyleSheets !== undefined;
    if (styles.length > 0 && useConstructableStyleSheets) {
      const sheet = new CSSStyleSheet();
      sheet.replaceSync(styles);
      this.shadow.adoptedStyleSheets = [sheet];
    }
    template.innerHTML = `
      ${styles.length > 0 && !useConstructableStyleSheets ? `<style>${styles}</style>` : ''}
      ${markup}
    `;
    this.shadow.appendChild(template.content.cloneNode(true));
  }
}

And now we have some easy to author component styles!

class MyComponent extends BaseElement {
	static styles = `
		h1 { 
			color: thistle;
			font-family: cursive;
			font-size: 1.4rem;
		}
	`;
	render() {
		return `<h1>Hello! I'm in the Shadow DOM!</h1>`;
	}
}

Declarative interactivity

As a final example, we can use the NodeIterator Web API to add custom declarative event binding syntax (i.e. @click). It can handle native browser events and custom events.

class BaseElement extends HTMLElement {
	constructor() {
		super();
		this.shadow = this.attachShadow({ mode: 'open' });
		this._listeners = [];
	}
	connectedCallback() {
		this._render();
	}
	disonnectedCallback() {
		for (const { elem, event, callback } of this._listeners) {
			elem?.removeEventListener(event, callback);
		}
	}
	_render() {
		if (!this.render) {
			throw new Error('Web components extending BaseElement must implement a `render` method.');
		}
		const markup = this.render();
		const styles = this.constructor.styles || '';
		const template = document.createElement('template');
		const useConstructableStyleSheets = this.shadow.adoptedStyleSheets !== undefined;
		if (styles.length > 0 && useConstructableStyleSheets) {
			const sheet = new CSSStyleSheet();
			sheet.replaceSync(styles);
			this.shadow.adoptedStyleSheets = [sheet];
		}
		template.innerHTML = `
			${styles.length > 0 && !useConstructableStyleSheets ? `<style>${styles}</style>`: ''}
      ${markup}
    `;
		this.shadow.appendChild(template.content.cloneNode(true));
		this._configureEventListeners(this.shadow);
	}
	_configureEventListeners(rootNode) {
		let node;
		const nestedCustomElements = [];
		// Create a node iterator to iterate through all elements in the shadow root
		const iterator = document.createNodeIterator(
			rootNode,
			NodeFilter.SHOW_ELEMENT,
			{
				// Function for filtering out nodes we can skip
				acceptNode: (node) => {
					// Reject any node that is not an HTML element
					if (!(node instanceof HTMLElement)) {
						return NodeFilter.FILTER_REJECT;
					}
					// Check if node is a nested custom element
					if (node.tagName.includes('-') && node.tagName !== this.tagName) {
						nestedCustomElements.push(node);
						return NodeFilter.FILTER_REJECT;
					}
					// Check if node is a child of a nested custom element
					for (const nested of nestedCustomElements) {
						if (nested.contains(node)) {
							return NodeFilter.FILTER_REJECT;
						}
					}
					return NodeFilter.FILTER_ACCEPT;
				},
			}
		);
		// Iterate through all nodes in the shadow root
		while ((node = iterator.nextNode())) {
			if (!node) return;
			for (const attr of node.attributes) {
				// Check for custom event listener attributes
				if (attr.name.startsWith('@')) {
					this._processEventHandler(attr);
				}
			}
		}
		for (const { elem, event, callback } of this._listeners) {
			elem?.addEventListener(event, callback);
		}
	}
	_processEventHandler(attr) {
		const elem = attr.ownerElement;
		// Extract the name and value of the attribute
		// Example: `@click="handleClick"` -> `click` event and `handleClick` method
		const { name: event, value: method } = attr;
		this._listeners.push({
			elem: elem,
			event: event.slice(1),
			callback: (e) => this[method](e),
		});
		// Remove (non-standard) attribute from element
		elem.removeAttributeNode(attr);
	}
}

And voila, declarative event binding!

class MyComponent extends BaseElement {
	static styles = `
		button { 
			color: white;
			background-color: rgb(95, 85, 236, 0.5);
			border: 2px solid rgb(95, 85, 236);
			border-radius: 8px;
			padding: 6px 10px;
		}
	`;
	render() {
		return `<button @click="helloMsg">Click me!</button>`;
	}
	helloMsg() {
		alert('Hello there!');
	}
}

So many possibilities

In less than 100 lines of code we’ve built a dependency-free web component base class that I’d argue gets us 80% of the way to a “proper” web component framework in terms of functionality and usability.

Obviously, the notable missing piece from this post is reactivity. It’s not too much harder to implement once you learn some of the basic concepts, but I ran out of time to go over that today. I won’t leave you hanging though, here’s an example of a reactive HTML web components implementation in ~250 lines of code that I made last year.

Beyond that, however, there are so many possibilities for tweaking and adjusting the examples I gave above to suit your personal preferences. Perhaps you don’t like render as a method name or you want to add more custom attribute syntax that enables new behavior using the NodeIterator API –– the possibilities are basically endless.

Go forth and build dependency-free components that are fun to write and last a life time.

I want a marketplace of base classes

To leave with a final thought, I have often dreamed about the idea of a marketplace of web component base classes.

Imagine an UnJS-like ecosystem of composable base classes that all have one tightly scoped purpose and can be freely mixed and matched to build up the ideal component authoring experience for each job.

To keep in the spirit of Chris’s Mastodon post, all base classes would need to be built without any dependencies and would simply be copy-pasted into each project (à la shadcn/ui).

If there’s any interest in this idea, I’d love to hear what the community thinks a base class contract/protocol should look like so anyone could hook into and build for this composable ecosystem. I’d like to write my thoughts on this one day (and perhaps even build some of it), but first I’ve got a wedding to get to 😁.

Until the next one!