Zurück
Tech 9 min de lecture - 5. Sept. 23 - Valentin Gerest

Next.js App Router : der Cache und seine Gefahren

“Il y a seulement 2 problèmes compliqués en informatique : nommer les choses, et l’invalidation de cache”. Phil Karlton.
Avec Next.js 13.4, l'App Router passe en version stable, 6 mois après sa sortie en beta dans Next.js 13. Dans ce nouveau système de routeur, le framework introduit de nombreux changements, dont les React Server Components, auxquels l'écosystème React commence tout juste à s'adapter. Pour maximiser leur performance, le framework a sorti l'artillerie lourde en matière de cache, avec pas moins de 4 couches différentes. Tout ce qu'il faut pour se tirer une balle dans le pied quand on ne connait pas bien leur fonctionnement.
Heureusement il y a une documentation très complète à ce sujet que je vous recommande vivement de lire EN ENTIER.
En complément, dans cet article je vous propose de découvrir ces différents types de cache à travers un exemple concret, et d'apprendre comment les désactiver au besoin.
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 :
Modell der Mitgliederkarte
Da der Komponent nicht interaktiv ist, entscheiden wir uns, ihn zu einem Server Component zu machen. Um zufällige und plausible Informationen über die Person zu erhalten, entscheiden wir uns, die API zu nutzen randomuser.me.
Wir erhalten also eine erste Version des Codes, welche wie folgt lautet:
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>
  );
}
Zugegeben, die API 2-mal aufzurufen ist nicht am effizientesten, und wir könnten Promise.all verwenden, aber wir werden diesen Code beibehalten, um unsere Cache-Probleme zu illustrieren. Wir mussten das Registrierungsjahr mit Math.random() generieren, da die API diese Information nicht bereitstellt.
Wir stossen sofort auf die erste Cache-Schicht. In der Entwicklung auf unserer Maschine ändert sich nur das Registrierungsjahr, wenn wir die Seite neu laden, und schlimmer noch, wenn wir in Produktion sind, ändert sich nichts, wenn wir neu laden.
Hier ist eine erste Sache, die man gut im Kopf behalten muss: der Cache-Verhalten ist überhaupt nicht dasselbe im Entwicklungsmodus wie im Produktionsmodus. Der Entwickler kann sich also leicht hereinlegen lassen, wenn er seinen Code nur im Entwicklungsmodus testet und ein Problem erst in der Staging- oder Produktionsumgebung bemerkt. Um potenzielle Cache-Probleme lokal zu testen, muss das Projekt mit dem Befehl `NODE_ENV="production" npm run build && NODE_ENV="production" npm run start` gestartet werden.
Dieses erste Cache-System nennt sich der Full Route Cache. Standardmässig ist unsere Seite statisch, das heisst, dass Next.js unser Komponent/unsere Seite nur ein einziges Mal rendert: zum Zeitpunkt des Builds. Das Ergebnis wird in einer statischen Datei gespeichert, die ausgeliefert wird, wenn der Benutzer auf die Seite zugreift. Die API wird auch nur zum Zeitpunkt des Builds aufgerufen. Das ist also sehr effizient, die Seite wird schnell und ohne aufwändige Operationen ausgeliefert, aber das ist überhaupt nicht das, was wir in unserem Fall wollen.
Um dieses Verhalten zu deaktivieren und unsere Seite dynamisch zu machen, gibt es mehrere Lösungen (siehe Doku). Next.js versucht, selbst zu erraten, wann unsere Seite dynamisch ist. Zum Beispiel, sobald wir auf einen searchParameter, auf Cookies oder auf Header zugreifen, versteht das Framework, dass die Seite dynamisch sein muss und deaktiviert den Full Route Cache.
In unserem Fall stellen wir uns vor, wir möchten unsere Karte in verschiedene Sprachen übersetzen können. Wir werden also den Parameter 'lang' in der URL unserer Seite lesen. (ex : https://notresite.com/membre?lang=en). Dies hat zur Folge, dass der Cache deaktiviert und unsere Route dynamisch wird.
Wir haben also den folgenden Code:
export default async function DynamicPage(props: {
  searchParams: { lang?: string }
}) {
  const lang = props.searchParams["lang"] ?? 'en';
  return <MemberCard lang={lang} ... />
}
Next.js erkennt selbst, wenn auf eine Eigenschaft von searchParams zugegriffen wird (wahrscheinlich unter Verwendung eines Proxy), und deaktiviert unseren Cache.
Leider funktioniert unser Generator immer noch nicht richtig. Dieses Mal in Produktion ändert sich das Registrierungsjahr bei jedem Neuladen, ein Beweis dafür, dass der Full Route Cache korrekt deaktiviert ist und Next.js unseren Komponent rendert, aber die Informationen der Person sind immer noch dieselben.
Dies stammt von einer anderen Form des Caches: der Data Cache Next.js hat die Funktion 'fetch' serverseitig tatsächlich modifiziert, um Anfragen abzufangen und sie im Cache zu speichern, wobei die URL als Schlüssel verwendet wird. Dieser Cache ist standardmässig aktiviert und bleibt von einer Anfrage zur nächsten bestehen und sogar wenn wir unser Projekt neu bauen und neu bereitstellen! Dieser Cache kann sehr leistungsstark sein, aber in unserem Fall ist er störend, wir möchten ihn daher gerne deaktivieren.
Dafür gibt es, wieder einmal, mehrere dokumentierte Lösungen hier. Wenn man den Cache nur bei bestimmten Anfragen deaktivieren möchte, kann man die Parameter `cache: 'no-store'` oder `next.revalidate: 0` von fetch verwenden. (Gut zu wissen: Wenn man den Cache bei nur einer der Anfragen der Seite deaktiviert, wird diese automatisch dynamisch.) Man kann den Data Cache auch für eine ganze Route mit `export const dynamic="force-dynamic"` oder `export const revalidate = 0` deaktivieren.
Schliesslich kann man diesen Cache manuell invalidieren, indem man die Option next.tags de fetch et revalidateTag. Wenn Sie zum Beispiel Contentful verwenden, um Ihre Artikel zu verfassen und diese auf Ihrem Next.js-Blog anzeigen, können Sie das System von Webhook de Contentful in Kombination mit revalidateTag, um die Next.js-Seite Ihres Artikels zu aktualisieren, sobald Sie ihn auf Contentful ändern.
Um auf unseren Fall zurückzukommen, um den Data Cache zu deaktivieren, werden wir den folgenden Code verwenden:
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];
};
Wir testen unseren Code in Produktion und... ja, dieses Mal haben wir tatsächlich bei jedem Laden unterschiedliche Daten! Nur seltsamerweise haben wir zweimal denselben Vornamen auf unserer Mitgliederkarte... wir fetchen doch zwei verschiedene Benutzer in unserem Code, wir sollten zwei verschiedene Vornamen haben...
Illustration zweimal derselbe Vorname
Und ja, es ist noch ein Cache-Typ im Spiel: es ist die Request Memoization. Diesmal handelt es sich um eine Funktionalität der React Components und nicht von Next.js. Zusätzlich zum Data Cache von Next.js implementiert React einen Cache, der nur für die Dauer einer Anfrage besteht und nur dann angewendet wird, wenn eine Anfrage in einem Server Component ausgeführt wird. Wenn wir getRandomUser zum ersten Mal aufrufen, rufen wir die API randomuser.me mit fetch auf, und React speichert das Ergebnis im Cache, dann gibt React beim zweiten Mal das Ergebnis der ersten Anfrage zurück, wodurch eine oft unnötige Anfrage vermieden wird. In unserem Fall stellt dies ein Problem dar, und wieder einmal gibt uns die Dokumentation eine Lösung, um diesen Cache zu deaktivieren. Hier ist also unser neuer Code:
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];
};
Gut, dieses Mal ist es in Ordnung, jedes Mal, wenn wir unsere Seite neu laden, haben wir tatsächlich einen anderen Benutzer mit den korrekten Daten! Aber wir bemerken ein seltsames kleines Detail: Wenn wir auf eine andere Seite unserer Website gehen und dann auf unsere zufällige Mitgliederseite zurückkehren, ist es derselbe Benutzer wie beim Verlassen dieser Seite. Was passiert da?
Nun, wenn man die Seite ordentlich wechselt, indem man die Link-Komponente oder die navigate-Funktion von Next.js verwendet, wechselt man die Seite nicht wirklich. Das Framework lädt lediglich den Server Component, der der neuen Seite entspricht, und rendert ihn, es aktualisiert also einfach einen Teil des DOMs: das nennt man die soft-navigation.
Wenn Next.js den Server Component der Seite lädt, speichert es ihn im Cache und verwendet diesen Server Component wieder, wenn man auf die Seite zurückkehrt: das ist der Router Cache.
Es gibt nicht wirklich einen Parameter, um diesen Cache-Typ zu deaktivieren. Man kann ihn jedoch manuell mit `useRouter().refresh()` invalidieren (Achtung : useRouter de 'next/navigation', pas 'next/router') et bientôt avec les server actions.
Wenn man zum Beispiel einen Button hinzufügen möchte, um einen neuen Benutzer zu generieren, könnte man den folgenden Client Component verwenden:
"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>
  );
};
Und damit haben wir die 4 wichtigsten Cache-Formen des App Routers abgedeckt.

Route handler caching

Ich möchte nun noch einen letzten Punkt ansprechen: den Cache mit den Route Handlers. Es gibt ein Cache-System, das nur für GET-Anfragen gilt und das ganz anders funktioniert als das, was wir oben gesehen haben.
Es gibt eine Dokumentation über das Cache-System der Route Handlers, aber sie ist weniger detailliert und ich musste einige Tests durchführen, um richtig zu verstehen, wie es funktioniert.
Es gibt mehrere Punkte zu beachten:
  1. Standardmässig sind GET-Anfragen statisch! Sie werden nur zur Build-Zeit ausgeführt und für die gesamte Dauer der Anwendung im Cache gespeichert.
  2. Standardmässig gibt es ein System von Data Cache das gilt nur zur Build-Zeit. Wenn Sie einen ersten Build durchführen, werden die mit fetch abgerufenen Daten im Cache gespeichert, und der nächste Build wird diese Daten nicht aktualisieren. Um das Data Cache zur Build-Zeit, können Sie `cache: "no-store"` verwenden.
  3. Sie können Ihre Anfrage dynamisch gestalten, indem Sie den Request-Parameter Ihres Handlers (req.headers.get('referer'), req.url, req.cookies, etc...) verwenden, und dies wird Ihre Route automatisch dynamisch machen. Sie können auch die Option `export const dynamic = "force-dynamic"` oder `export const revalidate = 0` verwenden.
  4. Achtung, die Option `{ cache: "no-store" }` von fetch wird Ihre Route nicht dynamisch machen!
  5. Achtung, aktuell (Next.js 13.4.19), `{ next: { revalidate: 0 } }` funktioniert nicht! Der Wert Null scheint als undefined behandelt zu werden, anstatt den Cache zu deaktivieren. Wahrscheinlich ein Bug. Dagegen scheint `revalidate: 1` gut zu funktionieren.
  6. Es gibt keinen Data Cache zur Laufzeit ! Es gibt auch keine Request Memoization. Wenn Ihre Route dynamisch ist, werden alle Fetches eine Netzwerkanfrage stellen.
Also entweder ist Ihre Route vollständig statisch, oder sie ist vollständig dynamisch.

Fazit

Next.js bietet viele verschiedene Cache-Typen - Full Route Cache, Data Cache, Request Memoization und Router Cache - die es essenziell ist, gut zu verstehen. Es ist wichtig zu bedenken, dass sich der Cache nicht auf die gleiche Weise verhält im Development-Modus wie im Production-Modus. Das Cache-System der Route Handler ist ebenfalls ziemlich anders und überraschend in seiner Funktionsweise. Es ist daher wichtig, die hervorragende Dokumentation von Next.js genau zu lesen und sich mit seinen neuen Funktionen vertraut zu machen.

Möchten Sie bei der Lancierung Ihres digitalen Projekts begleitet werden?

Reichen Sie Ihr Projekt jetzt ein