Coding

Padroneggiare gli e-commerce con un'architettura SSR a micro-frontend

Seguici nel nostro percorso attraverso le complessità dell'architettura SSR a micro-frontend in progetti e-commerce su larga scala e nella nostra esplorazione di un server SSR personalizzato integrato con Nginx SSI e Vite.

Vincenzo Guerrisi Vincenzo Guerrisi
5 settembre 2024 — 9 min

Nel nostro ruolo di consulenti tecnici abbiamo partecipato a diversi progetti sviluppati in React.js, affrontando sfide architetturali uniche, tutte con un fattore critico comune: la necessità di un’architettura micro-frontend che permetta a più team di fornire componenti distinti, sviluppati con tecnologie eterogenee, integrati in modo fluido in un’unica applicazione coerente.

Nei siti web complessi e orientati al cliente finale, dove SEO e tempi di caricamento iniziali sono critici, il Server-Side Rendering (SSR) è essenziale per migliorare le prestazioni e potenzialmente aumentare le conversioni. Tuttavia, le soluzioni SSR più diffuse come Next.js o Remix sembrano mancare di opzioni robuste per la costruzione di micro-frontend, e l’approccio Module Federation, pur essendo valido, presenta le proprie limitazioni.

In questo articolo vedremo come abbiamo gestito l’SSR in un’architettura incentrata su Nginx SSI. Mostreremo come lo sviluppo di un server SSR personalizzato possa superare i vincoli tipici dei framework, offrendo maggiore flessibilità nella progettazione e nelle prestazioni delle applicazioni web.

Il problema: affrontare l’interoperabilità nei progetti multi-team

Le applicazioni web su larga scala, per dimensioni e complessità, implicano spesso la gestione di funzionalità diverse sviluppate e mantenute da più team autonomi, ciascuno con competenze e background tecnologico differenti.

Immagina un e-commerce di grandi dimensioni che offre varie funzionalità indipendenti — dai dettagli dei prodotti alle recensioni dei clienti, fino al carrello virtuale. Tutte queste funzionalità richiedono competenze specialistiche per sviluppo e manutenzione. Non è raro, quindi, trovare team diversi dedicati a componenti differenti.

Coordinare questi componenti indipendenti affinché lavorino in modo coeso è una sfida significativa. È qui che i micro-frontend 1 emergono come potenziale soluzione, consentendo a ogni team di sviluppare e distribuire la propria parte in modo indipendente, senza interrompere l’esperienza utente complessiva.

Tuttavia, orientarsi tra i vari metodi per recuperare questi componenti indipendenti lato server e integrarli tra loro può risultare complesso. La sfida è ancora maggiore quando l’obiettivo è migliorare prestazioni e SEO mantenendo il Server-Side Rendering.

Nei nostri progetti più recenti abbiamo affrontato queste complessità con un’architettura basata sulla funzionalità Server-Side Include (SSI) di Nginx

__wf_reserved_inherit

Dentro l’architettura

La configurazione è composta da più “pagine” indipendenti, ognuna sviluppata con la tecnologia più adatta al singolo caso. Un reverse proxy o load balancer analizza i segmenti dell’URL per indirizzare l’utente alla pagina appropriata.

Queste “pagine” possono fungere da contenitori per i “frammenti” — micro-app responsabili di generare snippet HTML distinti per una determinata area o funzionalità indipendente della pagina (come il carrello o l’area delle recensioni). L’integrazione avviene al momento della richiesta: il server web esamina l’HTML del padre alla ricerca di commenti HTML speciali che fanno riferimento al server responsabile di fornire lo snippet del frammento. Lo snippet viene quindi recuperato dal server e inserito nella pagina prima che questa venga inviata all’utente. La stessa strategia può essere usata anche dai frammenti per includere altri frammenti in una struttura annidata.

In questa architettura il requisito chiave è preservare la metodologia SSR, migliorando il tempo di caricamento iniziale tramite il pre-rendering di tutti gli snippet HTML dei componenti lato server, accelerando così la consegna della pagina completa all’utente.

__wf_reserved_inherit

Implementazione dei frammenti

Perché le tecnologie SSR tradizionali non bastano?

Quando si parla di tecnologie SSR in React.js si pensa subito a framework come Next.js e Remix, che semplificano l’implementazione di applicazioni SSR.

Tuttavia, se queste soluzioni funzionano molto bene in un’applicazione monolitica tradizionale, dove un’unica app controlla l’intero DOM, diventano problematiche quando più applicazioni devono coesistere nello stesso albero DOM , come nel caso dei frammenti dell’architettura proposta.

Dal punto di vista pratico il problema è che framework come Next.js o Remix si basano su oggetti globali attaccati al documento DOM, e questi elementi definiti globalmente possono interferire con — o addirittura sovrascrivere — funzionalità di altre applicazioni renderizzate sulla stessa pagina (sul repository di Remix c’è una discussione aperta3 in cui il problema è spiegato più in dettaglio).

Poiché il problema sembra profondamente radicato nei principi di funzionamento di questi framework, non appare praticabile configurarne il comportamento o applicare un namespace a questi oggetti globali per evitare conflitti: e questo li rende poco adatti alle nostre esigenze.

Module Federation non è un’opzione valida?

Forse hai sentito parlare di Module Federation4, una funzionalità introdotta in Webpack 5 che consente a build separate di comportarsi come contenitori, esponendo e consumando codice tra loro per creare un’unica applicazione unificata.

Questa tecnologia si è già dimostrata un approccio valido per comporre app micro-frontend in Next.js a livello di build , come descritto ad esempio in questo articolo5 di Alibek.

Tuttavia, per quanto robusta, Module Federation presenta alcuni svantaggi importanti. Innanzitutto richiede una dipendenza stretta da Webpack per tutte le build dei frammenti, limitando di fatto la flessibilità che caratterizza un’architettura micro-frontend: la libertà di usare la tecnologia ottimale per ogni singolo componente.

Inoltre, poiché la composizione avviene a livello di build, si perdono la flessibilità e l’indipendenza dei singoli micro-frontend, dovendo coordinare i team per mantenere coerenza nelle interfacce e nelle tempistiche di rilascio.

Infine, la gestione delle dipendenze condivise con Module Federation può diventare spesso complessa. La difficoltà emerge in particolare quando componenti o frammenti diversi, costruiti in momenti differenti o da team differenti, dipendono da versioni divergenti o incompatibili di una stessa risorsa condivisa. Spesso questo si manifesta come bug o conflitti subdoli e difficili da tracciare, che richiedono molto tempo per essere individuati e risolti.

__wf_reserved_inherit

Implementare un server SSR personalizzato con Vite + React.js

Rendendoci conto che questi framework non potevano soddisfare adeguatamente le nostre esigenze, abbiamo analizzato i loro meccanismi SSR sottostanti per capire come funzionano “dietro le quinte” e valutare se potevamo personalizzare il comportamento SSR per implementare i nostri frammenti.

La documentazione di Vite, in particolare la sezione SSR6, è un buon punto di partenza per capire come funziona un’applicazione SSR. Poiché Remix è costruito direttamente su Vite, non è difficile credere che sotto il cofano faccia qualcosa di molto simile a quanto descritto lì.

Ecco, in breve, i passaggi da seguire:

  1. Configurare un server Node.js con un endpoint pensato specificamente per servire l’HTML del nostro micro-frontend.
  2. Integrare i middleware di Vite nel server Node.js seguendo la documentazione. Questo permette al server di gestire richieste specifiche di Vite, come il caricamento dei moduli, l’hot module reload in fase di sviluppo o il servizio di risorse ottimizzate in produzione.
  3. Implementare l’endpoint che serve lo snippet SSR eseguendo il codice definito in un modulo TypeScript caricato dinamicamente, solitamente chiamato entry-server.tsx. Questo modulo usa le Server React Node APIs come renderToPipeableStream per convertire l’app React.js in uno snippet HTML inviato nello stream della risposta del server.
  4. Come ultimo passaggio, dobbiamo assicurarci che l’HTML statico si trasformi in componenti interattivi appena raggiunge il client. Questo processo, chiamato hydration (per approfondire, qui7), avviene inserendo nello snippet HTML un tag script che avvia il download e l’esecuzione di un modulo client, tipicamente chiamato entry-clients.tsx . Questo modulo applica il metodo hydrateRoot, dando vita agli elementi HTML e rendendoli interattivi.‍

entry-server.tsx

export async function render() {
  const html = ReactDOMServer.renderToString(
    <React.StrictMode>
      <MyApp />
    </React.StrictMode>,
  )
  return { html }
}

entry-client.tsx

ReactDOM.hydrateRoot(
  document.getElementById('root')!,
  <React.StrictMode>
    <MyApp />
  </React.StrictMode>,
)

Con questo endpoint Node.js che serve il frammento, il componente padre (sia esso la pagina o un altro frammento) può specificare nel proprio HTML una direttiva SSI che verrà gestita da Nginx: questa interrogherà l’endpoint del frammento e inietterà l’HTML restituito nel padre prima di consegnarlo all’utente.

__wf_reserved_inherit

Recuperare i dati sul server

A un certo punto, inevitabilmente, abbiamo incontrato la necessità di recuperare dati prima che l’app venisse pre-renderizzata sul server. Questi dati potevano includere qualsiasi cosa: dalle informazioni utente ai dettagli di prodotto, fino ai contenuti dinamici necessari per il rendering iniziale del componente (ad esempio la localizzazione).

Il recupero dei dati in uno scenario di rendering lato client è semplice, e diverse librerie come TanStack Query aiutano a gestire le sfide tipiche.

Tuttavia, in uno scenario SSR, oltre ad avere i dati disponibili prima che inizi il rendering, dobbiamo assicurarci che i dati recuperati dal server siano accessibili anche lato client, affinché la funzione di hydrate possa comprendere e ricreare correttamente gli elementi interattivi. Un modo per ottenerlo è incorporare i dati direttamente nell’HTML generato dal server, solitamente come blob JSON. Lo script lato client può quindi prelevarli dal DOM, evitando una richiesta di rete ridondante.

async function getData() {
  const data = // fetch the needed data from external resources
  return { data }
}

export async function render() {
  const data = await getData()

  const html = ReactDOMServer.renderToString(
    <React.StrictMode>
      <MyApp data={data} />
      <script
        id="__MYAPP_DATA__"
        type="application/json"
        dangerouslySetInnerHTML={{ __html: devalue.stringify(data) }}
      />
    </React.StrictMode>,
  )
  return { html }
}
async function initApp() {
 try {
    const dehydratedData = document.getElementById('__MYAPP_DATA__')?.textContent
    return devalue.parse(dehydratedData!)
  } catch (e) {
    console.log('Failed to hydrate data', e)
    throw e
  }
}

initApp().then(data => {
  ReactDOM.hydrateRoot(
    document.getElementById('root')!,
    <React.StrictMode>
      <MyApp data={data} />
    </React.StrictMode>,
  )
})

Nota che qui usiamo https://github.com/Rich-Harris/devalue invece di JSON.stringify/JSON.parse, per proteggerci dagli attacchi XSS e avere un supporto migliore per tipi complessi come Date o Map.

Questo è un esempio di come i framework tipici si dimostrino inadeguati: allegando i dati recuperati lato server a un contesto globale del documento DOM senza applicare un namespace, più applicazioni renderizzate sullo stesso documento possono sovrascrivere i dati recuperati dalle altre, interferendo con il loro rendering.

Gestendo invece questo processo manualmente, possiamo assegnare ai nostri dati un ID univoco e specifico dell’app (vedi l’id __MYAPP_DATA__ assegnato al tag script nell’esempio sopra), rendendo possibile la coesistenza di altri micro-frontend con la nostra app e il recupero, lato client, dei soli dati di propria competenza.

__wf_reserved_inherit

Strategie aggiuntive per migliorare le prestazioni

Un potenziale problema dell’architettura discussa finora è l’inclusione ripetuta di librerie di base come React.js, il cui codice viene impacchettato e scaricato con ogni micro-frontend indipendente che le utilizza. Questo significa che più micro-frontend sviluppati con lo stesso framework richiederanno il download del codice JavaScript del framework più volte per una singola pagina, con un impatto sul tempo di caricamento iniziale.

Una possibile soluzione consiste nell’istruire i micro-frontend a non incorporare direttamente nella build finale gli script delle tecnologie condivise, come React e ReactDOM. Invece di incorporarli, i micro-frontend dovrebbero importarli da una fonte esterna, come una Content Delivery Network (CDN).

Caricando gli script esternamente, il browser può riconoscere e deduplicare le richieste di questi moduli provenienti da micro-frontend diversi, oppure sfruttare i propri meccanismi di caching per recuperarli una sola volta per pagina.

__wf_reserved_inherit

Conclusione

Abbiamo esplorato un’architettura SSR a micro-frontend versatile, mostrando come possa portare flessibilità alle applicazioni web complesse e permettere a parti costruite con tecnologie diverse di lavorare insieme senza attriti, senza sacrificare tempi di caricamento e prestazioni.

L’architettura proposta offre un’alternativa agli approcci tradizionali come Module Federation, evidenziando come possa preservare meglio l’indipendenza dei team, sia negli aspetti operativi sia nei processi critici come il ciclo di rilascio e le pipeline di build.

Se il nostro percorso nell’implementare questa architettura ti ha incuriosito e pensi di aver bisogno di supporto, non esitare a contattarci. Possiamo darti l’aiuto necessario per affrontare il processo con facilità.

Bibliografia

[1] https://micro-frontends.org/

[2] https://nginx.org/en/docs/http/ngx_http_ssi_module.html

[3] https://github.com/remix-run/remix/discussions/9156

[4] https://webpack.js.org/concepts/module-federation/

[5] https://alibek.dev/micro-frontends-with-nextjs-and-module-federation

[6] https://vitejs.dev/guide/ssr

[7] https://www.gatsbyjs.com/docs/conceptual/react-hydration/#what-is-hydration

‍

Vincenzo Guerrisi
Vincenzo Guerrisi AI Productivity Engineer and Tech Lead

Vincenzo joined Buildo in 2016 as a Fullstack Engineer, with a strong focus on Frontend technologies. He pays close attention to process improvement and team dynamics, working every day to foster effective collaboration and bring out the best in each team member.

NEWSLETTER

Ehi, c'è anche una Newslettero!
Sì, proprio con la o.

Ogni mese scegliamo articoli utili ma anche idee fresche e stimolanti. Proprio quello che vorremmo trovare noi in una newsletter!