If you don’t know anything about Web Components you should read this great introduction and this series, and skim through the docs at MDN: Custom Elements, Shadow DOM and HTML <template>. I will built the same functionality as a customized built-in element (<div is="recent-tracks" data-api-key="API_KEY" data-user="USER_NAME"></div>) and as an autonomous custom element (<recent-tracks data-api-key="API_KEY" data-user="USER_NAME"></recent-tracks>) and note the differences. The related JavaScript files are last-fm-web-component-customized.js and last-fm-web-component-autonomous.js, both heavily commented, and there is also a (basic) stylesheet for both elements: last-fm-web-component.css. Let’s see how the different parts fit together.

customized built-in element

<div is="recent-tracks-customized" data-api-key="API_KEY" data-user="USER_NAME"></div>

Here follows the code of the component without the comments, for brevity:

if ('customElements' in window) {
  customElements.define('recent-tracks-customized',
    class extends HTMLDivElement {
      constructor() {
        super();
      }

      connectedCallback() {
        this.apiKey = this.getAttribute('data-api-key');
        this.user = this.getAttribute('data-user');
        this.url = `http://ws.audioscrobbler.com/2.0/?method=user.getrecenttracks&limit=5&user=${this.user}&api_key=${this.apiKey}&format=json`;

        this.renderLatestTracks();
      }

      showSpinner() {
        this.classList.add('is_loading');
      }

      hideSpinner() {
        this.classList.remove('is_loading');
      }

      buildTracksList(tracks) {
        let tracksList = '';

        if (typeof tracks != 'string') {
          tracks.forEach((track) => {
            let className = (track['@attr']) ? 'is_playing' : '';

            tracksList += `<li class="${className}">${track.artist['#text']} — <a href="${track.url}" rel="external">${track.name}</a></li>`;
          });

          this.innerHTML = `<ul>${tracksList}</ul>`;
        }
      }

      async fetchLatestTracks() {
        this.showSpinner();

        try {
          let fetchResponse = await fetch(this.url);
          let latestTracks = await fetchResponse.json();

          if (latestTracks.recenttracks && latestTracks.recenttracks.track && latestTracks.recenttracks.track.length) {
            return latestTracks.recenttracks.track;
          } else {
            return 'ERROR';
          }
        } catch (error) {
          return error.message;
        }
      }

      async renderLatestTracks() {
        this.buildTracksList(await this.fetchLatestTracks());

        this.hideSpinner();
      }
    }, {
      extends: 'div'
    }
  );
}

You should register for an API key — don’t just copy-paste my key from the source! Be a good netizen! This API call does not require authentication, since this information is already public in my last.fm profile page. The API key is irrelevant to the provided last.fm user, so you can see what your friends and family listens. I use a single <LI> element with a not-so-random track as a fallback, in case JavaScript fails. It is a good practice, to provide some content until JavaScript downloads, executes and fetches results, not to mention that this is the only content that some users will see.

autonomous custom element

<recent-tracks data-api-key="API_KEY" data-user="USER_NAME"></recent-tracks>

And here is the source (without comments) of this version of the component:

if ('customElements' in window) {
  customElements.define('recent-tracks-autonomous',
    class extends HTMLElement {
      constructor() {
        super();
      }

      connectedCallback() {
        this.template = document.createElement('template');

        this.template.innerHTML = `
          <style>
            :host {
              display: block;
            }

            :host ul { /* tag added for extra specificity */
              margin: 0 0 1rem 0;
              padding: 4px;
              list-style: none;
              overflow: hidden;
              position: relative;
              color: black;
              background: white;
              border-radius: 6px;
              box-shadow: 0 0 8px rgba( 0, 0, 0, .2 );
            }

            :host li {
              padding: 4px;
              line-height: 1;
            }

            :host .is_playing {
              background: lightgoldenrodyellow;
            }

            :host .is_playing a { /* irrelevant to the web component, just overwrites my theme */
              text-shadow: 3px 0 lightgoldenrodyellow, 2px 0 lightgoldenrodyellow, 1px 0 lightgoldenrodyellow, -1px 0 lightgoldenrodyellow, -2px 0 lightgoldenrodyellow, -3px 0 lightgoldenrodyellow;
            }
          </style>

          <slot></slot>
        `;

        this.attachShadow({mode: 'open'});

        this.shadowRoot.appendChild(this.template.content.cloneNode(true));

        this.apiKey = this.getAttribute('data-api-key');
        this.user = this.getAttribute('data-user');
        this.url = `http://ws.audioscrobbler.com/2.0/?method=user.getrecenttracks&limit=5&user=${this.user}&api_key=${this.apiKey}&format=json`;

        this.renderLatestTracks();
      }

      showSpinner() {
        this.classList.add('is_loading');
      }

      hideSpinner() {
        this.classList.remove('is_loading');
      }

      buildTracksList(tracks) {
        let tracksList = '';

        if (typeof tracks != 'string') {
          tracks.forEach((track) => {
            let className = (track['@attr']) ? 'is_playing' : '';

            tracksList += `<li class="${className}">${track.artist['#text']} — <a href="${track.url}" rel="external">${track.name}</a></li>`;
          });

          this.shadowRoot.querySelector('slot').assignedElements()[0].innerHTML = tracksList;
        }
      }

      async fetchLatestTracks() {
        this.showSpinner();

        try {
          let fetchResponse = await fetch(this.url);
          let latestTracks = await fetchResponse.json();

          if (latestTracks.recenttracks && latestTracks.recenttracks.track && latestTracks.recenttracks.track.length) {
            return latestTracks.recenttracks.track;
          } else {
            return 'ERROR';
          }
        } catch (error) {
          return error.message;
        }
      }

      async renderLatestTracks() {
        this.buildTracksList(await this.fetchLatestTracks());

        this.hideSpinner();
      }
    }
  );
}

The resulting components are not very different, although this implementation uses Shadow DOM and the <slot></slot> element. The styling of the page does not cross the shadow border, so some styling must be done inline. However, the implementation logic is similar. Here follow some key points to remember:

  • You must write the custom tag first, then load the JavaScript of the web component! Otherwise you might find an empty <slot></slot> element in your code, in Chrome and Safari
  • Edge and Internet Explorer will happily use the “fallback” markup and ignore anything else — no harm done!
  • Customized built-in elements are not supported by Safari, Opera and iOS Safari, to name a few — see the current status at Can I use. There is also a polyfill that may or may not suit your situation (project, browser usage, etc.)
  • The support matrix for Shadow DOM that the autonomous implementation builds upon looks somewhat better than the customized built-in element, and with the above polyfill your users might be served fine
  • The safest way to style the Shadow DOM is with inline styles. Stylesheets can be @imported or <link>ed to — Caleb Williams has all the details
  • For the autonomous component to be encapsulated, the styling can be done with the :host pseudo-class, which is the Shadow DOM root element — the <recent-tracks-autonomous> in this case. However, the component and its contents will have the default styling until the JavaScript is loaded, parsed and executed, unless you copy at least some styles in a global stylesheet — but that defeats the purpose of the “autonomous” custom element

…and what about the “latest tracks” thing?

The logic is similar in both implementations, the only difference being how the data are rendered. A bunch of variables are attached to the component instance (this), namely apiKey, user and the url that uses those two. Then I call the renderLatestTracks. This is an async method, awaiting the results of the fetchLatestTracks method, before passing those results to the buildTracksList method to be rendered. Afterwards the loading “spinner” is removed.

Getting API data (fetchLatestTracks)

This is also an async method, that initially adds a class on the parent element to show a “spinner” while fetching the data from last.fm, and then fetches those using the url that was built earlier. The response stream of that promise is resolved as JSON. Afterwards, the method returns either an array of tracks or error messages.

Rendering the tracks list (buildTracksList)

This method iterates through the tracks array, or not, depending on the actual data that were passed to it, and the markup is built — a bunch of <LI>s with <A>s to the tracks’ page at last.fm. The final markup is rendered as innerHTML, either in the instance, wrapped in an <UL>, in the case of customized built-in element, or in the slotted <UL>, in the autonomous custom element case — this.shadowRoot.querySelector('slot').assignedElements()[0].innerHTML.

Fetch? Async/await? Response stream? It’s all Greek to me!

OK, some of those are relatively new to me too! So what?