Micro Frontends

estendiamo l'idea dei microservizi al frontend

Guarda il progetto su GitHub
EN JP ES PT KR RU CN IT

Tecniche, strategie e ricette per sviluppare un’applicazione web moderna con il contributo di team diversi che possano rilasciare funzionalità in maniera indipendente.

Cosa sono i Micro Frontend?

Il termine Micro Frontend è apparso per la prima volta su ThoughtWorks Technology Radar alla fine del 2016. Estende i concetti dei microservizi al mondo del frontend. Il trend corrente era di costruire applicazioni browser potenti e ricche di funzionalità - note come single page application - in cima ad architetture a microservizi. Con il tempo, questo strato di frontend, sviluppato il più delle volte da un team a sé stante, cresce e diventa difficile da manutenere. Lo chiamiamo Frontend Monolitico.

L’idea alla base dei Micro Frontend è - invece - di pensare al sito web o alla web app come a una composizione di funzionalità che fanno capo a team indipendenti. Ogni team ha una sua area di business, o missione, diversa, di cui si prende cura e in cui si specializza. Ogni team è cross funzionale e sviluppa le sue funzionalità end-to-end, dal database all’interfaccia utente.

C’è da dire che quest’idea non è nuova. Ha molti punti in comune con il concetto di Sistemi auto-contenuti. In passato, approcci simili venivano chiamati Integrazione del Frontend per Sistemi Verticalizzati. Ma, chiaramente, Micro Frontend è un termine più comodo e meno altisonante.

Frontend Monolitici Frontend Monolitici

Organizzazione in verticali Team End to End con Micro Frontends

Cos’è un’applicazione web moderna?

Nell’introduzione, ho usato l’espressione “costruire un’applicazione web moderna”. Definiamo le assunzioni collegate a questa definizione.

Partiamo da lontano: Aral Balkan ha scritto un articolo su quello che chiama il Continuum documenti-applicazioni. Ha proposto l’immagine di una bilancia scorrevole alla cui sinistra c’è un sito costruito da documenti statici, connessi attraverso link, mentre alla destra c’è un’applicazione senza contenuti, guidata puramente da comportamenti (behaviour driven), come un editor di foto.

Se il tuo progetto si posiziona alla sinistra dello spettro, è adatto a un’integrazione a livello di webserver. In tale modello, un server raccoglie e concatena stringhe HTML provenienti da tutti i componenti che costituiscono la pagina richiesta dall’utente. Gli aggiornamenti sono fatti ricaricando la pagina dal server o sostituendone alcune parti con Ajax. Gustaf Nilsson Kotte ha scritto un articolo esaustivo su quest’argomento.

Quando la tua interfaccia utente deve mostrare un feedback immediato, anche in caso di cattiva connessione, non basta più un sito costruito interamente sul server. Per implementare tecniche come UI ottimistica o Skeleton Screens devi poter aggiornare la UI sul device stesso. La definizione di Google Progressive Web Apps descrive convincentemente l’atto di bilanciamento che consiste nell’essere un bravo cittadino del web (enhancement progressivo), garantendo allo stesso tempo performance simili a quelle di un’app. Questo tipo d’applicazione si pone più o meno a metà del continuum sito-app. Qui non basta più una soluzione basata solo sul server. Dobbiamo spostare l’integrazione nel browser, e questo è il focus di quest’articolo.

Idee fondamentali alla base dei Micro Frontend

  • Sii Agnostico sulla Tecnologia
    Ogni team dovrebbe poter scegliere e aggiornare il suo stack senza doversi coordinare con gli altri team. Gli Elementi Custom sono un modo ottimo per nascondere i dettagli implementativi, fornendo al contempo un’interfaccia neutrale agli altri.
  • Isola il Codice del Team
    Non condividere il runtime, anche se tutti i team usano lo stesso framework. Costruisci applicazioni indipendenti e auto-contenute. Non fare affidamento sullo stato condiviso o su variabili globali.
  • Stabilisci Prefissi per i Team
    Condividi una naming convention laddove non sia ancora possibile l’isolamento. Fornisci un namespace a CSS, Eventi, Local Storage e Cookies per evitare collisioni e per chiarire chi è l’owner.
  • Privilegia le Funzionalità Native del Browser rispetto alle API Custom Usa Gli Eventi del Browser per la comunicazione invece di implementare un sistema globale PubSub. Se proprio devi creare un’API cross-team, cerca di tenerla la più semplice possibile.
  • Costruisci un Sito Resiliente
    Le feature del sito dovrebbero rimanere utili anche se JavaScript fallisce o non è ancora stato eseguito. Usa il Rendering Universale e l’Enhancement Progressivo per migliorare le performance percepite.

Il DOM è l’API

Gli Elementi Custom, che rappresentano l’aspetto d’interoperabilità della specifica Web Components, sono una buona primitiva per realizzare un’integrazione nel browser. Ogni team costruisce il suo componente usando la tecnologia che preferisce e la avvolge in un Elemento Custom (esempio: <order-minicart></order-minicart>). La specifica DOM di questo particolare elemento (tag-name, attributi ed eventi) fa da contratto o API pubblica per gli altri team. Il vantaggio è che questi ultimi possono usare il componente e le sue funzionalità senza conoscerne l’implementazione: devono solo interagire col DOM.

Ma gli Elementi Custom, da soli, non sono la soluzione a tutti i nostri problemi. Per indirizzare l’enhancement progressivo, il rendering universale e il routing, abbiamo bisogno di software aggiuntivo.

Questa pagina è divisa in due aree principali. Prima dobbiamo discutere della Composizione della Pagina - ovvero come assemblare una pagina da più componenti gestiti da team diversi. Dopo, mostreremo esempi per implementare le Transizioni di Pagina lato client.

Composizione della Pagina

Oltre proprio all’integrazione del codice lato client e server scritto con framework diversi, ci sono un sacco di argomenti a lato da discutere: i meccanismi per isolare il JavaScript, evitare i conflitti CSS, caricare le risorse quando serve, condividere le risorse comuni fra i team, gestire la richiesta di dati dal server e pensare a una giusta gestione degli stati di caricamento per l’utente. Affronteremo questi argomenti un passo alla volta.

Il Prototipo Base

Useremo come base per gli esempi seguenti la pagina prodotto di un negozio di modellini di trattore.

Espone un selettore di varianti per scegliere fra i tre diversi modellini di trattore. A ogni cambiamento, si aggiornano l’immagine, il nome, il prezzo e le raccomandazioni del prodotto. C’è anche un pulsante d’acquisto, che aggiunge la variante selezionata al cestino, e un mini carrello alla sommità della pagina, che si aggiorna di conseguenza.

Esempio 1 - Pagina Prodotto - JS Puro

provalo nel browser & ispeziona il codice

Tutto l’HTML è generato usando JavaScript puro e stringhe template ES6 senza nessuna dipendenza. Il codice usa una semplice separazione stato/markup e ri-renderizza tutto l’HTML lato client a ogni cambiamento - non c’è nessun DOM diffing strano e nessun rendering universale per ora. Inoltre, non c’è separazione fra team - il codice è scritto in un file js/css.

Integrazione lato Client

In quest’esempio, la pagina è divisa in componenti/frammenti separati, gestiti da tre team. Team Checkout (blu) adesso è responsabile di tutto quello che riguarda il processo d’acquisto - nello specifico, il pulsante d’acquisto e il mini carrello. Il Team Inspire (verde) gestisce i prodotti raccomandati su questa pagina. La pagina stessa è gestita dal Team Product (rosso).

Esempio 1 - Pagina Prodotto - Composizione

prova nel browser & ispeziona il codice

Ogni Team

Il Team Product decide che funzionalità dev’essere inclusa e dove deve essere posizionata nel layout. La pagina contiene informazioni che possono essere fornite dallo stesso Team Product, come il nome del prodotto, l’immagine e le varianti disponibili. La pagina include anche frammenti (elementi custom) dagli altri team.

Come creare un Elemento Custom?

Prendiamo per esempio il pulsante d’acquisto. Il Team Product include il pulsante aggiungendo semplicemente <blue-buy sku="t_porsche"></blue-buy> alla posizione desiderata del markup. Per farlo funzionare, il Team Checkout deve registrare l’elemento blue-buy sulla pagina.

class BlueBuy extends HTMLElement {
  connectedCallback() {
    this.innerHTML = `<button type="button">acquista a 66,00 €</button>`;
  }

  disconnectedCallback() { ... }
}
window.customElements.define('blue-buy', BlueBuy);

Adesso, ogni volta che il browser trova un nuovo tag blue-buy, viene chiamata la callback connectedCallback. this è un riferimento al nodo DOM root dell’elemento custom. Si possono usare tutte le proprietà e i metodi di un elemento DOM standard, come innerHTML or getAttribute().

Elemento Custom in Azione

Quando dai un nome all’elemento, l’unico requisito definito dalla specifica è che il nome deve includere un trattino (-) per mantenere la compatibilità con tag HTML futuri. Nei prossimi esempi, useremo la convenzione [colore_del_team]-[feature]. Il namespace del team ci protegge da collisioni e, in aggiunta, diventa in questo modo ovvio chi detiene una feature, guardando semplicemente il DOM.

Comunicazione Padre-Figlio / Modifica del DOM

Se l’utente seleziona un altro trattore nel selettore di varianti, dev’essere aggiornato corrispondentemente il pulsante d’acquisto. A questo scopo, il Team Prodotto può semplicemente togliere l’elemento esistente dal DOM e inserirne uno nuovo.

container.innerHTML;
// => <blue-buy sku="t_porsche">...</blue-buy>
container.innerHTML = '<blue-buy sku="t_fendt"></blue-buy>';

La callback disconnectedCallback del vecchio elemento viene invocata in maniera sincrona per dare all’elemento la possibilità di fare pulizia di cose come i listener di eventi. Dopo, viene invocata la callback connectedCallback dell’elemento appena creato t_fendt.

Un’altra possibilità più performante è di aggiornare solo l’attributo sku dell’elemento esistente:

document.querySelector('blue-buy').setAttribute('sku', 't_fendt');

Se il Team Product usasse un motore di template che implementa il DOM diffing, come React, questo verrebbe eseguito automaticamente dall’algoritmo.

Cambio Attributo Elemento Custom

Per supportare questo comportamento, l’Elemento Custom può implementare la callback attributeChangedCallback e specificare una lista di observedAttributes per cui dovrebbe essere scatenata questa callback.

const prices = {
  t_porsche: '66,00 €',
  t_fendt: '54,00 €',
  t_eicher: '58,00 €',
};

class BlueBuy extends HTMLElement {
  static get observedAttributes() {
    return ['sku'];
  }
  connectedCallback() {
    this.render();
  }
  render() {
    const sku = this.getAttribute('sku');
    const price = prices[sku];
    this.innerHTML = `<button type="button">acquista a ${price}</button>`;
  }
  attributeChangedCallback(attr, oldValue, newValue) {
    this.render();
  }
  disconnectedCallback() {...}
}
window.customElements.define('blue-buy', BlueBuy);

Per evitare duplicazioni, introduciamo un metodo render() che viene chiamato da connectedCallback e attributeChangedCallback. Questo metodo raccoglie i dati necessari e il nuovo markup va in innerHTML. Se si decide di usare un motore o framework di template più sofisticato nell’Elemento Custom, questo è il posto dove dovrebbe andare il suo codice d’inizializzazione.

Supporto dei Browser

L’esempio precedente usa la specifica versione 1 degli Elementi Custom, che al momento è supportata da Chrome, Safari e Opera. Però, con il progetto document-register-element è stato reso disponibile un polyfill rodato e leggero per far funzionare tutto ciò in tutti i browser. Sotto il cofano, usa un’API ampiamente supportata, la Mutation Observer, dunque non ci sono controlli strani in background dell’albero DOM.

Compatibilità Framework

Siccome gli Elementi Custom sono uno standard web, li supportano tutti i principali framework JavaScript, come Angular, React, Preact, Vue o Hyperapp. Però, quando entri nei dettagli, alcuni di questi framework hanno ancora qualche problemino implementativo. Su Custom Elements Everywhere, Rob Dodson ha messo in piedi una suite di test che evidenzia i problemi irrisolti.

Evitiamo l’Anarchia dei Framework

Usare gli Elementi Custom è un ottimo modo per raggiungere un alto grado di disaccoppiamento fra i frammenti dei diversi team. In questo modo, ogni team è libero di scegliere un framework di frontend. Però, solo perché puoi farlo non significa che sia saggio mixare tecnologie differenti. Proviamo ad evitare l’Anarchia dei Micro Frontend e a creare invece un livello di allineamento ragionevole fra i vari team. Così, i team possono scambiarsi insegnamenti e best practice. Ci renderà poi la vita più facile se vogliamo stabilire una pattern library centralizzata. Detto ciò, la possibilità di mixare le tecnologie può essere utile quando lavori con un’applicazione legacy e vuoi migrarla a uno stack tecnologico nuovo.

Comunicazione fra Figlio-Genitore o fra Fratelli / Eventi DOM

Ma passare gli attributi non è sufficiente per tutte le interazioni. Nel nostro esempio, il mini carrello dovrebbe aggiornarsi quando l’utente clicca sul pulsante d’acquisto.

Entrambi i frammenti sono di proprietà del Team Checkout (blu), quindi loro potrebbero creare una qualche API JavaScript interna che permettesse al mini carrello di sapere quando è stato premuto un pulsante. Ma questo significherebbe che le istanze dei componenti dovrebbero conoscersi a vicenda e questa sarebbe pure una violazione dell’isolamento.

Un modo più pulito è di usare un meccanismo PubSub, in cui un componente può pubblicare un messaggio e altri componenti possono sottoscriversi a certi topic. Per fortuna, i browser hanno nativamente questa funzionalità. Questo è esattamente come funzionano eventi del browser tipo click, select o mouseover. In aggiunta agli eventi nativi, c’è anche la possibilità di creare eventi di livello superiore con new CustomEvent(...). Gli eventi sono sempre legati al nodo DOM su cui sono stati creati o dispacciati. La maggior parte degli eventi nativi supporta anche il bubbling. Questo rende possibile ascoltare tutti gli eventi su un sotto-albero specifico del DOM. Se vuoi ascoltare tutti gli eventi della pagina, attacca l’event listener all’elemento window. Ecco come appare la creazione dell’evento blue:basket:changed nell’esempio:

class BlueBuy extends HTMLElement {
  [...]
  connectedCallback() {
    [...]
    this.render();
    this.firstChild.addEventListener('click', this.addToCart);
  }
  addToCart() {
    // magari qui chiama un'API
    this.dispatchEvent(new CustomEvent('blue:basket:changed', {
      bubbles: true,
    }));
  }
  render() {
    this.innerHTML = `<button type="button">acquista</button>`;
  }
  disconnectedCallback() {
    this.firstChild.removeEventListener('click', this.addToCart);
  }
}

Il mini carrello può adesso sottoscriversi a quest’evento su window ed essere avvisato quando dovrebbe aggiornare i suoi dati.

class BlueBasket extends HTMLElement {
  connectedCallback() {
    [...]
    window.addEventListener('blue:basket:changed', this.refresh);
  }
  refresh() {
    // leggi dati nuovi e renderizzali
  }
  disconnectedCallback() {
    window.removeEventListener('blue:basket:changed', this.refresh);
  }
}

Con quest’approccio, il frammento del mini carrello aggiunge un listener a un elemento del DOM che è fuori dal suo scope (window). Questo dovrebbe essere OK in molte applicazioni ma, se proprio non piace, si può anche implementare un approccio in cui la pagina stessa (Team Product) ascolta un evento e notifica il mini carrello chiamando refresh() sull’elemento del DOM.

// page.js
const $ = document.getElementsByTagName;

$('blue-buy')[0].addEventListener('blue:basket:changed', function() {
  $('blue-basket')[0].refresh();
});

Non è comune chiamare imperativamente metodi del DOM, ma si può trovare un esempio nella video element api. Se possibile, dovrebbe essere preferito l’uso dell’approccio dichiarativo (cambio dell’attributo).

Rendering lato Server / Rendering Universale

Gli Elementi Custom vanno benissimo per integrare componenti nel browser. Però, quando costruisci un sito che è accessibile dal web, è probabile che siano importanti pure le performance di caricamento iniziale e gli utenti vedranno lo schermo bianco finché non vengono scaricati ed eseguiti tutti i framework JavaScript. In aggiunta, è utile capire cosa succede quando il JavaScript fallisce o è bloccato. Jeremy Keith ne spiega l’importanza nel suo eBook / podcast Resilient Web Design. Dunque, la capacità di renderizzare i contenuti core sul server è chiave. Purtroppo, la specifica sui web component non parla proprio di rendering lato server. Niente JavaScript, niente Elementi Custom :(

Elementi Custom + Server Side Includes = ❤️

Per far funzionare il rendering lato server, bisogna fare refactoring dell’esempio precedente. Ogni team ha il proprio server Express ed è accessible via web anche il metodo render() dell’Elemento Custom.

$ curl http://127.0.0.1:3000/blue-buy?sku=t_porsche
<button type="button">acquista a 66,00 €</button>

Il nome del tag dell’Elemento Custom viene usato come nome del percorso - gli attributi diventano query parameters. Adesso abbiamo un modo per renderizzare lato server il contenuto di ogni componente. Insieme agli Elementi Custom <blue-buy>, si ottiene qualcosa di molto simile a un Componente Web Universale:

<blue-buy sku="t_porsche">
  <!--#include virtual="/blue-buy?sku=t_porsche" -->
</blue-buy>

Il commento #include fa parte dei Server Side Includes, che è una funzionalità disponibile nella maggior parte dei server web. Sì, è la stessa tecnologia che si usava una volta per inglobare la data corrente sui siti web. Ci sono anche tecniche alternative come ESI, nodesi, compoxure e tailor ma, per i nostri progetti, le SSI si sono mostrate una soluzione semplice e incredibilmente stabile.

Il commento #include viene sostituito dalla risposta di /blue-buy?sku=t_porsche prima che il server invii la pagina completa al browser. La configurazione in nginx appare così:

upstream team_blue {
  server team_blue:3001;
}
upstream team_green {
  server team_green:3002;
}
upstream team_red {
  server team_red:3003;
}

server {
  listen 3000;
  ssi on;

  location /blue {
    proxy_pass  http://team_blue;
  }
  location /green {
    proxy_pass  http://team_green;
  }
  location /red {
    proxy_pass  http://team_red;
  }
  location / {
    proxy_pass  http://team_red;
  }
}

La direttiva ssi: on; abilita la funzionalità SSI. Vengono aggiunti un blocco upstream e uno location per team, per assicurarsi che tutti gli URL che cominciano con /blue siano diretti all’applicazione giusta (team_blue:3001). In aggiunta, la rotta / viene mappata al Team Red, che controlla la homepage / pagina prodotto.

Quest’animazione mostra il negozio di modellini di trattori in un browser che ha JavaScript disabilitato.

Rendering lato Server - JavaScript Disabilitato

ispeziona il codice

I pulsanti di selezione della variante adesso sono proprio link e ogni click porta a ricaricare la pagina. La linea di comando sulla destra mostra il processo con cui una richiesta della pagina viene inoltrata al Team Rosso, che controlla la pagina prodotto; dopo, il markup viene fornito dai frammenti dei Team Blu e Verde.

Se viene riattivato JavaScript, sarà visibile solo il messaggio di log per la prima richiesta. Tutte le modifiche seguenti al trattore saranno gestite lato client, come nel primo esempio. In un esempio successivo, i dati dei prodotti saranno estratti dal JavaScript e caricati da una API REST per quel che serve.

Puoi giocare con quest’esempio sulla tua macchina locale. Devi installare solo Docker Compose.

git clone https://github.com/neuland/micro-frontends.git
cd micro-frontends/2-composition-universal
docker-compose up --build

Docker fa partire nginx sulla porta 3000 e costruisce un’immagine node.js per ogni team. Quando apri http://127.0.0.1:3000/ nel browser, dovresti vedere un trattore rosso. I log combinati di docker-compose permettono di vedere facilmente cosa succede sulla rete. Purtroppo non c’è modo di controllare il colore dell’output, quindi devi rassegnarti al fatto che il Team Blu potrebbe essere evidenziato in verde :)

I file src sono mappati nei container individuali e l’applicazione node ripartirà quando fai una modifica al codice. Cambiare il file nginx.conf richiede un riavvio di docker-compose per avere effetto. Sentiti libero di giochicchiare e di fornire un feedback.

Lettura dei dati & stati di Caricamento

Uno svantaggio dell’approccio SSI/ESI è che il frammento più lento determina il tempo di risposta dell’intera pagina. Quindi, può far bene cachare la risposta di un frammento. Per frammenti che sono dispendiosi da produrre e difficili da mettere in cache, è spesso indicato escluderli dal rendering iniziale. Possono essere caricati in maniera asincrona nel browser. Nel nostro esempio, un buon candidato per questo è il frammento green-recos, che mostra raccomandazioni personalizzate.

Una possibile soluzione sarebbe che il Team Rosso saltasse proprio l’SSI Include.

Prima

<green-recos sku="t_porsche">
  <!--#include virtual="/green-recos?sku=t_porsche" -->
</green-recos>

Dopo

<green-recos sku="t_porsche"></green-recos>

Nota a lato importante. Gli Elementi Custom non possono essere self-closing, quindi è sbagliato scrivere <green-recos sku="t_porsche" />

Riposizionamento

Il rendering avviene solo nel browser. Ma, come si può vedere nell’animazione, questo cambio ha introdotto un reflow sostanziale della pagina. Il principio, la sezione raccomandazioni è bianca. Il JavaScript del Team Verde viene caricato ed eseguito. Viene fatta la chiamata API per ricevere le raccomandazioni personalizzate. Viene renderizzato il markup delle raccomandazioni e vengono richieste le immagini associate. Il frammento ora ha bisogno di più spazio e spinge giù il layout della pagina.

Ci sono diverse opzioni per evitare un riposizionamento fastidioso come questo.

Il Team Rosso, che controlla la pagina, potrebbe rendere fissa l’altezza del container delle raccomandazioni. Su un sito responsive è spesso ingannevole determinare l’altezza, perché potrebbe differire per schermi diversi. Ma il problema più serio è che questo tipo di accordi inter-team crea un accoppiamento stretto fra i Team Rosso e Verde. Se il Team Verde vuole introdurre una sotto-intestazione aggiuntiva nell’elemento raccomandazioni, dovrebbe coordinarsi con il Team Rosso per la nuova altezza. I team dovrebbero rilasciare simultaneamente per evitare che si rompa il layout.

Un metodo migliore è di usare una tecnica chiamata Skeleton Screens. Il Team Rosso lascia l’include SSI green-recos nel markup. In più, il Team Verde cambia il metodo di renderizzazione lato-server del suo frammento in modo che produca una versione schematica del contenuto. Il markup skeleton_ può riusare parte degli stili del layout del contenuto reale. Così prenota lo spazio necessario e il riempimento del contenuto reale non comporta un salto.

Schermo Skeleton

Gli Skeleton screen sono anche molto utili per il rendering lato client. Quando il tuo Elemento Custom viene inserito nel DOM per un’azione dell’utente, potrebbe renderizzare immediatamente lo scheletro finché non arrivano i dati di cui ha bisogno dal server.

Anche per un cambio d’attributo (per esempio per la selezione di una variante) si può decidere di passare alla vista scheletro finché non arrivano i nuovi dati.

In questo modo, l’utente riceve un’indicazione che qualcosa sta succedendo nel frammento. Ma quando l’endpoint risponde velocemente, può dare fastidio anche un piccolo sfarfallio dello scheletro fra i dati vecchi e nuovi. Può aiutare preservare i vecchi dati o usare timeout intelligenti. Quindi, usa questa tecnica saggiamente e cerca di ottenere il feedback degli utenti.

continua presto … (Giuro)

Guarda il Repo Github per ricevere le notifiche

Risorse Aggiuntive

Tecniche Correlate


Cose in Arrivo … (molto presto)

  • Use Cases
    • Navigazione fra le pagine
      • navigazione soft vs. hard
      • universal router
  • Argomenti a lato
    • CSS Isolato / Interfaccia Utente Coerente / Style Guide & Pattern Library
    • Performance al caricamento iniziale
    • Performance mentre usi il sito
    • Caricare il CSS
    • Caricare JS
    • Test d’Integrazione

Hanno contribuito

Questo sito è generato da Github Pages. Il codice si trova qui: neuland/micro-frontends.