DEV Community

Pascal Schilp
Pascal Schilp

Posted on • Updated on

Web Components: from zero to hero, part two

Web components: from zero to hero, part two

Supercharging web components with lit-html

If you've followed along with part one of this blog series, you'll know the basics of web components by now. If you haven't, I suggest you go back to part one and catch up, because we'll be revisiting, and building on top of a lot of the concepts we covered in part one.

In this blog post, we'll be supercharging our to-do application with a rendering library called lit-html. But before we dive in, there's a couple of things we need to discuss. If you've paid close attention, you'll have noticed that I referred to our web component as being a raw web component before. I did that, because web components are low level, and don't include templating or other features by design. Web components were always intended as a collection of standards that do very specific things that the platform didn't allow yet.

I'd like to quote Justin Fagnani by saying that all web components do is give the developer a when and a where. The when being element creation, instantiation, connecting, disconnecting, etc. The where being the element instance and the shadowroot. What you do with that is up to you.

Additionally, lit-html is not a framework. It's simply a javascript library that leverages standard javascript language features. The difference between libraries and frameworks is often a controversial subject, but I'd like to define it as this analogy by Dave Cheney:

Frameworks are based on the Hollywood Pattern; don’t call us, we’ll call you.

Lit-html is also extremely lightweight at <2kb, and renders fast.

Now that we've got that out of the way, let's see how lit-html works.

🔥 Lit-html

  • [x] Learn about Lit-html
  • [ ] Lit-html in practice
  • [ ] Supercharge our web component
  • [ ] Attributes, properties, and events
  • [ ] Wrapping up

Lit-html is a rendering library that lets you write HTML templates with javascript template literals, and efficiently render and re-render those templates to DOM. Tagged template literals are a feature of ES6 that can span multiple lines, and contain javascript expressions. A tagged template literal could look something like this:

const planet = "world";

html`hello ${planet}!`;

Tagged template literals are just standard ES6 syntax. And these tags are actually just functions! Consider the following example:

function customFunction(strings) {
    console.log(strings); // ["Hello universe!"]
}

customFunction`Hello universe!`;

They can also handle expressions:

const planet = "world";

function customFunction(strings, ...values) {
    console.log(strings); // ["Hello ", "! five times two equals "]
    console.log(values); // ["world", 10]
}

customFunction`Hello ${planet}! five times two equals ${ 5 * 2 }`;

And if we look in the source code we can see this is exactly how lit-html works as well:

/**
 * Interprets a template literal as an HTML template that can efficiently
 * render to and update a container.
 */
export const html = (strings: TemplateStringsArray, ...values: any[]) =>
    new TemplateResult(strings, values, 'html', defaultTemplateProcessor);

Now if we'd write something like this:

const planet = "world";

function customFunction(strings, ...values) {
    console.log(strings); // ["<h1>some static content</h1><p>hello ", "</p><span>more static content</span>"]
    console.log(values); // ["world"]
}

customFunction`
    <h1>some static content</h1>
    <p>hello ${planet}</p>
    <span>more static content</span>    
`;

You'll notice that when we log our strings and values to the console, we've already separated the static content of our template, and the dynamic parts. This is great when we want to keep track of changes, and update our template with the corresponding data, because it allows us to only watch the dynamic parts for changes. This is also a big difference with how VDOM works because we already know the <h1> and the <span> are static, so we don't have to do anything with them. We're only interested in the dynamic parts, which can be any javascript expression.

So lit-html takes your template, replaces all the expressions with generic placeholders called Parts, and makes a <template> element out of the result. So we now have a HTML template, that knows where it has to put any data it will receive.

<template>
    <h1>some static content</h1>
    <p>hello {{}}</p> <-- here's our placeholder, or `Part`
    <span>more static content</span>    
</template>

Lit remembers where these placeholders are, which allows for easy and efficient updates. Lit will also efficiently reuse <template>s:

const sayHello = (name) => html`
    <h1>Hello ${name}</h1>
`;

sayHi('world');
sayHi('universe');

Both these templates will share the exact same <template> for efficiency, the only thing that's different is the data we're passing it. And if you paid close attention, you'll remember that we used the same technique in part one of this blog series.

The dynamic Parts of our template can be any javascript expression. Lit-html doesn't even have to do any magic to evaluate our expressions, javascript just does this for us. Here are some examples:

Simple:

customFunction`<p>${1 + 1}</p>`; // 2

Conditionals:

customFunction`<p>${truthy ? 'yes' : 'no'}</p>`; // 'yes'

And we can even work with arrays and nesting:

customFunction`<ul>${arr.map(item => customFunction`<li>${item}</li>`)}</ul>`;

🚀 Lit-html in practice

  • [x] Learn about Lit-html
  • [x] Lit-html in practice
  • [ ] Supercharge our web component
  • [ ] Attributes, properties, and events
  • [ ] Wrapping up

So let's see how this works in practice:

updating

You can see the full demo here or on github.

import { html, render } from 'lit-html';

class DemoElement extends HTMLElement {
  constructor() {
    super();
    this._counter = 0;
    this._title = "Hello universe!";
    this.root = this.attachShadow({ mode: "open"});
    setInterval(() => {this.counter++}, 1000);
  }

  get counter() {
    return this._counter;
  }

  set counter(val) {
    this._counter = val;
    render(this.template(), this.root);
  }

  template() {
    return html`
      <p>Some static DOM</p>
      <h1>${this.counter}</h1>
      <h2>${this._title}</h2>
      <p>more static content</p>
    `;
  }
}

window.customElements.define('demo-element', DemoElement);

If you've read the first blog post in this series, this should look familiar. We've made a simple web component, that increments a counter every second, and we've implemented lit-html to take care of our rendering for us.

The interesting bits are here:

    return html`
      <p>Some static DOM</p>
      <h1>${this.counter}</h1>
      <h2>${this._title}</h2>
      <p>more static content</p>
    `;

And the ouput in the DOM:

litoutput

We can now see how lit only updates the part of our code that has changed (this.counter), and doesn't even bother with the static parts. And it does all this without any framework magic or VDOM, and at less than 2kb library size! You also might notice a bunch of HTML comments in the output; Fear not, this is how lit-html keeps track of where static and dynamic parts are.

⚡️ Supercharging our component

  • [x] Learn about Lit-html
  • [x] Lit-html in practice
  • [x] Supercharge our web component
  • [ ] Attributes, properties, and events
  • [ ] Wrapping up

Now that we know how lit-html renders, lets put it in practice. You can see the full code here and on github. We'll be walking through this step by step, but lets get an overview of our supercharged component first:

to-do-app.js:

import { html, render } from 'lit-html';
import './to-do-item.js';

class TodoApp extends HTMLElement {
    constructor() {
        super();
        this._shadowRoot = this.attachShadow({ 'mode': 'open' });

        this.todos = [
        { text: 'Learn about Lit-html', checked: true },
        { text: 'Lit-html in practice', checked: false },
        { text: 'Supercharge our web component', checked: false },
        { text: 'Attributes, properties, and events', checked: false },
        { text: 'Wrapping up', checked: false }
    ];

        render(this.template(), this._shadowRoot, {eventContext: this});

        this.$input = this._shadowRoot.querySelector('input');
    }

    _removeTodo(e) {
      this.todos = this.todos.filter((todo,index) => {
          return index !== e.detail;
      });
    }

    _toggleTodo(e) {
      this.todos = this.todos.map((todo, index) => {
          return index === e.detail ? {...todo, checked: !todo.checked} : todo;
      });
    }

    _addTodo(e) {
      e.preventDefault();
      if(this.$input.value.length > 0) {
          this.todos = [...this.todos, { text: this.$input.value, checked: false }];
          this.$input.value = '';
      }
    }

    template() {
        return html`
            <style>
                :host {
                    display: block;
                    font-family: sans-serif;
                    text-align: center;
                }
                button {
                    border: none;
                    cursor: pointer;
                    background-color: Transparent;
                }
                ul {
                    list-style: none;
                    padding: 0;
                }
            </style>
            <h3>Raw web components + lit-html</h3>
            <br>
            <h1>To do</h1>
            <form id="todo-input">
                <input type="text" placeholder="Add a new to do"></input>
                <button @click=${this._addTodo}>✅</button>
            </form>
            <ul id="todos">
              ${this.todos.map((todo, index) => html`
                    <to-do-item 
                        ?checked=${todo.checked}
                        .index=${index}
                        text=${todo.text}
                        @onRemove=${this._removeTodo}
                        @onToggle=${this._toggleTodo}>    
                    </to-do-item>
                  `
              )}
            </ul>
        `;
    }

    set todos(value) {
        this._todos = value;
        render(this.template(), this._shadowRoot, {eventContext: this});
    }

    get todos() {
        return this._todos;
    }
}

window.customElements.define('to-do-app', TodoApp);

Got the general overview? Great! You'll find quite a lot things have changed in our code, so let's take a closer look.

The first thing you might have noticed is that the way we handled the rendering of our component has completely changed. In our old app we had to imperatively create a template element, set its innerHTML, clone it, and append it to our shadowroot. When we wanted to update our component, we had to create a bunch of elements, set their attributes, add their event listeners and append them to the DOM. All by hand. I'm getting a headache just reading that. What we've done instead is delegate all the rendering to lit-html.

Now we only declare our template once, we can set attributes, properties and events declaratively in the template, and just call lit-html's render function when we need to. The great thing about lit-html is that it's fast and efficient at rendering; It looks only at the dynamic expressions, and changes only what needs to be updated. And all this without the overhead of a framework!

You'll also notice we changed our _addTodo, _removeTodo and _toggleTodo methods to some immutable update patterns instead. This is nice because every time we set the value of todos, we'll trigger a render of our component. This is an important concept that we'll explore more in the third and final part of this blog series.

🔨 Attributes, properties, and events

  • [x] Learn about Lit-html
  • [x] Lit-html in practice
  • [x] Supercharge our web component
  • [x] Attributes, properties, and events
  • [ ] Wrapping up

Let's continue and take a look at how lit-html handles attributes, properties, and events.

${this.todos.map((todo, index) => {
    return html`
        <to-do-item 
            ?checked=${todo.checked}
            .index=${index}
            text=${todo.text}
            @onRemove=${this._removeTodo}
            @onToggle=${this._toggleTodo}>    
        </to-do-item>
    `;
})}

You might have seen this weird syntax in the updated version of our component, and wonder what it means. Lit-html allows us to declaratively set our attributes, properties and event handlers in our templates, as opposed to setting them imperatively. Since we learned all about attributes, properties and events in part one of this series, this should be easy enough to follow. If you need a refresher, I got you covered.

Let's walk through all of this step by step.

💅 Attributes

text=${todo.text}

We set attributes in lit-html... Exactly like you'd set an attribute in standard HTML. The only difference is the fact that we're using a dynamic value in a template string. Very anticlimactic, I know. We previously had to set our attributes imperatively like this: el.setAttribute('text', todo.text);.

Hey! Listen!

Regular attributes are still only limited to String types!

☑️ Boolean attributes

?checked=${todo.checked}

As you'll remember from the last blog post, Boolean attributes are generally handled a bit differently...

Flashback

...

This means that only the following examples are acceptable for a true value:

<div hidden></div>
<div hidden=""></div>
<div hidden="hidden"></div>

And one for false:

<div></div>

Conveniently enough, lit-html allows us to easily specify our attribute as a Boolean attribute by prefixing the attribute name with a ?, and then makes sure the attribute is either present on the element, or not.

Previously we set our boolean attributes as:

if(todo.checked){
    el.setAttribute('checked', '');
}

and omitted it altogether when our conditional was falsy.

📂 Properties

.index=${index}

If we want to pass down some rich data like arrays or objects, or in this case, a number value, we can simply use the dot prefix.

Previously, to set properties on our components, we had to imperatively query for the component, and set the property. Thanks to lit-html, we can handle all this in our template instead.

Previously we set properties as:

el.index = index;

🎉 Events

@onRemove=${this._removeTodo}

And finally, we can declaratively specify our event listeners by prefixing them with an @. Whenever the to-do-item component fires an onRemove event, this._removeTodo is called. Easy peasy.

Just to give you another example, here's how we could handle a click event:

<button @click=${this._handleClick}></button>

Hey! Listen!

Notice how we specified an eventContext in our render() function: render(this.template(), this._shadowRoot, {eventContext: this});. This makes sure we always have the correct reference to this in our event handlers, and makes it so we don't have to manually .bind(this) our event handlers in the constructor.

💭 Wrapping up

  • [x] Learn about Lit-html
  • [x] Lit-html in practice
  • [x] Supercharge our web component
  • [x] Attributes, properties, and events
  • [x] Wrapping up

If you made it all the way here, you're on your way to becoming a real Web Components hero. You've learned about lit-html, how lit-html renders, how to use attributes, properties and events, and how to implement lit-html to take care of the rendering of your Web Component.

Great job! We supercharged our web component, and it now efficiently renders to-do's, but we still have a bunch of boilerplate code, and a lot of property and attribute management to take care of. It would be great if there would be an easier way to handle all this...

...what?

...Is it a bird? 🐦

...Is it a plane? ✈️

It's...

💥 LitElement 💥

Which we'll talk about in the next, and final part of this blog series. Thanks for reading!

Latest comments (3)

Collapse
 
bitecode profile image
BC

Great Post, thank you! There might be a typo in the example code:

const sayHello = (name) => html`
    <h1>Hello ${name}</h1>
`;

sayHi('world');
sayHi('universe');

I believe this sayHi should be sayHello?

Collapse
 
mantou132 profile image
mantou

hi.
When a string is compiled into a dom, how do you establish a connection between the data and the dom?

In other words, when render is called again, how do you update dom?

Collapse
 
tiho2 profile image
tiho2

It is delegated to Lit-html as explained in this tutorial.