< Home
Stampa

Single Page Application

Sommario

Una Single Page Application è una applicazione Web Javascript composta da una sola pagina html, ma che può comprendere diverse pagine visualizzabili dall’utente. In pratica anzichè utilizzare il sistema tradizionale per cui ad ogni pagina Web corrisponde una pagina HTML con un eventuale Javascript associato, viene caricata una sola pagina, ed è il programma Javascript a mostrare (o nascondere) dinamicamente la singola pagina dell’applicazione attualmente visualizzata.

Il grosso vantaggio di questo tipo di applicazione è che il caricamento della pagina è praticamente istantaneo (nessuna latenza) e questo da all’utente la percezione non di navigare su un sito ma di utilizzare una applicazione locale, dove l’utilizzo della rete è ridotto al solo di scambio di dati col server.

Il meccanismo più semplice per gestire una SPA è attraverso la creazione di “pagine logiche” definite da un tag specifico (ad esempio <div>) all’interno della pagina HTML e poi realizzare un componente JS con il ruolo di “navigatore”, che secondo regole ben definite, mostra in ogni momento una sola di queste pagine.

Un esempio di pagina: il Navigator

Nel progetto qui presentato realizzeremo un tipo di pagina di questo tipo, con queste caratteristiche:

  1. La pagina è suddivisa in contenitori <div> che contengono i componenti di una singola pagina logica.
  2. Ogni pagina è associata ad una URL interna (che contiene quindi un suffisso “#nomepagina”) ovvero utilizzando la forma https//miosito/path/pagina.html#nomepagina, dove ad ogni pagina viene associata una URL univoca. Quindi per ogni url interna avremo un contenitore div che contiene una specifica pagina logica. Le singole pagine possono contenere link ad altre pagine logiche, permettendo quindi una navigazione interna.
  3. E’ presente poi un componente navigator che contiene una funzione che analizza la URL corrente, ed in base a questa mostra la pagina corrispondente.
  4. Infine il navigator intercetta l’evento di cambio pagina, esegue la funzione del punto precedente e cambia la pagina visualizzata, in modo del tutto trasparente per l’utente.

Vediamo un esempio di codice funzionante, da personalizzare poi per i propri progetti.

HTML

<!DOCTYPE html>
<html lang="en">

<head>
   <meta charset="UTF-8">
   <meta name="viewport" content="width=device-width, initial-scale=1.0">
   <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet"
      type="text/css" />
   <link rel="stylesheet" type="text/css" href="./style.css" />
   <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.min.js"></script>
   <title>SPA semplice</title>
</head>

<body>
   <div id="container" class="container">
      <div id="pagina1" class="page">
         <div class="row">Questo è il contenuto di pagina 1</div>
         <div class="row"><a href="#pagina2">Questo è un link a pagina 2</a></div>
         <div class="row"><a href="#pagina3">Questo è un link a pagina 3</a></div>                  
      </div>
      <div id="pagina2" class="page">
         <div class="row"><a href="#pagina1">Questo è un link a pagina 1</a></div>
         <div class="row">Questo è il contenuto di pagina 2</div>         
         <div class="row"><a href="#pagina3">Questo è un link a pagina 3</a></div>                  
      </div>
      <div id="pagina3" class="page">         
         <div class="row"><a href="#pagina1">Questo è un link a pagina 1</a></div>
         <div class="row"><a href="#pagina2">Questo è un link a pagina 2</a></div>                  
         <div class="row">Questo è il contenuto di pagina 3</div>
      </div>
   </div>
   <script type="module" src="./index.js"></script>

</body>

</html>

come si può vedere ogni <div> di pagina è caratterizzato da una classe “page” in modo da essere identificabile dal componente navigator. L’id del div corrisponde alla URL interna del sito, pertanto ad esempio http://miosito/spa.html#pagina2 corrisponde alla visualizzazione del <div> con id=”pagina2″. Osservare poi che le ancore hanno lo stesso nome delle pagine presenti (col prefisso “#”).

CSS

.visible {
   display: block;
}

.hidden {
   display: none;
}

Sono necessarie due classi CSS per gestire la visibilità delle singole pagine, come vedremo qui sotto.

Navigator

const hide = (elements) => {
   elements.forEach((element) => {
      element.classList.add("hidden");
      element.classList.remove("visible");
   });
}

const show = (element) => {
   element.classList.add("visible");
   element.classList.remove("hidden");   
}

export const createNavigator = (parentElement) => {
   const pages = Array.from(parentElement.querySelectorAll(".page"));
   
   const render = () => {
      const url = new URL(document.location.href);
      const pageName = url.hash.replace("#", "");
      const selected = pages.filter((page) => page.id === pageName)[0] || pages[0];

      hide(pages);
      show(selected);
   }
   window.addEventListener('popstate', render); 
   render();   
}

La funzione hide ha lo scopo di nascondere tutte le pagine prima di mostrare la nuova pagina. La funzione show invece mostra la sola pagina selezionata.

Veniamo al costruttore del navigator. L’istruzione:

   const pages = Array.from(parentElement.querySelectorAll(".page"));

seleziona tutti i div con classe “page”, ovvero le pagine logiche.

Le istruzioni:

const url = new URL(document.location.href);
const pageName = url.hash.replace("#", "");

servono a estrarre dal suffisso il nome della pagina, che sappiamo corrispondere alla pagina.

A questo punto:

const selected = pages.filter((page) => page.id === pageName)[0] || pages[0];

Seleziona la pagina a partire dal nome (o di default la prima pagina).

hide(pages);
show(selected);

A questo punto si procede prima col nascondere tutte le pagine e poi mostrare quella selezionata.

Infine:

window.addEventListener('popstate', render);

L’evento popstate viene emesso ogni volta che cambia la url (ad esempio dopo aver cliccato su un link). Quindi quel che succede è che non appena l’utente clicca su un link interno questo evento viene intercettato dal navigator, che esegue la funzione render, che analizza la URL del browser, estrae il nome della pagina e la mostra.

index

import { createNavigator } from "./navigator.js";

const navigator = createNavigator(document.querySelector("#container"));

Il navigator può essere quindi incluso nella pagina index.js del progetto, analogamente a quanto già visto nella lezione sui componenti.

Comunicazione tra componenti

Nel momento in cui si sviluppa una SPA tramite la realizzazione di pagine a loro volta composte da componenti, si pone un problema di progettazione importante, ovvero come notificare agli altri componenti degli aggiornamenti di stato, ad esempio dovuti ad una modifica dei dati o ad aggiornamenti dovuti all’interazione con l’utente e comunque in generale qualsiasi nuovo evento.

Il problema dell’aggiornamento dei dati

Prendiamo come esempio una applicazione SPA composta da due pagine:

  • una pagina pubblica, che mostra una tabella con informazioni anagrafiche (ad esempio un elenco di impiegati di una azienda), con un componente che chiameremo “public”.
  • una pagina di amministrazione, che consiste in un form dove vengono inseriti/modificati questi dati, con un componente che chiameremo “admin”.

Come abbiamo già visto ciascuna pagina è gestita da uno o più componenti che gestiscono l’interazione con l’utente. Tuttavia ciascun componente non solo è un oggetto incapsulato e quindi con metodi e proprietà non accessibili dagli altri componenti, ma non deve nemmeno essere consapevole dell’esistenza degli altri componenti. Nell’esempio sopra riportato quindi il componente di amministrazione gestisce la modifica dei dati (ad esempio l’aggiunta di una anagrafica) ma non avrebbe modo di comunicare al componente tabella della pagina pubblica che è presente una nuova anagrafica.

Perché è assolutamente necessario che i componenti non comunichino direttamente? Perché se fosse possibile, vorrebbe dire che il componente “admin” dipende direttamente da quello che fa il componente “public”. Questo significa che se in uno sviluppo futuro volessimo modificare il componente public, dovremmo modificare anche il componente admin. Questo significa che il componente admin dipende forzatamente dal modo in cui è implementato il componente public.

Inoltre se volessimo creare un nuovo componente, che mostri per esempio quante sono le anagrafiche, anche questo componente dovrebbe essere aggiornato dal componente admin ogni volta che viene aggiunta una anagrafica, e quindi il programmatore si troverebbe di fronte al problema di dover modificare ancora una volta l’admin, per renderlo “consapevole” del dover far aggiornare gli altri componenti.

La soluzione

La soluzione consiste sostanzialmente nel rendere possibile un aggiornamento da parte di un componente verso gli altri, senza che però questo componente sappia chi sono.

In questa direzione sono state proposte storicamente tre diverse soluzioni:

  • il delegate pattern: in questo caso il componente che vuole essere notificato si registra (ad esempio con una callback detta delegate) presso il componente che sta aggiornando i dati. Il componente che aggiorna i dati chiama il delegate che a sua volta aggiorna i dati;
  • l’observer pattern: si crea una coppia di oggetti, detti Soggetto e Osservatore (Subject e Observer) il primo che contiene l’oggetto da osservare (i dati) e il secondo che osserva le modifiche sui dati. Ogni volta che il soggetto viene modificato, questo notifica all’osservatore che c’è stata una modifica, e questo si comporterà di conseguenza;
  • il publisher-subscribe pattern: in questo caso si crea un oggetto mediatore detto pubsub, visibile a entrambe le parti (generatore di eventi e sottoscrittore di eventi). Il sottoscrittore (ad esempio public) si registra al pubsub con una callback associata ad un determinato evento, mentre il generatore quando si modificano i dati chiama il pubsub emettendo l’evento stesso. Il pubsub quindi richiama tutte le callback dei sottoscrittori notificando loro che c’è un evento.

I primi due pattern sono in generale utilizzati nei linguaggi di programmazione ad oggetti che prevedono il concetto di interfaccia. Tuttavia questo concetto non è presente in tutti i linguaggi (come ad esempio Javascript o Python) e quindi la soluzione più semplice diventa il pubsub pattern.

Vediamo quindi con un esempio come funziona il pubsub.

Pubsub

Il pubsub è molto semplice da implementare in Javascript, ad esempio col codice seguente.

const createPubSub = () => {
    const dict = {};
    return {
        subscribe: (eventName, callback) => {
            if (!dict[eventName]) {
                dict[eventName] = [];
            }
            dict[eventName].push(callback);
        },
        publish: (eventName) => {
            dict[eventName].forEach((callback) => callback());
        }
    }
}

Come si può vedere gli eventi sono gestiti come un dizionario di callback, dove ad ogni chiave corrisponde un evento.

Ipotizziamo di volerlo usare in una semplice applicazione con due componenti: uno che mostra un contatore di click, ed un secondo che contiene due pulsanti, uno che incrementa i click ed uno che azzera il contatore.

Qui l’HTML:

<!DOCTYPE html>
<html lang="en">

<head>
   <meta charset="UTF-8">
   <meta name="viewport" content="width=device-width, initial-scale=1.0">
   <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet"
      type="text/css" />
   <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.min.js"></script>
   <title>PubSub</title>
</head>

<body>
   <div id="container" class="container">
      <div id="component1" class="row">
         
      </div>
      <div id="component2" class="row">
        <div class="col">
            <button id="increment" type="button" class="btn btn-positive">Incrementa</button>
        </div>
        <div class="col">
            <button id="clear" type="button" class="btn btn-positive">Azzera</button>
        </div>
      </div>
   </div>
   <script type="module" src="./index.js"></script>

</body>

</html>

Qui il componente 1 (che mostra il contatore):

const createComponent1 = (element, pubsub) => {
    let counter = 0;
    const render = () => {
        element.innerText = "Hai cliccato il pulsante " + counter + " volte.";
    }
    pubsub.subscribe("increment", () => {
        counter++;
        render();
    });
    pubsub.subscribe("clear", () => {
        counter = 0;
        render();
    });
    render();
}

Qui il componente 2 (che gestisce i pulsanti):

const createComponent2 = (element, pubsub) => {
    element.querySelector("#increment").onclick = () => pubsub.publish("increment");
    element.querySelector("#clear").onclick = () => pubsub.publish("clear");
}

A questo punto è sufficiente collegare tutti i componenti al pubsub:

const pubsub = createPubSub();
const component1 = createComponent1(document.querySelector("#component1"), pubsub);
const component2 = createComponent2(document.querySelector("#component2"), pubsub);

Concludendo:

  • pubsub è un componente generico: non sa chi notifica, cosa notifica, svolge quindi il semplice ruolo di passaggio di informazioni.
  • con pubsub i componenti che emettono eventi e quelli che li ricevono non sono consapevoli gli uni degli altri. E’ quindi possibile scrivere componenti in piena libertà senza dipendere dagli altri. Ad esempio si potrebbe scrivere un nuovo componente che mostra l’orario dell’ultimo cambiamento del contatore (si sottoscrive a tutti gli eventi ed aggiorna la data ora).
  • è possibile avere un numero illimitato di eventi, sottoscrittori e in qualsiasi combinazione.

Versioni evolute

Il problema di una gestione centralizzata dei dati unita a quello della notifica ai sottoscrittori ha portato allo sviluppo di soluzioni ancora più evolute di queste. Presentiamo qui il Flux pattern e RXJS, che sono alla base dei framework rispettivamente di React (con Redux) anche se usato anche da Angular, e di RXJS, una libreria fondamentale del framework Angular (ma che può essere usata in modo indipendente).

Flux (e Redux)

In Flux (e la sua principale e più nota implementazione, la libreria Redux) l’obiettivo è realizzare un contenitore generale per tutti i dati e contemporaneamente gestire l’accesso in scrittura e lettura e le notifiche per tutti i componenti. In pratica Flux è sia contenitore dei dati che una specie di Pubsub. In Flux sono previsti 3 concetti:

  • lo store, un oggetto che a sua volta contiene tutti i dati;
  • le views: sono delle funzioni pubbliche dello store che permettono di esportare i dati (ad esempio con una query, un filtro, o altro);
  • le actions: sono delle funzioni pubbliche che permettono di modificare lo store, sono suddivise per tipo (il tipo di modifica) e ricevono come parametri i dati da modificare (o cancellare o aggiungere). Le actions producono una nuova istanza dello store che contiene le modifiche richieste (ed il vecchio store viene eliminato).
  • I dispatcher: i dispatcher sono i publisher del pubsub, emettono eventi ai sottoscrittori, che vengono quindi notificati che lo store è cambiato.

Con questo sistema si separa in modo netto il flusso di modifica dello store (ad esempio inserendo un nuovo dato) dallo store stesso (che ha internamente le procedure per modificare il dato), così come si separa la notifica dalla visualizzazione dei dati. Questo principio si chiama CQSP (Command Query Segregation Principle), ovvero separa il flusso di modifica da quello di visualizzazione, che è un po’ il principio di fondo che abbiamo visto all’inizio della lezione.

Flux introduce concetti di programmazione funzionale (lo store è immutabile, ad ogni modifica ne viene creato uno nuovo, ed ogni trasformazione è eseguita da una funzione) ed è alla base del framework React (con Redux) ma è usatissimo anche in Angular (NgRx) e Vue (Vuex) e rappresenta la vera svolta nelle SPA perché permette di gestire un vero e proprio domain model.

RXJS

La libreria RX ha un approccio completamente nuovo al problema, in quanto utilizzo un altro aspetto della programmazione funzionale, ovvero il concetto di funzione che esegue un insieme di trasformazioni parziali su un set di dati. RXJS unisce questo flusso al concetto di promise.

Alla base di RX c’è l’oggetto Observable, che a partire da un set di dati, crea un oggetto concettualmente identico ad una promise, che però a differenza della promise può essere elaborato da una operazione di trasformazione, che produce quindi un nuovo Observable. In pratica quindi è possibile collegare in sequenza le azioni in una catena (chiamata pipe) che al termine producono un nuovo Observable.

L’Observable è come una promise, la serie di azioni è pronta per essere eseguita. Per attivarlo (e quindi eseguire tutta la sequenza) c’è quindi una azione subscribe che riceve come argomento un oggetto Observer, che riceve il risultato finale e lo elabora per la sua destinazione finale (ad esempio una render, oppure una fetch).

Inoltre, siccome, gli Observable sono oggetti, possono essere parzialmente elaborati (ad esempio applicando trasformazioni come il map o reduce) da componenti distinti e produrre quindi degli Observable “semilavorati” che possono poi essere successivamente passati al componente successivo per una successiva elaborazione o al componente finale.

L’aspetto più interessante è poi quello di rendere possibile la costruzione dinamica della pipe in base a condizioni dinamiche (quindi a runtime), il fatto di poter collegare la stessa fonte di dati a più Observers, o viceversa combinare più Observable in uno solo.

RXJS è quindi qualcosa di più che una libreria che aiuta a gestire i dati, ma un nuovo modo di concepire il loro flusso. Un Observable può essere sia una fonte di dati, ma anche un evento utente, o una fetch remota, un timeout, o un interval (che emette periodicamente un evento).