© Francesco Strazzullo 2019
F. StrazzulloFrameworkless Front-End Developmenthttps://doi.org/10.1007/978-1-4842-4967-3_4

4. Web Components

Francesco Strazzullo1 
(1)
TREVISO, Treviso, Italy
 

All the major front-end frameworks that developers use today have something in common. They all use components as basic blocks for building the UI. In Chapter 2, you saw how to create a component registry based on pure functions. On (almost) all modern browsers, it’s possible to create components for your web applications with a suite of native APIs known as web components.

The APIs

Web components consist of three main technologies that let developers build and publish reusable UI components.
  • HTML templates. The <template> tag is useful if you want to keep content that is not rendered, but may be used by JavaScript code as a “stamp” to create dynamic content.

  • Custom elements. This API lets developers create their own fully featured DOM elements.

  • Shadow DOM: This technique is useful if the web components should not be affected by the DOM outside the component itself. It’s very useful if you’re creating a component library or a widget that you want to share with the world.

Caution

The shadow DOM and the virtual DOM solve two completely different problems. The shadow DOM is about encapsulation, whereas the virtual DOM is about performances. For more information, I suggest reading the post at https://develoger.com/shadow-dom-virtual-dom-889bf78ce701 .

Can I Use It?

As I write this chapter in early 2019, all three API are supported by all browsers except Internet Explorer and Edge (see Table 4-1). But the team behind Edge is developing the feature, and they plan to ship it by the end of 2019. In any case, is it easily polyfillable with this package ( https://github.com/webcomponents/custom-elements ). You have to add a lot of polyfills if you have to support IE, however, so I strongly suggest that you do not start developing web components.
Table 4-1

Status of Web Components Adoption (early 2019)

API Supported

Chrome

Firefox

Safari

Edge

Internet Explorer

HTML templates

Yes

Yes

Yes

Yes

No

Shadow DOM

Yes

Yes

Yes

Developing

No

Custom elements

Yes

Yes

Yes

Developing

No

I talked about HTML templates in Chapter 3. We used it in the last implementation of our rendering engine. Shadow DOM is beyond the scope of this chapter. I suggest reading the MDN tutorial about it at https://developer.mozilla.org/en-US/docs/Web/Web_Components/Using_shadow_DOM .

Custom Elements

The Custom Elements API is the core factor of the web components suite. In a nutshell, it permits you to create custom HTML tags, like this one:
<app-calendar/>

It is no coincidence that I used the name app-calendar. When you create a custom tag with the Custom Elements API, you have to use at least two words separated by a dash. Every one-word tag is for the sole use of the World Wide Web Consortium (W3C). In Listing 4-1, you see the simplest custom element possible: a “Hello World!” label.

Note

A custom element is just a JavaScript class that extends HTML elements.

export default class HelloWorld extends HTMLElement {
  connectedCallback () {
    window.requestAnimationFrame(() => {
      this.innerHTML = '<div>Hello World!</div>'
    })
  }
}
Listing 4-1

HelloWorld Custom Element

connectedCallback is one of the lifecycle methods of a custom element. This method is invoked when the component is attached to the DOM. It’s very similar to the componentDidMount method in React. It’s a good place to render the content of the component, like in our case, or to start timers or fetch data from the network. Similarly, the disconnectedCallback is invoked when the component is removed from the DOM, which is a useful method in any cleanup operation.

To use this newly created component, we need to add it to the browser component registry. To achieve this goal, we need to use the define method of the window.customElements property, as shown in Listing 4-2.
import HelloWorld from './components/HelloWorld.js'
window
  .customElements
  .define('hello-world', HelloWorld)
Listing 4-2

Adding HelloWorld to Custom Elements Registry

To add a component to the browser component registry means connecting a tag name ('hello-world' in our case) to a custom element class. After that, you can simply use the component with the custom tag that you created (<hello-world/>).

Managing Attributes

The most important feature of web components is that developers can make new components that are compatible with any framework; not just with React or Angular, but any web application, including legacy applications built with JavaServer Pages or some other older tool. But, to achieve this goal, the components need to have the same public API as any other standard HTML element. So if we want to add an attribute to a custom element, we need to be sure that we can manage this attribute in the same way as any other attribute. For a standard element like <input>, we can set an attribute in three ways.

The most intuitive way is to add the attribute directly to the HTML markup.
<input type="text" value="Frameworkless">
In JavaScript, you can manipulate the value attribute with a setter.
input.value = 'Frameworkless'
Alternatively , it’s possible to use the setAttribute method .
input.setAttribute('value', 'Frameworkless')

Each of these three methods accomplishes the same result: it changes the value attribute of the input element. They are also synchronized. If I input the value via the markup, I will read the same value with the getter or the getAttribute method . If I change the value with the setter or the setAttribute method, the markup will synchronize with the new attribute.

If we want to create an attribute for a custom element, we need to remember this characteristic of HTML elements. Listing 4-3 adds a color attribute to the HelloWorld component, which is used to change the color of the label’s content.
const DEFAULT_COLOR = 'black'
export default class HelloWorld extends HTMLElement {
  get color () {
    return this.getAttribute('color') || DEFAULT_COLOR
  }
  set color (value) {
    this.setAttribute('color', value)
  }
  connectedCallback () {
    window.requestAnimationFrame(() => {
      const div = document.createElement('div')
      div.textContent = 'Hello World!'
      div.style.color = this.color
      this.appendChild(div)
    })
  }
}
Listing 4-3

HelloWorld with an Attribute

As you can see, the color getter/setter is just a wrapper about getAttribute/setAttribute. So, the three ways to set an attribute are automatically synchronized.

To set the color of the component, you can use the setter (or setAttribute), or you can set the color via markup. An example using the color attribute is shown in Listing 4-4, and the related result in Figure 4-1.
<hello-world></hello-world>
<hello-world color="red"></hello-world>
<hello-world color="green"></hello-world>
Listing 4-4

Using the color Attribute for the HelloWorld Component

../images/476371_1_En_4_Chapter/476371_1_En_4_Fig1_HTML.jpg
Figure 4-1

HelloWorld component

Using this approach when designing attributes makes it easy for other developers to release the component. We only need to release the code of the component in a CDN, and then everyone could use it without any specific instructions. We just defined an attribute in the same way that the W3C did for standard components.

Nevertheless , this approach comes with a drawback: HTML attributes are strings. So when you need an attribute that is not a string, you have to first convert the attribute.

But, this strong constraint is really useful just for components that need to be published to other developers. In a real-world application based on web components, you may have a lot of components that are not meant to be published. They are “private” to your application. In these cases, you can just use a setter without converting the value to a string.

attributeChangedCallback

Listing 4-4 had the value of the color attribute in the connectedCallback method and applied that value to the DOM. But after the initial render, what happens if we change the attribute to a click event handler, as in seen Listing 4-5?
const changeColorTo = color => {
  document
    .querySelectorAll('hello-world')
    .forEach(helloWorld => {
      helloWorld.color = color
    })
}
document
  .querySelector('button')
  .addEventListener('click', () => {
    changeColorTo('blue')
  })
Listing 4-5

Changing the Color of the HelloWorld Component

When the button is clicked, the handler changes the color attribute of every HelloWorld component to blue. But on the screen, nothing happens. A quick-and-dirty solution to this problem would be adding some kind of DOM manipulation in the setter itself.
set color (value) {
  this.setAttribute('color', value)
  //Update DOM with the new color
}
But this approach is very fragile, because if we use the setAttribute method instead of the color setter, the DOM would not be updated either. The right way to manage attributes that can change during a component’s lifecycle is with the attributeChangedCallback method. This method (like its name suggests) is invoked every time attributes change. We can modify the code of our HelloWorld component (see Listing 4-6) to update the DOM every time that a new color attribute is provided.
const DEFAULT_COLOR = 'black'
export default class HelloWorld extends HTMLElement {
  static get observedAttributes () {
    return ['color']
  }
  get color () {
    return this.getAttribute('color') || DEFAULT_COLOR
  }
  set color (value) {
    this.setAttribute('color', value)
  }
  attributeChangedCallback (name, oldValue, newValue) {
    if (!this.div) {
      return
    }
    if (name === 'color') {
      this.div.style.color = newValue
    }
  }
  connectedCallback () {
    window.requestAnimationFrame(() => {
      this.div = document.createElement('div')
      this.div.textContent = 'Hello World!'
      this.div.style.color = this.color
      this.appendChild(this.div)
    })
  }
}
Listing 4-6

Updating the Color of the Label

The attributeChangedCallback method accepts three parameters: the name of the attribute that has changed, the attribute’s old value of the attribute, and the attribute’s new value.

Note

Not every attribute will trigger attributeChangedCallback, only the attributes listed in the observedAttributes array.

Virtual DOM Integration

Our virtual DOM algorithm from Chapter 2 is completely pluggable in any custom element. In Listing 4-7, there is a new version of the HelloWorld component. Every time that the color changes, it invokes the virtual DOM algorithm to modify the color of the label. The complete code for this example is at https://github.com/Apress/frameworkless-front-end-development/tree/master/Chapter04/00.3 .
import applyDiff from './applyDiff.js'
const DEFAULT_COLOR = 'black'
const createDomElement = color => {
  const div = document.createElement('div')
  div.textContent = 'Hello World!'
  div.style.color = color
  return div
}
export default class HelloWorld extends HTMLElement {
  static get observedAttributes () {
    return ['color']
  }
  get color () {
    return this.getAttribute('color') || DEFAULT_COLOR
  }
  set color (value) {
    this.setAttribute('color', value)
  }
  attributeChangedCallback (name, oldValue, newValue) {
    if (!this.hasChildNodes()) {
      return
    }
    applyDiff(
      this,
      this.firstElementChild,
      createDomElement(newValue)
    )
  }
  connectedCallback () {
    window.requestAnimationFrame(() => {
      this.appendChild(createDomElement(this.color))
    })
  }
}
Listing 4-7

Using Virtual DOM in a Custom Element

Using a virtual DOM for this scenario is clearly over-engineering, but it can be useful if your component has a lot of attributes. In a case like that, the code would be much more readable.

Custom Events

For the next example, we will analyze a more complex component called GitHubAvatar. The purpose of this component is to show the avatar of a GitHub user given their username. To use this component, you need to set the user attribute.
<github-avatar user="francesco-strazzullo"></github-avatar>
When the component is connected to the DOM, it shows a “loading” placeholder. Then it uses GitHub REST APIs to fetch the avatar image URL. If the request succeeds, the avatar is shown; otherwise, an error placeholder is shown. A diagram that explains how this component works is shown in Figure 4-2.
../images/476371_1_En_4_Chapter/476371_1_En_4_Fig2_HTML.png
Figure 4-2

Flowchart of GitHubAvatar component

You can look at the code of the GitHubAvatar component in Listing 4-8. For the sake of simplicity, I didn’t manage changes in the user attribute with the attributeChangedCallback method.
const ERROR_IMAGE = 'https://files-82ee7vgzc.now.sh'
const LOADING_IMAGE = 'https://files-8bga2nnt0.now.sh'
const getGitHubAvatarUrl = async user => {
  if (!user) {
    return
  }
  const url = `https://api.github.com/users/${user}`
  const response = await fetch(url)
  if (!response.ok) {
    throw new Error(response.statusText)
  }
  const data = await response.json()
  return data.avatar_url
}
export default class GitHubAvatar extends HTMLElement {
  constructor () {
    super()
    this.url = LOADING_IMAGE
  }
  get user () {
    return this.getAttribute('user')
  }
  set user (value) {
    this.setAttribute('user', value)
  }
  render () {
    window.requestAnimationFrame(() => {
      this.innerHTML = "
      const img = document.createElement('img')
      img.src = this.url
      this.appendChild(img)
    })
  }
  async loadNewAvatar () {
    const { user } = this
    if (!user) {
      return
    }
    try {
      this.url = await getGitHubAvatarUrl(user)
    } catch (e) {
      this.url = ERROR_IMAGE
    }
    this.render()
  }
  connectedCallback () {
    this.render()
    this.loadNewAvatar()
  }
}
Listing 4-8

GitHubAvatar Component

If you follow the flowchart shown in Figure 4-2, the code should be easy to read. To fetch the data from the GitHub API, I used fetch, a native way in modern browsers to make asynchronous HTTP requests. I talk more about this topic in Chapter 5. In Figure 4-3, you can see the result of various instances of the component.
../images/476371_1_En_4_Chapter/476371_1_En_4_Fig3_HTML.jpg
Figure 4-3

GitHubAvatar example

What if we want to react to the result of the HTTP request from the outside of the component itself? Remember that when it’s possible, a custom element should behave exactly like a standard DOM element. Earlier, we used attributes to pass information to a component, just like any other element. Following the same reasoning to get information from a component, we should use DOM events. In Chapter 3, I talked about the Custom Events API, which makes it possible to create DOM events that are bounded to the domain and not the user interaction with the browser.

In Listing 4-9, a new version of the GitHubAvatar component emits two events: one when the avatar is loaded and one when an error occurs.
const AVATAR_LOAD_COMPLETE = 'AVATAR_LOAD_COMPLETE'
const AVATAR_LOAD_ERROR = 'AVATAR_LOAD_ERROR'
export const EVENTS = {
  AVATAR_LOAD_COMPLETE,
  AVATAR_LOAD_ERROR
}
export default class GitHubAvatar extends HTMLElement {
  ...
  onLoadAvatarComplete () {
    const event = new CustomEvent(AVATAR_LOAD_COMPLETE, {
      detail: {
        avatar: this.url
      }
    })
    this.dispatchEvent(event)
  }
  onLoadAvatarError (error) {
   const event = new CustomEvent(AVATAR_LOAD_ERROR, {
     detail: {
       error
     }
   })
   this.dispatchEvent(event)
  }
  async loadNewAvatar () {
    const { user } = this
    if (!user) {
      return
    }
    try {
      this.url = await getGitHubAvatarUrl(user)
      this.onLoadAvatarComplete()
    } catch (e) {
      this.url = ERROR_IMAGE
      this.onLoadAvatarError(e)
    }
    this.render()
  }
  ...
}
Listing 4-9

GitHubAvatar with Custom Events

In Listing 4-10, we attach event handlers to the two kinds of events. In Figure 4-4, you can see that the right handlers are invoked.
import { EVENTS } from './components/GitHubAvatar.js'
document
  .querySelectorAll('github-avatar')
  .forEach(avatar => {
    avatar
      .addEventListener(
        EVENTS.AVATAR_LOAD_COMPLETE,
        e => {
          console.log(
            'Avatar Loaded',
            e.detail.avatar
          )
        })
    avatar
      .addEventListener(
        EVENTS.AVATAR_LOAD_ERROR,
        e => {
          console.log(
            'Avatar Loading error',
            e.detail.error
          )
        })
  })
Listing 4-10

Attaching Event Handlers to GitHubAvatar Events

../images/476371_1_En_4_Chapter/476371_1_En_4_Fig4_HTML.jpg
Figure 4-4

GitHubAvatar with events

Using Web Components for TodoMVC

It’s time to build the usual TodoMVC application. This time, we are going to use web components. Most of the code will be similar to the previous versions based on functions. I decided to split the application into three components: todomvc-app, todomvc-list, and todomvc-footer, as shown in Figure 4-5.
../images/476371_1_En_4_Chapter/476371_1_En_4_Fig5_HTML.jpg
Figure 4-5

TodoMVC components

The first thing that I want to analyze is the HTML part of the application. As you see in Listing 4-11, I made extensive usage of the <template> element.
<body>
    <template id="footer">
        <footer class="footer">
            <span class="todo-count">
            </span>
            <ul class="filters">
                <li>
                    <a href="#/">All</a>
                </li>
                <li>
                    <a href="#/active">Active</a>
                </li>
                <li>
                    <a href="#/completed">
                      Completed
                    </a>
                </li>
            </ul>
            <button class="clear-completed">
                Clear completed
            </button>
        </footer>
    </template>
    <template id="todo-item">
        <li>
            <div class="view">
                <input
                  class="toggle" type="checkbox">
                <label></label>
                <button class="destroy"></button>
            </div>
            <input class="edit">
        </li>
    </template>
    <template id="todo-app">
        <section class="todoapp">
            <header class="header">
                <h1>todos</h1>
                <input class="new-todo"
                  autofocus>
            </header>
            <section class="main">
                <input
                  id="toggle-all"
                  class="toggle-all"
                  type="checkbox">
                <label for="toggle-all">
                    Mark all as complete
                </label>
                <todomvc-list></todomvc-list>
            </section>
            <todomvc-footer></todomvc-footer>
        </section>
    </template>
    <todomvc-app></todomvc-app>
</body>
Listing 4-11

HTML for TodoMVC Application with Web Components

To keep the code simple, I only implemented two of the many events that are present in the complete TodoMVC: adding an item and deleting it. This way, we can skip the todomvc-footer code and concentrate on todomvc-app and todomvc-list. If you’re interested, you can check the complete code on GitHub at https://github.com/Apress/frameworkless-front-end-development/tree/master/Chapter04/01 . Let’s start with the list in Listing 4-12.
const TEMPLATE = '<ul class="todo-list"></ul>'
export const EVENTS = {
  DELETE_ITEM: 'DELETE_ITEM'
}
export default class List extends HTMLElement {
  static get observedAttributes () {
    return [
      'todos'
    ]
  }
  get todos () {
    if (!this.hasAttribute('todos')) {
      return []
    }
    return JSON.parse(this.getAttribute('todos'))
  }
  set todos (value) {
    this.setAttribute('todos', JSON.stringify(value))
  }
  onDeleteClick (index) {
    const event = new CustomEvent(
      EVENTS.DELETE_ITEM,
      {
        detail: {
          index
        }
      }
  )
    this.dispatchEvent(event)
}
createNewTodoNode () {
    return this.itemTemplate
      .content
      .firstElementChild
      .cloneNode(true)
}
getTodoElement (todo, index) {
  const {
    text,
    completed
  } = todo
  const element = this.createNewTodoNode()
  element.querySelector('input.edit').value = text
  element.querySelector('label').textContent = text
  if (completed) {
    element.classList.add('completed')
    element
      .querySelector('input.toggle')
      .checked = true
  }
  element
    .querySelector('button.destroy')
    .dataset
    .index = index
  return element
}
updateList () {
  this.list.innerHTML = "
  this.todos
    .map(this.getTodoElement)
    .forEach(element => {
      this.list.appendChild(element)
    })
  }
  connectedCallback () {
    this.innerHTML = TEMPLATE
    this.itemTemplate = document
      .getElementById('todo-item')
    this.list = this.querySelector('ul')
    this.list.addEventListener('click', e => {
      if (e.target.matches('button.destroy')) {
        this.onDeleteClick(e.target.dataset.index)
      }
    })
    this.updateList()
  }
  attributeChangedCallback () {
    this.updateList()
  }
}
Listing 4-12

TodoMVC List Web Component

Most of this code is very similar to the one in Chapter 3. One of the differences is that we use a custom event to tell the outer world what is happening when the user clicks the Destroy button. The only attribute that this component accepts as input is the list of todo items; every time the attribute changes, the list is rendered. As you saw earlier in this chapter, it’s very easy to attach a virtual DOM mechanism in here.

Let’s continue to the todomvc-app component code, shown in Listing 4-13.
import { EVENTS } from './List.js'
export default class App extends HTMLElement {
  constructor () {
    super()
    this.state = {
      todos: [],
      filter: 'All'
    }
    this.template = document
      .getElementById('todo-app')
  }
  deleteItem (index) {
    this.state.todos.splice(index, 1)
    this.syncAttributes()
  }
  addItem (text) {
    this.state.todos.push({
      text,
      completed: false
    })
    this.syncAttributes()
  }
  syncAttributes () {
    this.list.todos = this.state.todos
    this.footer.todos = this.state.todos
    this.footer.filter = this.state.filter
  }
  connectedCallback () {
    window.requestAnimationFrame(() => {
      const content = this.template
        .content
        .firstElementChild
        .cloneNode(true)
      this.appendChild(content)
      this
        .querySelector('.new-todo')
        .addEventListener('keypress', e => {
          if (e.key === 'Enter') {
            this.addItem(e.target.value)
            e.target.value = "
          }
        })
      this.footer = this
        .querySelector('todomvc-footer')
      this.list = this.querySelector('todomvc-list')
      this.list.addEventListener(
        EVENTS.DELETE_ITEM,
        e => {
          this.deleteItem(e.detail.index)
        }
      )
      this.syncAttributes()
    })
  }
}
Listing 4-13

TodoMVC Application Components

This component has no attributes; it has an internal state instead. Events from the DOM (standard or custom) change this state, and then the component syncs its state with the attributes of its children in the syncAttributes method. I talk more about which components should have an internal state in Chapter 7.

Web Components vs. Rendering Functions

Now that you have seen web components in action, let’s compare them to the rendering functions approach that we analyzed in Chapter 2 and Chapter 3. Next, I discuss some of the pros and cons of these two ways to render DOM elements.

Code Style

To create a web component means to extend an HTML element, so it requires you to work with classes. If you’re a functional programming enthusiast, you may feel uncomfortable working in this way. On the other hand, if you’re familiar with languages based on classes like Java or C#, you may feel more confident with web components than functions.

There is no real winner here; it’s really up to what you like most. As you saw in the last TodoMVC implementation, you can take your rendering functions and wrap them with web components over time so that you can adapt your design to your scenario. For example, you can start with simple rendering functions and then wrap them in a web component if you need to release them in a library.

Testability

To easily test rendering functions, you only need a test runner integrated with a JSDOM like Jest ( https://jestjs.io ). JSDOM is a mock DOM implementation used for Node.js that is extremely useful for testing rendering. The problem is that JSDOM doesn’t support (for now) custom elements. To test a custom element, you may need to use a real browser with a tool like Puppeteer ( https://developers.google.com/web/tools/puppeteer ), but your tests will be slower and likely more complicated.

Portability

Web components exist to be portable. The fact that they act exactly like any other DOM element is a killer feature if you need to use the same component between other applications.

Community

Component classes are a standard way to create DOM UI elements in most frameworks. This is a very useful thing to keep in mind if you have a large team or a team that needs to grow quickly. Your code is more readable if it is similar to other code that people are familiar with.

Disappearing Frameworks

A very interesting side effect of the emergence of web components is the birth of a bunch of tools that are called disappearing frameworks (or invisible frameworks). The basic idea is to write code like with any other UI framework, like React. When you create the production bundle, the output will be standard web components. In other words, during compile time, the framework will simply dissolve.

The two most popular disappearing frameworks are Svelte ( https://svelte.technology ) and Stencil.js ( https://stenciljs.com ). Stencil.js is based on TypeScript and JSX. At first, it seems a strange mix between Angular and React. I consider Stencil.js particularly interesting because it’s the tool that the team behind Ionic ( https://ionicframework.com ) built to create a new version of the famous mobile UIKit entirely based on web components. Listing 4-14 shows how to build a simple Stencil.js component.
import { Component, Prop } from '@stencil/core'
@Component({
  tag: 'hello-world'
})
export class HelloWorld {
  @Prop() name: string
  render() {
    return (
      <p>
        Hello {this.name}!
      </p>
    )
  }
}
Listing 4-14

A Simple Stencil.js Component

Once the code is compiled, you can use this component like any other custom element.
<hello-world name="Francesco"></hello-world>

Summary

In this chapter, you learned about the main APIs behind the web component standard and explored the Custom Elements API.

We built a new version of our TodoMVC application based on web components, and we evaluated the differences between this approach and rendering functions.

Finally, you learned about disappearing frameworks and saw how to create a very simple component with Stencil.js.

The next chapter focuses on building a frameworkless HTTP client to make asynchronous requests.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
3.145.158.106