19 mei 2021 #Frontend

Door Wessel Loth access_time20 min

Web Components 101 – Part 3: Show me the code!

Web components. Een term die steeds vaker terugkomt. Maar net als veel andere tech-hypes lijkt de belofte van web components nog niet waar gemaakt te zijn. Of toch wel? Web components zijn al lang niet meer zo obscuur als een paar jaar geleden. Alleen in Nederland al gebruiken een aantal grote organisaties ze al meerdere jaren in productie. Steeds meer teams herkennen de meerwaarde van de onafhankelijkheid die webstandaarden met zich meebrengen.

Echter vind ik dat ze nog niet altijd in even goed daglicht staan! Daarom neem ik je de komende weken mee in het gedachtegoed, de basics, het ecosysteem, en laat ik zien hoe je een volwaardige frontend applicatie kan bouwen met web components.

Dit is het derde deel van deze blogpost serie over Web Components. Heb je deel twee nog niet gelezen? Klik hier om hem alsnog te bekijken!

 

Web Components 101 – Part 3: Show me the code!

In deel twee heb ik laten zien wat web component libraries je kunnen bieden, en waarom je ze zou willen gebruiken. Aan de hand van een simpele feature kon je het verschil in werken en syntax tussen deze libraries naast elkaar zien. Maar in de echte wereld werk je niet aan simpele features: frontends worden namelijk steeds complexer. Daarom staat deze week in het teken van Show me the Code!

Ik ga allerlei veelvoorkomende voorbeelden laten zien, en ik laat zien hoe je deze met Lit kan bouwen. De keuze voor Lit is voor mij op dit moment de meest logische: het blijft dicht bij de webstandaarden en er zijn aantoonbaar relatief veel bedrijven die het gebruiken. Ook is er veel goede documentatie te vinden, een actieve Twitter-community en de library zelf is makkelijk te begrijpen.

Goed om te weten: ik heb alle voorbeelden gebouwd met TypeScript, omdat ik daar groots fan van ben. Ook heeft TypeScript out-of-the-box ondersteuning voor Decorators, waar Lit er ook standaard een aantal van heeft. Geen fan van TypeScript? Helemaal geen probleem. Wat in één regel met een decorator kan, kan vaak in twee of drie regels vanilla JavaScript. Decorators zijn overigens een feature die op termijn ook naar JavaScript komen, waardoor je voor al mijn voorbeelden geen TypeScript meer hebt.

Hello world!

Laten we eerst maar even een babystapje nemen. Om het geheugen nog even te verfrissen, heb ik een hele simpele “hello world” component gemaakt:

import { LitElement, html } from 'lit';
import { customElement, property } from 'lit/decorators.js';

@customElement('arcady-helloworld')
export class HelloWorld extends LitElement {
  @property({ type: String }) message = 'Hello World!';

  render() { 
    return html`
      <main>
        <h1>${this.message}</h1>
      </main>
    `;
  }
}

Veel doet dit component niet. Het bericht “Hello World!” wordt als property gedefinieerd, en in de render-functie wordt deze property in een stukje HTML gezet. Erg spannend is het resultaat dan ook niet:

Je kan zien dat Lit een paar markers heeft gezet in de DOM om later de juiste plek terug te vinden om dynamische content te updaten.

 

Data Binding

Oké, dat voorbeeld was niet zo heel interessant. Daarom gaan we snel door, naar iets wat je als frontend developer elke dag wel doet: data rondsturen met data binding. In dit voorbeeld breid ik uit op de “Hello World” van net. Het bericht staat gedefinieerd in het parent-component, en deze wordt meegegeven aan een child-component die het bericht oppakt en toont in de pagina. Als eerste, de child:

@customElement('data-binding-child')
export class DataBindingChild extends LitElement {
  @property({ type: String }) message = '';

  static styles = css`
    :host {
      display: block;
      border: 2px solid green;
    }
  `;

  render() {
    return html`
      <h3>This is the child</h3>
      <p>This is the message via data binding: "${this.message}"</p>
    `;
  }
}

 

Je ziet dat er hier gebruik wordt gemaakt van de @property(…) decorator. Deze decorator zorgt er voor dat andere componenten van buitenaf data kunnen meegeven aan dit component. En het mooie is: zodra Lit ziet dat een property een nieuwe waarde heeft, zal de render-functie opnieuw worden aangeroepen. Zo blijft Lit efficiënt werken, en worden er geen onnodige render acties uitgevoerd.

Naast @property(…) heb je ook nog @state(…). Dat is eigenlijk hetzelfde, maar dan voor intern gebruik. Zo heb je wel eens data die alleen binnen een enkel component relevant is, maar wél moet zorgen voor een re-render wanneer er een wijziging plaatsvind.

Goed om te weten: je ziet dat de type van de property in het voorbeeld is ingesteld op String: dat is géén feature van TypeScript, maar van Lit zelf. Je kan hier aangeven wat voor type je verwacht als waarde, zodat Lit zelf de waarde voor je kan omzetten indien nodig. Daar kan je in de docs veel meer informatie over lezen.

Het definieren van een property is simpel, maar hoe kan je nou deze property van buitenaf aanpassen? Dat kan zo!

@customElement('arcady-databinding')
export class DataBindingParent extends LitElement {
  @property({ type: String }) message = 'Data binding test!';

  static styles = css`
    :host {
      border: 2px solid red;
    }
  `;

  render() {
    return html`
      <h2>This is the parent</h2>
      <data-binding-child .message=${this.message}></data-binding-child>
    `;
  }
}

Je ziet in de render-functie dat je met .message=${this.message} een waarde mee kan geven van buitenaf. Nog even ter beeldvorming, het resultaat van deze twee componenten:

Dit was natuurlijk een simpel voorbeeld van data binding, maar je nog veel meer dingen doen, zoals:

  • Een attribuut een (string) waarde geven: class=”default ${dynamicClass}”
  • Een attribuut zélf wel of niet renderen: ?hidden=${showComponent === false}
  • Directives gebruiken om krachtigere expressies te schrijven, bijvoorbeeld om het hele attribuut niet te renderen als er geen image path bestaat: <img src=”/images/${ifDefined(imagePath}”>
  • …en Event Listeners declaratief beschrijven! Maar dat ga ik je nu in detail laten zien.

Events 101

Interactie met andere componenten gaat uiteraard niet alleen maar via data binding. Soms wil je ook informatie rondsturen zonder dat dat top-down gaat. Bijvoorbeeld wanneer er op een knop is gedrukt. Of wanneer iemand iets intypt in een input-veld.

Lit werkt niet met een maatwerk events systeem. Angular heeft bijvoorbeeld een schil gebouwd om naast de eigen EventEmitters ook op een Angular-manier om te gaan met native events, zoals een onclick of onsubmit. Lit maakt echter gebruik van browserstandaarden, door middel van DOM Events.

DOM Events zijn in tegenstelling tot de naam een hele slimme manier van data rondsturen op de webpagina. Het maakt gebruik van de DOM, de Document Object Model: een boomstructuur die de structuur van de pagina beschrijft. Events worden verzonden vanaf een bepaalde node in deze boomstructuur, en worden via Event Bubbling in de boom omhoog gestuurd. Dit is een erg beknopte manier van het beschrijven van DOM Events, omdat het nog véél meer kan. Als je je hier in wilt verdiepen, dan kan ik je deze website aanraden. Maar voor nu gaan we even uit van communicatie tussen parents en children.

Ik heb een klein componentje gemaakt die luistert naar wat voor tekst er wordt getypt in een input-element. Dit component vangt de waarde op, en geeft het mee aan een child-component.

Eerst het child-component dan, die doet namelijk niet zo veel:

@customElement('events-child')
export class EventsChild extends LitElement {
  @property({ type: String }) message = '';

  render() {
    return html` <p>Live typing value: "${this.message}"</p> `;
  }
}

 

Er kan een message mee worden gegeven, die daarna in een paragraaf wordt gezet. De parent daarintegen, die doet wat meer:

@customElement('arcady-events')
export class EventsParent extends LitElement {
  @state() inputvalue = '';

  onKeyup(e: Event) {
    const inputEl = e.target as HTMLInputElement;
    this.inputvalue = inputEl.value;
  }

  render() {
    return html`
      <label for="input">Enter a value:</label>
      <input name="input" type="text" @keyup=${this.onKeyup} />

      <events-child .message=${this.inputvalue}></events-child>
    `;
  }
}

 

In de render-functie zie je hoe Lit omgaat met het opvangen van events. De syntax hiervoor is @eventNaam=${functieVerwijzing}. Lit zorgt er dan zelf voor dat er onder de motorkap een event listener wordt aangemaakt die luistert naar het event. Ook zorgt Lit er voor dat wanneer dit component uit de pagina wordt gehaald, dat alle event listeners worden verwijderd, zodat er geen losse verwijzingen blijven rondzweven.

Wanneer het event wordt opgevangen in de onKeyup(…) functie, kan je de gegevens simpelweg uitlezen via e.target.value. Wanneer deze waarde dan weer in een property of state wordt gezet, zal de render-functie opnieuw worden afgetrapt, zodat de nieuwe waarde aan het child-component wordt meegegeven.

Events 201

Omgaan met native DOM Events als een keyup of onclick is natuurlijk maar een klein deel waarvoor je events wilt gebruiken. Het andere deel is het definiëren van eigen events, die precies de gegevens bevatten die je zelf wilt meesturen. Daarvoor hebben we nog zo’n mooie browserstandaard: Custom Events.

Het voorbeeldje van net heb ik ietsje uitgebreid. Nu heeft het child-component ook twee knoppen. Wanneer je op de eerste knopt drukt zal het component een event sturen naar de parent om aan te geven dat de tekst moet worden gereset. De tweede knop zal een ander event sturen om aan te geven dat de parent een specifieke tekst moet overnemen.

De code van het child-component heb ik aangepast naar het volgende:

…
render() {
  return html`
    <p>Live typing value: "${this.message}"</p>
    <button @click=${this.fireClearTextEvent}>Clear text in parent</button>
    <button @click=${this.fireSetTextEvent}>Set text to "Arcady"</button>
  `;
}

fireClearTextEvent() {
  const event = new CustomEvent('clear-text');
  this.dispatchEvent(event);
}

fireSetTextEvent() {
  const event = new CustomEvent('set-text', { detail: 'Arcady' });
  this.dispatchEvent(event);
}
…

 

Je ziet dat ik twee knoppen heb toegevoegd, met twee unieke click-handlers. De eerste handler maakt een nieuwe CustomEvent aan, puur met een unieke nama. De tweede handler maakt gebruik van de extra mogelijkheden van een CustomEvent: het meesturen van extra details. Een detail kan vanalles zijn, een string, number, boolean of zelfs een array of object. In dit geval willen we alleen een nieuwe waarde meegeven: “Arcady”.

Je kan nog véél meer configureren en uitlezen uit een CustomEvent: bijvoorbeeld of het event moet bubblen, of je het event moet kunnen annuleren maar ook of het event shadow boundaries heeft gepasseerd.

Om deze nieuwe events op te vangen kan je dezelfde syntax gebruiken als bij standaard events, bijvoorbeeld zo in de parent-component:

...
render() {
  return html`
    <label for="input">Enter a value:</label>
    <input name="input" type="text" @keyup=${this.onKeyup} />

    <events-child2
      .message=${this.inputvalue}
      @clear-text=${this.onClearText}
      @set-text=${this.onSetText}
    ></events-child2>
  `;
}

onClearText() {
  const el = this.shadowRoot?.querySelector('input');
  if (el) {
    el.value = '';
    this.inputvalue = '';
  }
}

onSetText(event: CustomEvent) {
  const el = this.shadowRoot?.querySelector('input');
  if (el) {
    el.value = event.detail;
    this.inputvalue = event.detail;
  }
}
…

De @clear-text en @set-text roepen twee unieke functies aan, waarbij de eerste het event verder niet gebruikt, en de input-waarde leegmaakt. De onSetText(…) functie gebruikt het CustomEvent object wél en kan via event.detail de juiste waarde uitlezen.

Custom Events zijn dus een uitbreiding op de standaard DOM Events, die op dezelfde manier werken en dezelfde regels volgen. Het werken er mee is dan ook zo simpel als dat je gewend bent van “ouderwets JavaScript van vóór de frameworks”. Zo kan je gewoon gebruik maken van addEventListener om buiten een web component klasse oók naar (custom) events te luisteren, bijvoorbeeld in een service die altijd draait of een logging-tool die bepaalde events opvangt en logt.

Forms & API’s

In de basis zijn 95% van alle frontend-applicaties eigenlijk een veredeld formulier. De gebruiker komt op een pagina om een actie te voltooien, en moet daarvoor een (aantal) formulieren invullen om tot een resultaat te komen. Input->Output dus. Natuurlijk kon ik deze blogpost dan ook niet live zetten zonder een voorbeeld over formulieren.

Het beschrijven van dit formulier is supersimpel:

render() {
  return html`
    <form name="signupForm" @submit=${this.onSubmit}>
      <label for="email">Email address:</label>
      <input name="email" type="email" />

      <label for="age">Age:</label>
      <input name="age" type="number" />

      <label for="password">Password:</label>
      <input name="password" type="password" />

      <button type="submit">Send</button>
    </form>
  `;
}

 

Je ziet een aantal velden met verschillende types: een e-mailadres, een nummer en een wachtwoord. Om ze te beschrijven gebruik ik geen fancy tooltjes of componenten, het zijn standaard input-element die binnen een form-element zitten. Wat wél anders is, is dat er een event wordt opgevangen: het submit-event. Die handler ziet er zo uit:

onSubmit(e: InputEvent) {
  // Prevent the default behavior of POSTing the form data
  e.preventDefault();

  // Get the form data as FormData and turn it into an Object
  const rawFormData = new FormData(e.target as HTMLFormElement);
  const formData = Object.fromEntries(rawFormData.entries());

  // POST the form body somewhere
  fetch('https://api.mywebsite.com/newuser', {
    method: 'POST',
    body: JSON.stringify(formData),
  }).then(console.log);
}

 

Het standaardgedrag van een submit­-actie op een formulier is dat er een POST-request wordt afgevuurd met de ingevulde gegevens. Dat is handig, maar zorgt er ook voor dat de pagina wordt ververst. Daarom wordt er meteen e.preventDefault() aangeroepen, om dat standaardgedrag te annuleren. Daarna kan je simpelweg met behulp van de FormData-klasse het formulier omzetten naar een JSON Object. Geen handmatig gedoe met de querySelector dus.

Oh ja, dit hoofdstuk ging ook over API-interactie hè. Daar kan ik heel kort over zijn: je kan gebruik maken van de ingebouwde browserstandaarden: Fetch of XMLHttpRequest. Fetch is een hele complete tool om te interacteren met je backend, en bevat waarschijnlijk alles wat je nodig hebt. Wil je toch meer functionaliteit zoals Interceptors en Cancellation? Dan kan je gebruik maken van Axios of Ky, of zelf een wrappertje schrijven om Fetch heen.

Tot slot: het voorbeeld hierboven stuurt het volgende naar de API, waarmee dit client-side formulier compleet is:

Werken met formulieren vind ik persoonlijk nog wel een uitdaging met web components. Dat komt omdat ik zelf ook een groots Angular fan ben, waar je out-of-the-box enorm krachtige oplossingen hebt om vanuit code een duidelijke structuur van je formulier te beschrijven, waar de type-definities en validaties aan vastgekoppeld zitten.

Wanneer je werkt met web components (dus niet alleen met Lit) zal je merken dat je veelal vast zit aan de browserstandaarden omtrent werken met formulieren. Dat betekent dat het simpel is om waardes uit te lezen, maar dat het niet altijd even makkelijk is om client-side validatie toe te voegen. Eigenlijk heb je maar een paar ingebouwde validatie functies, om basisvalidaties te doen zoals required, min, max en regex validatie. Alles wat je extra wilt komt neer op extra JavaScript-code.

Gelukkig zijn er wel bestaande oplossingen, zo hebben verschillende Component Libraries functionaliteit om uit te breiden op standaard formulieren. Natuurlijk brengen deze wél weer een extra afhankelijkheid met zich mee. Daarom zou ik eerst kijken naar wat voor soort formulieren je hebt, en wat voor features je nodig hebt om uit te breiden op de basis. Vaak kan je namelijk met een klein stukje generieke JavaScript al simpele validaties toepassen en de velden markeren als invalid, waarna je met de CSS invalid-pseudoklasse dan weer duidelijk de status kan aangeven aan de gebruiker.

Styling

Zonder mooie styles is je applicatie natuurlijk niet Lit 🔥 dus ik ga je nu laten zien hoe je CSS kan gebruiken in combinatie met Lit. Lit gebruikt geen preprocessor als SASS, SCSS of Stylus. In plaats daarvan gebruik je gewoon kale CSS. Nu krijg je misschien al flashbacks naar CSS uit den ouden tijd: veel duplicatie, geen structuur en geen slimmigheid. Maar wat je wellicht niet mee hebt gekregen, is dat CSS ook mee is gegaan met de tijd. Variabelen, lazy-rendering/lazy-loading, feature detection en nog veel meer zit standaard ingebakken in de browser.

Als je Lit gebruikt schrijf je je CSS ook in JavaScript bestanden, maar dan met de CSS template tag. Deze tag-functie zorgt er voor dat deze efficiënt worden omgezet in échte CSS, en dat gedupliceerde styles uit een cache worden gehaald, met behulp van Constructable Stylesheets. Omdat je CSS in JavaScript bestanden staat, kan je de kracht van JavaScript ES Module Imports gebruiken. Zo wordt het kinderspel om een CSS library op te zetten, kijk maar:

// colors.ts
import { css } from 'lit';

export const primary = css`cornflowerblue`;
export const secondary = css`tomato`;

// spacing.ts
export const spacing4 = css`4px`;
export const spacing8 = css`8px`;
export const spacing16 = css`16px`;
export const spacing24 = css`24px`;

​Na de vastlegging van dit soort basis-styles kan je hier op uitbreiden:

import { css } from 'lit';
import { primary, secondary } from './colors';

export const fontFamily = css`'Comic Sans MS', serif`;

export const fontSizeH1 = css`40px`;

/**
 * Sets all the right styles for a page header, with the
 * right font, size, color and border.
 */
export const fontHeadingMixin = () => css`
  font-family: ${fontFamily};
  font-size: ${fontSizeH1};
  color: ${primary};
  border-bottom: 5px dotted ${secondary};
`;

Wil je je basis styles gebruiken in je component? Just import them and go!

import { fontHeadingMixin } from './fonts';
import { spacing24 } from './spacing';

@customElement('arcady-styling')
export class FormsComponent extends LitElement {
  static styles = css`
    h1 {
      ${fontHeadingMixin()};
      margin: ${spacing24} 0;
    }
  `;

  render() {
    return html`
      <h1>This is a <i>very</i> nice header!</h1>
      <p>And this is a complementary paragraph explaining more.</p>
    `;
  }
}

Het resultaat? Een meesterwerk.

In de (Chrome) dev tools zie je ook terug dat de bron van deze styles uit een constructed stylesheet komen, in tegenstelling tot inline of een CSS-bestand.

Misschien was het je opgevallen dat ik de h1 niet heb gestyled met een klasse, ID of andere specifieke selector. In plaats daarvan heb ik de h1 simpelweg geselecteerd door het element direct aan te spreken. Dat is de kracht en simpliciteit van stylen binnen de Shadow DOM: nul risico op dat deze hele globaal-omschreven selector andere elementen op de pagina zal raken.

Voorbij zijn de dagen waar je selectors als .page > .page_header > .header > .header—primary { … } schrijft. De meest gebruikte feature van een preprocessor als SCSS is overbodig op het moment dat je simpele selectors kan gaan schrijven. En variabelen en mixins? Kan je ook allemaal zonder preprocessor. Naast de variabelen die je hier boven hebt gezien, heb je ook CSS Custom Properties. Hiermee kunnen componenten de styles van children aanpassen op een veilige manier zonder hacks. Ideaal voor het definiëren van thema’s of een dark mode voor jouw website.

Slots

In deel een van deze blogpost serie heb ik al kort het <slot> element laten zien, en wat je er mee kan. Om te laten zien hoe dat er met Lit uit ziet, heb ik hetzelfde voorbeeld van een card-element nagemaakt, met wat extra styling.

De render-functie van dit component ziet er zo uit:

…
render() {
  return html`
    <slot name="title"></slot>
    <slot name="content"></slot>
    <!-- Any additional content will be rendered in here -->
    <slot></slot>
  `;
}

 Er is een voor gedefinieerde slot voor de titel en inhoud van de card, met een extra wildcard-slot voor overige content. Je kan ‘m zo gebruiken:

<arcady-card>
  <h1 slot="title">Clickbaity title</h1>
  <p slot="content">Lorem ipsum dolor sit amet...</p>
</arcady-card>

<arcady-card>
  <h1 slot="title">World says "hello" back</h1>
  <p slot="content">Lorem ipsum dolor sit amet...</p>
  <a href="...">Click here to learn more</a>
</arcady-card>

 

Zo blijft je markup buiten het component overzichtelijk, maar kan het component er een extra sausje over aanbrengen. In het card-component kan je namelijk met de slotted-selector styles aanbrengen op content die via slots binnenkomt.

:host {
  display: block;
  background-color: white;
  padding: 15px;
  border-radius: 15px;
  box-shadow: 3px 3px 10px 0px rgba(0, 0, 0, 0.4);
}

::slotted(h1) {
  margin: 15px 0 0 0;
  font-family: cursive;
}

::slotted(a) {
  display: block;
  padding-top: 15px;
  margin-top: 15px;
  border-top: 1px solid #e4e4e4;
  text-decoration: none;
  text-transform: uppercase;
  font-weight: bold;
  color: crimson;
}
::slotted(a)::after {
  margin-left: 10px;
  content: '>';
}

 

Je ziet hier verschillende selectors die je mogelijk nog niet eerder hebt gezien. Als eerste de :host-selector, waarmee je de node kan stylen waarop dit component wordt aangemaakt: <arcady-card>. Het kan wel eens handig zijn om een display: block aan te geven zodat parent-componenten weten dat dit een element is die op een bepaalde manier moet worden geplaatst.

Met de ::slotted(*)-selector kan je verschillende elementen selecteren om ze extra te stylen. Zo zie je hierboven een regel dat als er een h1 binnenkomt, dat die een bepaald lettertype moet krijgen. En ondanks dat er geen standaard slot is voor een hyperlink in dit component, kan je hierop anticiperen door anchor-elementen alvast standaard styling mee te geven. Zó kom je dus uit op dit mooie plaatje.

Je kan nog veel meer met slots, bijvoorbeeld fallback waardes instellen, of ze binnen het component te manipuleren met JavaScript. Zo kan je hele krachtige componenten maken, die van buitenaf simpel te gebruiken zijn.

Routing

Als laatste voorbeeld wil ik nog een veelvoorkomende functionaliteit laten zien: de client-side router. Er is geen browserstandaard als het gaat om een router in de frontend, maar er zijn wel een aantal elementen die je kan combineren om hetzelfde te bereiken.

Tijdens het uitwerken van deze blogpost heb ik mijn eigen simpele router gemaakt. Die kan op basis van een key een bepaalde component lazy-loaded inladen, en daarna renderen. Dat lazy-loaden kan tegenwoordig heel makkelijk zonder allerlei complexe build-configuratie. Het import-statement wordt in moderne browsers standaard ondersteund, en daarmee kan je zonder enige build-tooling synchroon én asynchroon andere JavaScript bestanden inladen.

Voor deze voorbeeld applicatie heb ik een eigen RouterMixin geschreven. Mixins zijn een ontwikkelpatroon om een klasse of object uit te breiden met nieuwe functionaliteit, zonder daarvoor overerving toe te passen. Je kan zo meerdere mixins toepassen op een klasse, om hem zo telkens ietsje uit te breiden. Overigens: of je dit een goede design pattern vind mag je zelf beslissen. Aan de ene kant kan je er een Diamond of Death mee veroorzaken, en aan de andere kant geeft het je enorm veel flexibiliteit om in een prototype-gebaseerde programmeertaal als JavaScript je klasse uit te breiden. In ieder geval ziet de implementatie van mijn RouterMixin er zo uit:

@customElement('arcady-app')
export class ArcadyApp extends RouterMixin(LitElement) {
  // Route definitions
  protected routes: Route[] = [
    {
      key: 'arcady-helloworld',
      name: 'Hello World!',
      import: () => import('./1-HelloWorld/HelloWorld.js'),
      render: () => html`<arcady-helloworld></arcady-helloworld>`,
    },
    {
      key: 'arcady-databinding',
      name: 'Data Binding',
      import: () => import('./2-DataBinding/DataBinding.js'),
      render: () => html`<arcady-databinding></arcady-databinding>`,
    },
    ...
  ];

  render() {
    return html`
      <nav>
        ${this.routes.map((route: Route) => html`
            <button @click=${() => this.navigateTo(route.key)}>
                ${route.name}
            </button>`)}
      </nav>

      ${this.renderRoute()}
    `;
  }
}

​​Bovenin het component zie je een definitie van de verschillende routes. Ik heb er voor gekozen om er een unieke key aan te hangen (noem het maar de URL), een leesbare naam, een asynchrone import-statement en een render-functie. Zo zit er in elk object alles wat er nodig is om een route te kunnen tonen.

De functies navigateTo(…) en renderRoute(…) komen uit de mixin. Die zorgt er voor dat er de juiste route dynamisch wordt ingeladen en daarna getoond. Nu zou je denken: het lazy-loaden van een JavaScript bestand (en de daarbij horende afhankelijkheden) klinkt nog al complex, dan zal dat ook wel moeilijk zijn om te bouwen!

Gelukkig niet! Omdat ES Module Imports een erg volwassen en krachtige browserstandaard zijn, hoef je alleen maar de import(‘file.js’) functie aan te roepen:

/**
 * Call with a route key to navigate to that route, and lazy-load
 * the module if it hasn't been loaded yet.
 *
 * In a real scenario, this function should be extended to support
 * 404-redirection, URL-scheme matching & storing the activated
 * route in the URL (and history).
 */
async navigateTo(key: string) {
  const route = this.routes.find((f) => f.key === key);
  if (route) {
    await route.import(); // Loads the ES Module asynchronously!
    this.activeRoute = route;
  }
}

/**
 * Call in the render() function of the implementation
 * to render the currently activated route.
 */
renderRoute(): TemplateResult {
  return this.activeRoute?.render();
}

In de praktijk ziet dat er zo uit:

En het wordt nóg beter. Gebruik jij een build tool of bundler die ES Module Imports begrijpt, zoals Rollup, Snowpack of ESBuild? Dan zal je zien dat die begrijpt welke bestanden er wél en niet lazy-loaded worden ingeladen, waardoor de juiste bestanden worden gebundeld. Zo zie je hier onder dat er per route maar één JavaScript bestand wordt ingeladen, waar de lokale omgeving elke import-statement als een los JavaScript bestand beschouwt.

Voor dit voorbeeld heb ik het gehouden bij een simpele key<->component router, maar dit kan je natuurlijk zelf uitbreiden met de ingebouwde History API om de huidige pagina ook in de URL op te slaan. Een meer kant-en-klare oplossing is een library als de Vaadin Router, die nog veel meer features heeft. Maar mijn advies blijft altijd: begin klein, en breid uit wanneer nodig.

Afrondend

Zo, helemaal code-moe na al dat scrollen? Begrijp ik. Maar ik hoop dat je een beeld hebt kunnen vormen bij hoe je veelvoorkomende features er uit zien als je ze met Lit bouwt.

De belangrijkste mindset-switch die ik heb moeten maken tijdens het werken met web components is dat ik telkens terug moest naar de basis. Wat moet mijn formulier kunnen? Hoe moet ik mijn API aanroepen? Hoe moet ik dit stuk code asynchroon inladen? Het is erg verleidelijk om meteen in de NPM registry te gaan zoeken naar een package die dit voor je doet.

Maar als je je verdiept in de standaarden die de browser aan je geeft, kom je er al snel achter dat de browser je eigenlijk verassend veel functionaliteit toereikt, waar je normaliter al snel een NPM package voor zou binnenharken. Wil je valuta, datums en meer in de lokale notatie van de gebruiker tonen? Zie Intl! Exacte berekeningen met grote getallen? Check  BigInt! Tientallen MB’s tot meerdere Gigabytes aan data client-side bewaren? Daar heb je IndexedDB voor! Complexe en intensieve processen buiten de UI-thread om draaien? Het kan met Web Workers of WebAssembly!

Uiteindelijk maakt het begrijpen van deze standaarden je een betere ontwikkelaar. Je hoeft niet voor elk stukje code buiten je comfortzone een NPM package te installeren. Want elke afhankelijkheid heeft weer een eigen unieke kijk op hoe je met een functionaliteit moet werken. Maar elke unieke kijk zorgt weer voor een toename in de Cognitive Load: de hoeveelheid concepten en complexiteit die je in je hoofd moet hebben om de code te begrijpen.

Máár… natuurlijk ga je niet altijd het wiel opnieuw uitvinden. En alhoewel ik het liefste heb dat iedereen het web platform 100% begrijpt, ben ik ook een realist. Niet iedereen hoeft een expert te zijn in hoe je Proxy-objecten kan gebruiken om wijzigingen aan een property in te zien. En aan het einde van de dag zijn we allemaal pragmatische developers met een backlog van hier tot Silicon Valley. Daarom neem ik je volgende week mee in een kijkje naar het Ecosysteem rondom Web Components. Welke dependencies heb je allemaal die je verder kunnen helpen? En hoe zit het met build tooling, SSR en unit testing? Dat allemaal én meer, volgende week. Stay tuned!

Huiswerk!

Alles nog even rustig terugkijken en doorklikken? Ik heb alle code voorbeelden hier op GitHub staan, die je zelf gemakkelijk kan clonen en uitproberen. Wil je zonder gedoe de componenten in een echte webpagina bekijken? Dat kan hier op GitHub Pages.

Heb je de smaak te pakken en wil je zelf wat uitproberen? Clone mijn repository, of nóg makkelijker, check de Lit.dev Playground om direct in je browser te spelen met Lit. Uitdaging nodig? Bouw eens een componentje die een Public Web API aanroept en de resultaten mooi toont in de pagina! En deel je resultaat met mij op Twitter als je er trots op bent ;)  

Deelnemen aan een Web Components workshop met Wessel als trainer? Geef hier je interesse door. Ook beschikbaar voor developmentteams op locatie. 

 

Inhoudsopgave

  1. Web Components 101
  2. Libraries
  3. Show me the code!
  4. Tooling & Ecosysteem
  5. Scaling up!
  6. The Next Level

Web Components 101 – Part 2: Libraries
Volgend bericht
Web Components 101 – Part 2: Libraries