loading...
Cover image for Webcomponents: It's really that easy!

Webcomponents: It's really that easy!

sroehrl profile image neoan ・5 min read

It was 2015 when I first heard about webcomponents, custom elements and the mysterious shadow dom. Browser support was - well - let's call it experimental.

In a world of polyfills the name polymer seemed fitting for a framework supporting the more or less "Chrome only" technology. But even back then the enthusiasts seemed certain: this is the future. The reasons are obvious. Hooking into how the browser interprets elements provides a fast, snappy user experience that is reusable and contained.

Where we are

After early adopters experienced constant breaking changes to promising standard suggestions, we are now in a time where webcomponents feel stable, slick and extremely performant. More importantly: it has become simple.

The setup

We will not use any third-party libraries in this example, but I suggest taking a look at lit html for basic data binding needs.

all-caps

So here is what we want to do: We will create a custom element that will transform its text content to uppercase. Not exactly suspenseful and yes, certainly a little overkill compared to simply using CSS, but it gets the point across nicely. So we begin:

test.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Test custom element</title>
    <script src="all-caps.js" type="module">
</head>
<body>

<all-caps>this is uppercase</all-caps>

</body>
</html>

all-caps.js


// 1. create class extending HTMLElement
export class AllCaps extends HTMLElement {}

// 2. Define a new custom element
customElements.define('all-caps', AllCaps)

There is a lot to be said regarding these two lines of code.

First, we are extending HTMLElement. There are some necessities we will need to adhere to, but we'll get to that in the next step.

Next, we define 'all-caps' as a custom element (browser support should not be a problem anymore, but feel free to normalize behavior gist if you need to)

The constructor

So far so good. Now your class need a constructor. This function is executed when the class is initiated. It is important to understand that you will want to account for nesting and continue interpretation. While it is interesting to understand how JavaScript handles this in detail, it is sufficient to simply live by the following rule: Always start with super(). Don't worry, you will notice that 'this' is not available if you forget. That said, here is how our class looks now:

export class AllCaps extends HTMLElement {
    constructor() {
        super();
    }
}

Enter the Shadow DOM

The DOM (Document Object Model) is one of those expressions we use without thinking much about it. And one might be interested in looking into the history of HTML and respectively XML, but let's try to foster understanding by example:

In JavaScript, you may have wondered how something like document.getElementById() works regardless of context. Needless to say, that is because 'document' accesses (just like your browser) the global DOM tree. Whoever fought with XPath or iframes will have a painful story to tell about handling separated DOMs. On the other hand, separate documents allow for truly encapsulated elements. The Shadow DOM (or sometimes "virtual DOM") is just that. A "sub-DOM" that operates like its own document without the limitations of handling data and state an iframe would have. This is why the Shadow DOM does not inherit styles and provides safe re-usability in all contexts. Sounds great, doesn't it? You can even decide whether the "outside" has access to your element's Shadow DOM or not:

export class AllCaps extends HTMLElement {
    constructor() {
        super();
        // attach a shadow allowing for accessibility from outside
        this.attachShadow({mode: 'open'});
    }
}

At this point running test.html will show you a blank page as we work with a "new" DOM. However, this does not mean we have lost our content. Although I would prefer working with nodes, let's wrap up our code to get the first version of our intended output:

export class AllCaps extends HTMLElement {
    constructor() {
        super();
        // attach a shadow allowing for accessibility from outside
        this.attachShadow({mode: 'open'});

        // write our uppercased text to the Shadow DOM
        let toUpper = this.firstChild.nodeValue.toUpperCase();
        this.shadowRoot.innerHTML = toUpper;
    }
}

We got it! This is functional and refreshing test.html should show the expected outcome.

Advanced

Let's play around with some additional basics.

Applying style

NOTE: I would normaly structure this a little different, but to contain the bits we are talking about, let's do the following:

After the constructor, we add another function called "attachTemplate"

attachTemplate() {
    const template = document.createElement('template');
    template.innerHTML = `
        <style>
        :host{
         color: red;
        }
        </style>`;
    this.shadowRoot.innerHTML += template.innerHTML;
}

You might wonder about ":host". This selector refers to the element itself. In order to execute this function, we want to call it in our constructor:

this.attachTemplate()

Note that you could also make use of e.g. 'connectedCallback' as a function name but I want to keep this tutorial contained to the basics.
Our class should now look like this:

export class AllCaps extends HTMLElement {
    constructor() {
        super();
        this.attachShadow({mode: 'open'});
        let toUpper = this.firstChild.nodeValue.toUpperCase();
        this.shadowRoot.innerHTML = toUpper;
        this.attachTemplate();
    }
    attachTemplate() {
        const template = document.createElement('template');
        template.innerHTML = `
        <style>
        :host{
         color: red;
        }
        </style>`;
        this.shadowRoot.innerHTML += template.innerHTML;
    }
}

Reloading test.html should now give you not only uppercase, but also a red color (please consider single responsibility in real scenarios).

Slots

Another (here dirty) introduction at this point could be the use of slots. Slots can be named or referring to the complete content of the element. Let's try it out to get the hang of it:

In the literal string for our files, add the tag <slot></slot>, resulting in the following attachTemplate function

attachTemplate() {
    const template = document.createElement('template');
    template.innerHTML = `
        <slot></slot>
        <style>
        :host{
         color: red;
        }
        </style>`;
    this.shadowRoot.innerHTML += template.innerHTML;
}

Refreshing your browser, you will notice that the original content of our tag has been added to our DOM.

Attributes & data

As a last introduction, let's look into attributes. Again, this will be a non-sensical example, but I think it explains the concept well.
In our test.html, we will give our tag the attribute "addition" with the value "!"

<all-caps addition="!">hi there</all-caps>

Next, we will edit our template sting again and add ${this.addition} after our slot.

attachTemplate() {
    const template = document.createElement('template');
    template.innerHTML = `
        <slot></slot>
        ${this.addition}
        <style>
        :host{
         color: red;
        }
        </style>`;
    this.shadowRoot.innerHTML += template.innerHTML;
}

We now need to handle the attribute and at least account for it not being set. To do so, we should probably create a new function, but I will once again quickly "hack" it. In the contructior function, prior to executing "attachTemplate", we can add

if(this.hasAttribute('addition')){
    this.addition = this.getAttribute('addition')
} else {
    this.addition = '';
}

Our class now looks like this:

export class AllCaps extends HTMLElement {
    constructor() {
        super();
        this.attachShadow({mode: 'open'});
        let toUpper = this.firstChild.nodeValue.toUpperCase();
        this.shadowRoot.innerHTML = toUpper;
        if(this.hasAttribute('addition')){
            this.addition = this.getAttribute('addition')
        } else {
            this.addition = '';
        }
        this.attachTemplate();
    }
    attachTemplate() {
        const template = document.createElement('template');
        template.innerHTML = `
        <slot></slot>
        ${this.addition}
        <style>
        :host{
         color: red;
        }
        </style>`;
        this.shadowRoot.innerHTML += template.innerHTML;
    }

}

Refresh your browser to see the result.

Conclusion

This tutorial is meant to help you understand basic handling of custom elements and the Shadow DOM. As stated in the beginning, you probably want to use a library like lit-html to simplify things and you most certainly want to work a little cleaner (Fought with myself a lot between leading by example and keeping the code as consice as possible). However, I hope this gives you a good start and helps setting of the spark to dive deeper.

Today we can assume that webcomponents will be dominating the web and slowly push out performance intensive frameworks like Angular. Whether you are at the beginning of your career or a battle-tested React enthusiast, it does make sense to familiarize yourself with the direction the web is moving to. Enjoy!

Discussion

pic
Editor guide
Collapse
justinfagnani profile image
Justin Fagnani

What is the purpose of using a <template> in your example? You never clone it, you're just getting it's innerHTML as a string. This makes it a very expensive string holder.

You could simplify what you have to roughly this without the template:

export class AllCaps extends HTMLElement {
    constructor() {
        super();
        this.attachShadow({mode: 'open'});
        let toUpper = this.firstChild.nodeValue.toUpperCase();
        this.shadowRoot.innerHTML = `
          ${toUpper}
          <slot></slot>
          ${this.getAttribute('addition')}
          <style>
            :host{
             color: red;
            }
          </style>
        `;
    }
}

But not that with both a <slot> and copying the text content, you'll be displaying the content twice. You're also reading the attribute in the constructor, but it may not be set there yet.

Collapse
sroehrl profile image
neoan Author

Yes, you are absolutely right. As mentioned in several disclaimers throughout this post, this is not the structure you would go for. This example element is meant to introduce several concepts and by no means represents an actual custom element.
I carefully chose this setup to accommodate for progression throughout this tutorial and made some crude decisions to maintain overview yet somewhat of a separation.
As I am also concerned about potential readers imitating such a pattern: do you think I haven't mentioned used anti-patterns enough, or is your feedback only based on the actual code snippets?

Collapse
patarapolw profile image
Pacharapol Withayasakpunt

What is the best framework to use with Web Components? Perhaps even something like SnowPack? Or perhaps, Webpack or Parcel.js?

Today we can assume that webcomponents will be dominating the web and slowly push out performance intensive frameworks like Angular.

Actually, I hope that HTML/CSS/JS will all die. Only backend compilation to WebAssembly remains.

But in reality, at least HTML will probably survive.

Collapse
sroehrl profile image
neoan Author

HTML/CSS & JS will all survive for the time being. But yes, WebAssembly will make the same path I outlined here. Right now it's a little "clunky" to use, but that will change and will surely expand the web in a way never seen possible. What Google started with ChromeOS might progress into the next logical step: PCs will become simple "Internet terminals" and everything will be available through what we call a browser today.

As for the framework question: As mentioned, I like it simple and small. Since I personally work a lot backend, lit-html and axios is all I need to fulfill my needs.

Collapse
yurikaradzhov profile image
Yuri Karadzhov

You can try hqjs.org server. It works with web components as well as with different frameworks and metalanguages as typescript and scss

Collapse
sroehrl profile image
neoan Author

Thanks. But I run these with neoan3 as this

  1. Gives me the possibility to generate the skeleton of a new custom element via cli

  2. Allows me to use server side variables with ease

  3. Provides me with a unified system when developing API/backend

  4. Serve/use without building process or development server

Edit: sorry. I now realize that your suggestion is targeted at somebody else.

Thread Thread
yurikaradzhov profile image
Yuri Karadzhov

It was, but in any case you have to have server to deliver your code. hqjs.org is the server that avoid bundling and ensure that you browser get as little code as possible to run your app. It might not suite if you want to serve your app let's say from github, but can do the trick in m any other cases.

Collapse
somedood profile image
Basti Ortiz (Some Dood)

Surprisingly enough, you were indeed right about Web Components being "easy". Thanks for the neat introduction! I'll definitely start on a toy project for experimentation.

Collapse
sroehrl profile image
neoan Author

Thank you! This is exactly what I intended for the reader to take away from it.