Ritorno
Tech 9 min di lettura - 5 set. 23 - Valentin Gerest

Next.js App Router : la cache e i suoi pericoli

“Ci sono solo 2 problemi complicati in informatica: nominare le cose, e l'invalidazione della cache”. Phil Karlton.
Con Next.js 13.4, l'App Router passa in versione stabile, 6 mesi dopo la sua uscita in beta in Next.js 13. In questo nuovo sistema di router, il framework introduce numerosi cambiamenti, inclusi i React Server Components, ai quali l'ecosistema React sta appena iniziando ad adattarsi. Per massimizzare le loro prestazioni, il framework ha tirato fuori l'artiglieria pesante in materia di cache, con non meno di 4 strati diversi. Tutto ciò che serve per darsi la zappa sui piedi quando non si conosce bene il loro funzionamento.
Fortunatamente c'è una documentazione molto completa a questo proposito che ti raccomando vivamente di leggere INTERAMENTE.
In aggiunta, in questo articolo ti propongo di scoprire questi diversi tipi di cache attraverso un esempio concreto, e di imparare come disattivarli al bisogno.
Dans notre scénario, on nous demande de développer un composant qui affiche une carte membre associative d'un personnage fictif aléatoire. À chaque chargement de page, le personnage affiché doit être différent. En plus de son nom et prénom, on nous fait la demande un peu farfelue d'afficher son second prénom (middle-name en anglais). On veut également afficher l'année depuis laquelle le personnage est membre du site, son email et son téléphone. Voici à quoi ressemblerait la carte membre :
Mockup della carta membro
Dato che il componente non è interattivo, decidiamo di renderlo un Server Component. Per ottenere informazioni casuali e plausibili sul personaggio, scegliamo di usare l'API randomuser.me.
Otteniamo quindi una prima versione del codice che segue:
import { MemberCard } from "@/components/MemberCard";

const getRandomUser = async () => {
  const res = await fetch(
    "https://randomuser.me/api/?nat=fr&gender=female"
  );
  const { results } = await res.json();
  return results[0];
};

export default async function StaticPage() {
  const subscriptionYear = 1999 + Math.round(Math.random() * 25);
  const user1 = await getRandomUser();
  const user2 = await getRandomUser();
  return (
    <div className="p-6">
      <MemberCard
        firstName={user1.name.first}
        middleName={user2.name.first}
        lastName={user1.name.last}
        email={user1.email}
        phoneNumber={user1.phone}
        profilePictureUrl={user1.picture.large}
        subscriptionYear={subscriptionYear}
      />
    </div>
  );
}
Certo, chiamare l'API 2 volte non è il modo più efficiente, e potremmo usare Promise.all, ma manterremo questo codice per illustrare i nostri problemi di cache. Abbiamo dovuto generare l'anno di iscrizione con Math.random() perché l'API non dispone di questa informazione.
Incontriamo subito il primo livello di cache. In fase di sviluppo sulla nostra macchina, solo l'anno di iscrizione cambia quando ricarichiamo la pagina, e peggio ancora, quando siamo in produzione, nulla cambia al ricaricamento.
Ecco una prima cosa da tenere a mente: il comportamento della cache non è affatto lo stesso in modalità dev che in modalità production. Lo sviluppatore può quindi cadere facilmente in trappola se testa il suo codice solo in modalità dev e si accorge di un problema solo in staging o in produzione. Per testare potenziali problemi di cache in locale, devi avviare il progetto con il comando `NODE_ENV="production" npm run build && NODE_ENV="production" npm run start`.
Questo primo sistema di cache si chiama Full Route Cache. Per impostazione predefinita, la nostra pagina è statica, cioè che Next.js esegue il rendering del nostro componente/pagina una sola e unica volta: al momento del build. Il risultato viene memorizzato in un file statico che viene servito quando l'utente accede alla pagina. Chiamiamo l'API solo al momento del build. È quindi molto efficiente, la pagina viene servita rapidamente e senza operazioni costose, ma non è affatto ciò che vogliamo nel nostro caso.
Per disabilitare questo comportamento e rendere la nostra pagina dinamica, ci sono diverse soluzioni (vedi la documentazione). Next.js cerca di capire da solo quando la nostra pagina è dinamica. Ad esempio, non appena accediamo a un searchParameter, ai cookie o agli header, il framework capisce che la pagina deve essere dinamica e disabilita il Full Route Cache.
Nel nostro caso, immaginiamo di voler poter tradurre la nostra carta in diverse lingue. Leggeremo quindi il parametro 'lang' nell'url della nostra pagina. (ex : https://notresite.com/membre?lang=en). Ciò ha l'effetto di disabilitare la cache e rendere la nostra route dinamica.
Abbiamo quindi il seguente codice:
export default async function DynamicPage(props: {
  searchParams: { lang?: string }
}) {
  const lang = props.searchParams["lang"] ?? 'en';
  return <MemberCard lang={lang} ... />
}
Next.js rileva automaticamente quando si accede a una proprietà di searchParams (probabilmente usando un Proxy), e disabilita la nostra cache.
Sfortunatamente il nostro generatore non funziona ancora correttamente. Questa volta in produzione, l'anno di iscrizione cambia ad ogni ricaricamento, prova che il Full Route Cache è effettivamente disabilitato e che Next.js esegue il rendering del nostro componente, ma le informazioni del personaggio sono sempre le stesse.
Questo deriva da un'altra forma di cache: il Data Cache Next.js ha infatti modificato la funzione 'fetch' lato server per intercettare le richieste e metterle in cache, usando l'url come chiave. Questa cache è attiva per impostazione predefinita e persiste da una richiesta all'altra e anche quando ricostruiamo e ridistribuiamo il nostro progetto! Questa cache può essere molto potente ma nel nostro caso è fastidiosa, ci piacerebbe quindi disabilitarla.
Per questo, ancora una volta, ci sono diverse soluzioni, documentate qui. Se vuoi disabilitare la cache solo su alcune richieste, puoi usare i parametri `cache: 'no-store'` o `next.revalidate: 0` di fetch. (Buono a sapersi: se disattivi la cache su una sola delle richieste della pagina, questa diventerà automaticamente dinamica.) Puoi anche disabilitare la Data Cache su un'intera route con `export const dynamic="force-dynamic"`, oppure `export const revalidate = 0`.
Infine, si può invalidare manualmente questa cache usando l'opzione next.tags de fetch et revalidateTag. Se, per esempio, utilizzi Contentful per redigere i tuoi articoli e li visualizzi sul tuo blog Next.js, puoi usare il sistema di Webhook di Contentful in combinazione con revalidateTag per aggiornare la pagina Next.js del tuo articolo non appena lo modifichi su Contentful.
Per tornare al nostro caso, per disabilitare la Data Cache, useremo il seguente codice:
const getRandomUser = async () => {
  const res = await fetch(
    "https://randomuser.me/api/?nat=fr&gender=male&test=3",
    {
      cache: "no-store",
      // next: { revalidate: 0 }, // has similar effect
    }
  );
  const { results } = await res.json();
  return results[0];
};
Testiamo il nostro codice in produzione e... sì, questa volta abbiamo dati diversi ad ogni caricamento! Solo che stranamente abbiamo due volte lo stesso nome nella nostra carta membro... pur recuperando due utenti diversi nel nostro codice, dovremmo avere due nomi diversi...
Illustrazione due volte lo stesso nome
E sì, c'è ancora un tipo di cache in azione: è la Request Memoization. Questa volta si tratta di una funzionalità dei React Components e non di Next.js. Oltre alla Data Cache di Next.js, React implementa una cache che dura solo per la durata di una richiesta e che si applica solo se una richiesta viene effettuata in un Server Component. La prima volta che si richiama getRandomUser, si chiama l'API randomuser.me con fetch e React mette in cache il risultato, poi la seconda volta React restituisce il risultato della prima richiesta, evitando una richiesta spesso inutile. Nel nostro caso questo crea un problema e ancora una volta la documentazione ci fornisce una soluzione per disabilitare questa cache. Ecco quindi il nostro nuovo codice:
const { signal } = new AbortController();
const getRandomUser = async () => {
  const res = await fetch(
    "https://randomuser.me/api/?nat=fr&gender=female&test=2",
    {
      signal,
      cache: "no-store",
    }
  );
  const results = await res.json();
  return results.results[0];
};
Bene, questa volta è tutto a posto, ogni volta che ricarichiamo la nostra pagina, abbiamo effettivamente un utente diverso con i dati corretti! Ma notiamo un piccolo dettaglio strano: quando andiamo su un'altra pagina del nostro sito e torniamo alla nostra pagina membro casuale, è lo stesso utente di quando abbiamo lasciato questa pagina. Cosa succede?
Ebbene, quando si cambia pagina correttamente usando il componente Link o la funzione navigate di Next.js, non si cambia davvero pagina. Il framework caricherà semplicemente il Server Component corrispondente alla nuova pagina e ne eseguirà il rendering, aggiornando quindi semplicemente una parte del DOM: è quella che chiamiamo la soft-navigation.
Ora, quando Next.js carica il Server Component della pagina, lo mette in cache e riutilizza questo Server Component quando si torna alla pagina: è il Router Cache.
Non esiste un vero parametro per disabilitare questo tipo di cache. Tuttavia, lo si può invalidare manualmente con `useRouter().refresh()` (attenzione : useRouter di 'next/navigation', non 'next/router') e presto con le server actions.
Ad esempio, se vuoi aggiungere un pulsante per generare un nuovo utente, potresti usare il seguente client component:
"use client";
import { useRouter } from "next/navigation";

export const RefreshButton = () => {
  const router = useRouter();
  return (
    <button
      className="bg-sky-500 rounded-md text-white active:bg-sky-700 px-3 py-2"
      onClick={() => router.refresh()}
    >
      REFRESH
    </button>
  );
};
E con questo abbiamo coperto le 4 forme principali di cache dell'App Router.

Route handler caching

Voglio ora affrontare solo un ultimo punto: la cache con i Route Handlers. C'è un sistema di cache che si applica unicamente alle richieste GET, e che funziona in modo piuttosto diverso da quanto abbiamo visto sopra.
Esiste una documentazione sul sistema di cache dei route handlers, ma è meno dettagliata e ho dovuto fare parecchi test per capire bene come funziona.
Ci sono diversi punti da notare:
  1. Per impostazione predefinita, le richieste GET sono statiche! Sono eseguite solo al build time e messe in cache per tutta la durata dell'applicazione.
  2. Per impostazione predefinita, c'è un sistema di Data Cache che si applica solo al momento del build. Se fai un primo build, i dati recuperati con fetch saranno messi in cache, e il build successivo non aggiornerà questi dati. Per disattivare il Data Cache al build time, puoi usare `cache: "no-store"`.
  3. Puoi rendere la tua richiesta dinamica usando il parametro Request del tuo handler (req.headers.get('referer'), req.url, req.cookies, ecc...) e questo renderà la tua route automaticamente dinamica. Puoi anche usare l'opzione `export const dynamic = "force-dynamic"` o `export const revalidate = 0`.
  4. Attenzione l'opzione `{ cache: "no-store" }` di fetch non renderà la tua route dinamica!
  5. Attenzione, attualmente (Next.js 13.4.19), `{ next: { revalidate: 0 } }` non funziona! Il valore zero sembra essere trattato come un undefined invece di disabilitare la cache. Probabilmente un bug. Invece `revalidate: 1` sembra funzionare bene.
  6. Non c'è Data Cache al Runtime ! Non c'è nemmeno Request Memoization. Se la tua route è dinamica, tutti i fetch faranno una richiesta di rete.
Quindi o la tua route è completamente statica, o è completamente dinamica.

Conclusione

Next.js offre molti tipi di cache diversi - Full Route Cache, Data Cache, Request Memoization e Router Cache - che è essenziale comprendere bene. Bisogna tenere a mente che la cache non si comporta allo stesso modo in modalità development che in modalità production. Il sistema di cache dei route handlers è anche abbastanza diverso e sorprendente nel suo funzionamento. È quindi importante leggere attentamente l'eccellente documentazione di Next.js e familiarizzare con le sue nuove funzionalità.

Desideri essere accompagnato per lanciare il tuo progetto digitale ?

Invia il tuo progetto ora