Αν δεν γνωρίζετε τίποτα για τα Web Components πρέπει να διαβάσετε αυτή την σπουδαία εισαγωγή και αυτή την σειρά άρθρων, και να περάσετε στα γρήγορα την τεκμηρίωση στο MDN: Custom Elements, Shadow DOM και HTML <template>. Θα φτιάξω την ίδια λειτουργία με ένα customized built-in element (<div is="recent-tracks" data-api-key="API_KEY" data-user="USER_NAME"></div>) και ένα autonomous custom element (<recent-tracks data-api-key="API_KEY" data-user="USER_NAME"></recent-tracks>) για να δω τις διαφορές. Τα σχετικά αρχεία JavaScript είναι last-fm-web-component-customized.js και last-fm-web-component-autonomous.js, επαρκώς σχολιασμένα, και υπάρχει ένα (βασικό) stylesheet και για τα δύο elements: last-fm-web-component.css. Ας δούμε πως ταιριάζουν τα κομμάτια μεταξύ τους…

customized built-in element

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

Ακολουθεί ο σχετικός κώδικας, χωρίς σχόλια, χάρην συντομίας:

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'
    }
  );
}

Θα πρέπει να γραφτείτε και να φτιάξετε ένα δικό σας API key — μην αντιγράψετε απλά το δικό μου! Be a good netizen! Το συγκεκριμένο API δεν απαιτεί authentication, αφού αυτές οι πληροφορίες είναι ήδη δημόσιες στην σελίδα του προφίλ μου στο last.fm. Το API key δεν σχετίζεται με τον χρήστη, οπότε μπορείτε να δείτε τί ακούν οι φίλοι και η οικογένειά σας. Χρησιμοποιώ ένα και μοναδικό <LI> element με ένα όχι-εντελώς-τυχαίο τραγούδι σαν “fallback”, σε περίπτωση που υπάρχει οποιοδήποτε πρόβλημα με την JavaScript. Είναι μια καλή πρακτική, το να παρέχουμε κάποιο περιεχόμενο μέχρι να φορτώσουν τα scripts, να εκτελεστούν και να έρθουν τα αποτελέσματα, ενώ φυσικά υπάρχουν χρήστες που θα δουν μόνο το αρχικό περιεχόμενο.

autonomous custom element

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

Ορίστε και ο κώδικας (χωρίς σχόλια) αυτής της εκδοχής του 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();
      }
    }
  );
}

Τα τελικά components δεν έχουν μεγάλες διαφορές, παρόλο που η συγκεκριμένη εκδοχή χρησιμοποιεί Shadow DOM και το στοιχείο <slot></slot>. Το γενικότερο στυλ της σελίδας δεν περνάει το shadow border, οπότε το styling πρέπει να γίνει “inline”. Ωστόσο, η λογική είναι παρόμοια. Μερικά σημεία που πρέπει να θυμάστε:

  • Πρέπει να γράψετε πρώτα το custom στοιχείο, και έπειτα να φορτώσετε την JavaScript του web component! Αλλιώς, είναι πιθανό να βρείτε κενά στοιχεία <slot></slot>, σε Chrome και Safari
  • Ο Edge και ο Internet Explorer θα χρησιμοποιήσουν το “fallback” και θα αγνοήσουν όλα τα υπόλοιπα χωρίς περαιτέρω προβλήματα!
  • Τα customized built-in elements δεν υποστηρίζονται από Safari, Opera και iOS Safari, για να αναφέρω μερικούς clients — ορίστε ο σχετικός πίνακας στο Can I use. Υπάρχει βέβαια ένα “polyfill” που ίσως να κάνει τη δουλειά που έχετε, με βάση τους χρήστες, τα specs του έργου, κλπ.
  • Ο πίνακας υποστήριξης του Shadow DOM που χρησιμοποιεί η autonomous υλοποίηση φαίνεται κάπως καλύτερος από του customized built-in element, και με το παραπάνω polyfill οι χρήστες σας μπορεί να βολευτούν μια χαρά
  • Ο ασφαλέστερος τρόπος να “ντύσεις” με CSS το Shadow DOM είναι με inline styles. Τα stylesheets μπορούν επίσης να φορτωθούν με @import ή <link> — ο Caleb Williams έχει ήδη γράψει σχετικά
  • Για να είναι encapsulated το autonomous component, το styling πρέπει να γίνει με το :host pseudo-class, που είναι το “root” στοιχείο του Shadow DOM — το <recent-tracks-autonomous> στη συγκεκριμένη περίπτωση. Ωστόσο, το ίδιο το component και τα περιεχόμενά του θα έχουν τα εξ ορισμού στυλ του browser μέχρι να φορτώσει και να εκτελεστεί ο κώδικας JavaScript, εκτός αν αντιγράψετε τουλάχιστον κάποια CSS rules στο βασικό σας stylesheet — αλλά αυτό μάλλον ακυρώνει κάπως το “encapsulation” του “autonomous” custom element

…και πως παίρνουμε τα “latest tracks”?

Η λογική είναι λίγο-πολύ ίδια και στις δύο υλοποιήσεις, εκτός από το πως προκύπτει το τελικό markup της λίστας. Ορίζω λίγες μεταβλητές στο component instance (this), τις apiKey, user και url, που αξιοποιεί τις δύο πρώτες. Έπειτα καλώ την μέθοδο renderLatestTracks. Είναι μια async function, περιμένει (await) τα αποτελέσματα της fetchLatestTracks, για να τα περάσει στην buildTracksList και να βγουν στην οθόνη. Έπειτα κρύβω το “spinner”.

Παίρνοντας τα data του API (fetchLatestTracks)

Αυτή είναι επίσης μια async function, που αρχικά προσθέτει μια κλάση στο parent element για να εμφανιστεί ένα “spinner” για όσο κατεβαίνουν τα δεδομένα από το last.fm, και μετά ζητάει τα data με το fetch, από το url που έφτιαξα νωρίτερα. To response stream αυτού του promise γίνεται resolve σαν JSON. Τέλος, επιστρέφω είτε έναν πίνακα με καλλιτέχνες και μουσικά κομμάτια, είτε κάποιο μήνυμα λάθους.

Προβάλοντας την λίστα των κομματιών (buildTracksList)

Αυτή η function περνάει ένα-ένα τα στοιχεία του πίνακα tracks (ή όχι, εξαρτάται από τα data που περάστηκαν στην μεταβλητή tracks) και χτίζει το markup — μια σειρά από <LI>s με <A>s προς την σελίδα του τραγουδιού στο last.fm. Το τελικό markup μπαίνει στο innerHTML, είτε στο ίδιο το instance του component, μέσα σε <UL> στην περίπτωση του customized built-in element, είτε στο <UL> που είναι slotted στην περίπτωση του autonomous custom element — this.shadowRoot.querySelector('slot').assignedElements()[0].innerHTML.

Fetch? Async/await? Response stream? Δεν βγάζω άκρη!

Εντάξει, μην σκας, κι εγώ δεν τα έχω μάθει καλά-καλά!

  • Η καλύτερη εξήγηση του async/await είναι στο javascript.info. Βασικά, όλο το κεφάλαιο για τα Promises είναι εξαιρετικό
  • Μπορείτε να διαβάσετε για το Fetch API στο MDN — η μέθοδος json είναι μέρος αυτού του API
  • Αρκετοί browsers δεν υποστηρίζουν το Fetch API, αλλά υπάρχει ένα polyfill, ενώ μπορεί επίσης να χρησιμοποιηθεί το παλαιότερο XMLHttpRequest