Before you go, check out these stories!

0
Hackernoon logoThe Wonderful World of Web Components by@rijk

The Wonderful World of Web Components

Author profile picture

@rijkRijk van Zanten

Web components are a new set of browser APIs that allow you to create custom HTML elements with their own encapsulated styles and functionality.

These APIs enable developers to build webapps in a component based manner, versus the “default” document based approach of the web.

The whole “Web Components” package is made out of four sub-APIs which can be used separately from each other as well. The real magic happens when these four come together though.

HTML Templates

HTML templates allow you to declare fragments of markup which are parsed as HTML, go unused at page load, but can be instantiated later on at runtime.

They’re a place to put a big wad of HTML that you don’t want the browser to mess with at all… for any reason. 
Rafael Weinstein (HTML Templates specification author)

Creating a template is as simple as creating any other regular DOM element:

<template id="template-author">
<h2>Rijk</h2>
<img src="..." alt="The author of this article (Rijk)" /></template>

Key takeaways:

  • Template content is inert until activated
  • There are no side-effects until the template is used
  • The content is considered not to be in the DOM
  • Templates can be placed anywhere
  • There isn’t any form of native data-binding. So you’ll still need a library to handle that or write some business logic yourself to actually fill a template with useful content.

HTML Imports

We have had ways to import assets like CSS and JavaScript files into our HTML markup for a while now. With HTML imports, we now have a way to include HTML documents in other HTML documents. This isn’t limited to markup either. An import can also include it’s own CSS, JavaScript or anything else.

Importing HTML documents is very similar to including CSS files:

<link rel="import" href="/author.html" />

As soon as the imported file has been loaded, it’s embedded scripts will run and the contents can be used by referencing the import property of the element:

const content = document.querySelector('link[rel="import"]').import;

This API can be used very effectively when importing other custom elements, as will be demoed in a bit.

One major note on the browser support of HTML Imports: Chrome has implemented and shipped it a while ago now, but WebKit and Firefox aren’t going to implement it at all. WebKit provides that they want to investigate a combination with ES6 modules.

Key takeaways:

  • link rel=import de-duplicates all request automatically.
  • The imported document isn’t placed in the DOM whatsoever, just made available for use
  • Script tags inside imported documents will run on import
  • Import statements are blocking by default, but accept the async attribute
  • When accessing document from within an to-be-imported document, you are actually accessing the parent document. document.currentScript.ownerDocument is the reference to the "child"-document.
  • Deep-nested html imports will screw with document.currentScript.ownerDocument. Wrap nested scripts in an iife with document.currentScript.ownerDocument as param to prevent this.

Shadow DOM

From Google’s Shadow DOM Fundamentals:

Shadow DOM is just normal DOM with two differences:
1) how it’s created/used and
2) how it behaves in relation to the rest of the page.
Normally, you create DOM nodes and append them as children of another element. With shadow DOM, you create a scoped DOM tree that’s attached to the element, but separate from its actual children. This scoped subtree is called a shadow tree. The element it’s attached to is its shadow host. Anything you add in the shadows becomes local to the hosting element, including <style>. This is how shadow DOM achieves CSS style scoping.

You have probably used shadow DOM before, even when you didn’t realize it. The browser uses shadow DOM for a lot of different native HTML elements like a range input:

Shadow DOM of a standard <input type=”range”>

The shadow DOM API allows developers to create and attach their own shadow DOMs to elements, which goes great together with Custom Elements.

Key takeaways:

  • Everything inside a shadow-root is scoped.
  • Use :host selector to style parent container
  • position: fixed and other css positioning uses the component boundaries as "viewport"
  • Styles of elements inside shadow-root can be overwritten from the light-dom (“regular”/”parent”-dom).

Custom Elements

With custom elements, developers can create new HTML tags, extend existing HTML tags, or extend the components other developers have build. It provides a native way to create re-usable components.

Custom elements have to be defined in JavaScript. They have a couple of lifecycle hooks:

class ArticleAuthor extends HTMLElement {
  // When element is created or upgraded  
constructor() {...}
  // When element is inserted into a document 
connectedCallback() {...}
  // When element is removed from a document
disconnectedCallback() {...}
  // When an observed attribute changes
attributeChangedCallback() {...}
  // When the element is adopted into a new document
adoptedCallback() {...}
}
// Register <article-author>
customElements.define('article-author', ArticleAuthor);

Adding DOM to the element can be done by inserting markup into this.innerHTML :

...
connectedCallback() {
this.innerHTML = '<b>Hi there!</b>';
}
...

A nicer way of adding contents (imo) is combining the above mentioned HTML Templates and Shadow DOM (see demo below).

Key takeaways:

  • Names must contain a hyphen (-) (f.e. <article-author>)
  • Name has to be unique
  • Custom elements can’t be self-closing
  • According to the spec you can extend existing HTML elements (like HTMLButtonElement) instead of the default HTMLElement. This will inherit all default functionality of this element (like tabindex on buttons). Sadly this hasn’t been implemented anywhere as of this moment.
  • HTML Templates are an ideal way to markup the (Shadow) DOM of a custom element.
  • Always add getter and setter methods for any and every attribute that your component will use.

All together now

Let’s create a nasa-apod tag, which renders NASA’s Astronomy Picture of the Day (APOD) using their API.

I’ll inline all CSS and JS in this demo for clarities sake. The full demo can be found at https://github.com/rijkvanzanten/nasa-apod.

The goal is to create an element which supports a caption attribute like follows:

<nasa-apod caption="Supercool Web Components Demo"></nasa-apod>

Lets start by creating a nasa-apod.html file which will create and register the custom element.

<!-- nasa-apod.html -->
<script>
  class NasaApod extends HTMLElement {}
  customElements.define('nasa-apod', NasaApod);
</script>

Our element needs to display an image. To achieve that, we are going to add a regular <img> tag to the shadow DOM of our element.

First off, let’s start by attaching a shadow DOM to our element and adding a template for later use.

<!-- nasa-apod.html -->
<template>
<img src="" />
<p></p>
</template>
<script>
  class NasaApod extends HTMLElement {
constructor() {
super();
      this.attachShadow({mode: 'open'});
}

}
  customElements.define('nasa-apod', NasaApod);
</script>

To insert the template into the shadow DOM, we need to select it with document.querySelector or another selection method. However, because this element is imported into another document, document actually references the document that imports the current document. To get a reference to the ownerDocument , we need to wrap our random-img.html logic into a IIFE in which we provide document.currentScript.ownerDocument as parameter:

<!-- nasa-apod.html -->
<template>
<img src="" />
<p></p>
</template>
<script>
(function(ownerDocument) {
  class NasaApod extends HTMLElement {
constructor() {
super();
      this.attachShadow({mode: 'open'});
}
}
  customElements.define('nasa-apod', NasaApod);
}(document.currentScript.ownerDocument))
</script>

Now that we have a reference to the correct document, we can select the template and insert it into the shadow DOM.

<!-- nasa-apod.html -->
<template>
<img src="" />
<p></p>
</template>
<script>
(function(ownerDocument) {
  class NasaApod extends HTMLElement {
constructor() {
super();
      this.attachShadow({mode: 'open'});
      const {shadowRoot} = this;
const template = ownerDocument.querySelector('template');
const instance = template.content.cloneNode(true);
shadowRoot.append(instance);
}
}
  customElements.define('nasa-apod', NasaApod);
}(document.currentScript.ownerDocument))
</script>

Now that we have an element which can actually be rendered, let’s try it out by importing it into our main HTML document.

<!-- index.html -->
<!doctype html>
<html>
<head>
<meta charset="utf-8">

<!-- import and register the nasa-apod component -->
<link rel="import" href="nasa-apod.html" />
</head>
<body>
<nasa-apod caption="Supercool Web Components Demo"></nasa-apod>
</body>
</html>

If your browser supports all webcomponents specs (only Chrome as of this moment), you should be able to see this in the inspector:

It doesn’t do anything particular useful yet, but it works!

Let’s continue by getting our element to fetch an image from the API and display it in the shadow DOM.

<!-- nasa-apod.html -->
<template>
<img src="" />
<p></p>
</template>
<script>
(function(ownerDocument) {
  class NasaApod extends HTMLElement {
constructor() {
super();
      this.attachShadow({mode: 'open'});
      const {shadowRoot} = this;
const template = ownerDocument.querySelector('template');
const instance = template.content.cloneNode(true);
shadowRoot.append(instance);
}
    connectedCallback() {
const {shadowRoot} = this;

fetch('https://api.nasa.gov/planetary/apod?api_key=DEMO_KEY')
.then(res => res.json())
.then(res => {
const image = shadowRoot.querySelector('img');
image.src = res.hdurl;
})
.catch(err => console.error);

}
}
  customElements.define('nasa-apod', NasaApod);
}(document.currentScript.ownerDocument))
</script>

Well isn’t this just lovely?

The rendered picture in our <nasa-apod> element

Let’s add in support for the caption attribute.

First, we need to let the element know that it needs to observe the caption attribute and provide a getter and setter method for it. This allows the element to be updated in the main document by altering it’s attribute via either the inspector or JavaScript.

<!-- nasa-apod.html -->
<template>
<img src="" />
<p></p>
</template>
<script>
(function(ownerDocument) {
  class NasaApod extends HTMLElement {
constructor() {
super();
      this.attachShadow({mode: 'open'});
      const {shadowRoot} = this;
const template = ownerDocument.querySelector('template');
const instance = template.content.cloneNode(true);
shadowRoot.append(instance);
}
    static get observedAttributes() {
return ['caption'];
}
    get caption() {
return this.getAttribute('caption');
}
    set caption(val) {
this.setAttribute('caption', val);
}
    connectedCallback() {
const {shadowRoot} = this;
      fetch('https://api.nasa.gov/planetary/apod?api_key=DEMO_KEY')
.then(res => res.json())
.then(res => {
const image = shadowRoot.querySelector('img');
image.src = res.hdurl;
})
.catch(err => console.error);
}
}
  customElements.define('nasa-apod', NasaApod);
}(document.currentScript.ownerDocument))
</script>

Then we need to update the contents of the paragraph tag <p> in the shadow root dynamically.

<!-- nasa-apod.html -->
<template>
<img src="" />
<p></p>
</template>
<script>
(function(ownerDocument) {
  class NasaApod extends HTMLElement {
constructor() {
super();
      this.attachShadow({mode: 'open'});
      const {shadowRoot} = this;
const template = ownerDocument.querySelector('template');
const instance = template.content.cloneNode(true);
shadowRoot.append(instance);
}
    static get observedAttributes() {
return ['caption'];
}
    get caption() {
return this.getAttribute('caption');
}
    set caption(val) {
this.setAttribute('caption', val);
}
    connectedCallback() {
const {shadowRoot} = this;
      const caption = this.getAttribute('caption');
      if (caption) {
const paragraph = shadowRoot.querySelector('p');
paragraph.innerText = caption;
}
      fetch('https://api.nasa.gov/planetary/apod?api_key=DEMO_KEY')
.then(res => res.json())
.then(res => {
const image = shadowRoot.querySelector('img');
image.src = res.hdurl;
})
.catch(err => console.error);
}
    attributeChangedCallback(name, oldVal, newVal) {
if (name === 'caption') {
const {shadowRoot} = this;
const paragraph = shadowRoot.querySelector('p');
paragraph.innerText = newVal;
}
}

}
  customElements.define('nasa-apod', NasaApod);
}(document.currentScript.ownerDocument))
</script>

If you update the attribute in the element inspector in your browser, or update them dynamically with JavaScript by setting the attribute, the element updates in real-time!

Last but not least, add some scoped styling to our element and call it a day.

<!-- nasa-apod.html -->
<template>
<style>
:host {
display: flex;
flex-direction: column;
align-items: center;
padding: 15px;
border: 2px dashed grey;
width: max-content;
}
    img {
max-width: 500px;
max-height: 500px;
object-fit: cover;
}
    p {
text-align: center;
text-transform: uppercase;
color: grey;
}
</style>

<img src="" />
<p></p>
</template>
<script>
(function(ownerDocument) {
  class NasaApod extends HTMLElement {
constructor() {
super();
      this.attachShadow({mode: 'open'});
      const {shadowRoot} = this;
const template = ownerDocument.querySelector('template');
const instance = template.content.cloneNode(true);
shadowRoot.append(instance);
}
    static get observedAttributes() {
return ['caption'];
}
    get caption() {
return this.getAttribute('caption');
}
    set caption(val) {
this.setAttribute('caption', val);
}
    connectedCallback() {
const {shadowRoot} = this;
      const caption = this.getAttribute('caption');
      if (caption) {
const paragraph = shadowRoot.querySelector('p');
paragraph.innerText = caption;
}
      fetch('https://api.nasa.gov/planetary/apod?api_key=DEMO_KEY')
.then(res => res.json())
.then(res => {
const image = shadowRoot.querySelector('img');
image.src = res.hdurl;
})
.catch(err => console.error);
}
    attributeChangedCallback(name, oldVal, newVal) {
if (name === 'caption') {
const {shadowRoot} = this;
const paragraph = shadowRoot.querySelector('p');
paragraph.innerText = newVal;
}
}
}
  customElements.define('nasa-apod', NasaApod);
}(document.currentScript.ownerDocument))
</script>

Note: I’ve used NASA’s demo api-key for this example which has quite a low rate limit. Sign-up for your own (free) api_key at https://api.nasa.gov.

Tags

Join Hacker Noon

Create your free account to unlock your custom reading experience.