Zum Inhalt springen

Was `use client` Sie wirklich kostet

Die Client-Grenze ist keine Performance-Einstellung, sie ist eine Bundle-Grenze. So finden Sie heraus, wohin Ihre nach oben gewandert ist — und was es bringt, sie zurückzuschieben.

4 Min. Lesezeit

use client bedeutet nicht „diese Komponente läuft im Client". Jede Komponente einer Next.js-Anwendung wird mindestens einmal auf dem Server gerendert.

Was use client tatsächlich erklärt, ist eine Grenze: von diesem Modul abwärts wird alles in das JavaScript-Bundle kompiliert und an den Browser ausgeliefert, damit es hydratisieren kann. Das ist der Preis. Nicht das Rendern — das Ausliefern.

Sobald man diese Definition im Kopf hat, werden die üblichen Fehler offensichtlich.

Das Abdriften nach oben

Grenzen wandern zur Wurzel, eine vernünftige Entscheidung nach der anderen.

Das Design wünscht sich ein Dropdown im Header. Der Header braucht useState, also kommt use client an den Anfang von Header.tsx. Der Header importiert Nav, das NavItem importiert, das eine formatDate-Hilfsfunktion importiert, die eine Datumsbibliothek importiert. Nichts davon musste interaktiv sein. Alles davon liegt jetzt im Bundle, auf jeder Seite, für jeden Besucher.

Sechs Monate später ist das Layout eine Client-Komponente und niemand weiß mehr, warum.

Die eigene Grenze finden

Die Build-Ausgabe verrät, wo es schiefging. Sehen Sie sich First Load JS pro Route an:

Route (app)                      Size  First Load JS
┌ ○ /                          1.2 kB         184 kB
├ ○ /about                     0.8 kB         184 kB
└ ○ /pricing                   2.1 kB         186 kB

Wenn jede Route dieselbe große Grundlast trägt, liegt das Gewicht im gemeinsamen Layout, nicht in den Seiten. Das ist die Signatur einer Client-Grenze, die in den Layout-Baum geklettert ist.

Für die Details lassen Sie den Bundle-Analyzer laufen:

npm install --save-dev @next/bundle-analyzer
ANALYZE=true npm run build

Sie suchen zwei Dinge: Bibliotheken, die Sie nicht erwartet haben, und Bibliotheken, die im gemeinsamen Chunk auftauchen, obwohl sie im Chunk einer einzelnen Route gehörten.

Die Grenze nach unten schieben

Die Lösung hat fast immer dieselbe Form — das interaktive Blatt bleibt eine Client-Komponente, alles darum herum bleibt auf dem Server.

Statt dessen:

'use client';
 
import { heavyMarkdownRenderer } from 'some-large-lib';
 
export function Article({ content, comments }) {
  const [showComments, setShowComments] = useState(false);
 
  return (
    <article>
      {heavyMarkdownRenderer(content)}
      <button onClick={() => setShowComments(true)}>Comments</button>
      {showComments ? <Comments data={comments} /> : null}
    </article>
  );
}

so:

// Article.tsx — server component, no 'use client'
import { heavyMarkdownRenderer } from 'some-large-lib';
import { CommentsToggle } from './CommentsToggle';
 
export function Article({ content, comments }) {
  return (
    <article>
      {heavyMarkdownRenderer(content)}
      <CommentsToggle>
        <Comments data={comments} />
      </CommentsToggle>
    </article>
  );
}
// CommentsToggle.tsx — the only client component
'use client';
 
export function CommentsToggle({ children }: { children: ReactNode }) {
  const [open, setOpen] = useState(false);
 
  return (
    <>
      <button onClick={() => setOpen(true)}>Comments</button>
      {open ? children : null}
    </>
  );
}

Die Markdown-Bibliothek bleibt auf dem Server. Comments bleibt eine Server-Komponente, obwohl sie innerhalb einer Client-Komponente gerendert wird — weil sie als children übergeben wird, war sie bereits auf dem Server gerendert und kommt als serialisierte Ausgabe an, nicht als Code.

Children, die durch eine Client-Komponente gereicht werden, überqueren die Grenze nicht. Diese eine Regel löst die meisten Fälle auf, in denen Teams glauben, sie müssten zwingend auf die Client-Seite wechseln.

Warum das im INP auftaucht, nicht nur in der Ladezeit

Interaction to Next Paint misst, wie lange der Haupt-Thread braucht, um zu reagieren, wenn eine Besucherin etwas antippt. Jedes Kilobyte JavaScript im Bundle wird geparst, kompiliert und ausgeführt, bevor die Seite auf irgendetwas reagieren kann, und die Hydratation konkurriert mit dem ersten Tippen.

Auf einem Android-Gerät der Mittelklasse — also dem Gerät des größten Teils der Welt — sind 300 KB JavaScript grob eine Sekunde Arbeit im Haupt-Thread, bevor die Hydratation fertig ist. Ein Tippen in dieser Sekunde landet in der Warteschlange. Das ist ein verfehltes INP, und keine noch so gute CSS-Arbeit repariert es.

Server Components sind in erster Linie keine Rendering-Optimierung. Sie sind eine Art, den Code gar nicht erst zu senden.

Was keine Client-Komponente sein muss

Aus den Audits, die wir durchführen, die wiederkehrenden Fehlalarme:

  • Datums- und Zahlenformatierung. Intl funktioniert auf dem Server. Geht es um die Zeitzone der Besucherin, reichen Sie die formatierte Zeichenkette nach unten durch oder rendern Sie ein <time>-Element mit dateTime-Attribut und überlassen die Anzeige CSS oder einer winzigen Blattkomponente.
  • Ein Theme lesen. Das data-theme-Attribut plus CSS erledigt das ohne JavaScript.
  • Daten beim Mounten laden. Sind sie nicht nutzerspezifisch, laden Sie sie auf dem Server. Sind sie es, laden Sie sie auf dem Server hinter Suspense.
  • Analytics. Laden Sie sie in einem <Script> mit strategy="afterInteractive" oder "lazyOnload", nicht in einer Komponente.
  • Icon-Bibliotheken. Icons sind Markup. Importieren Sie das einzelne SVG, nicht die Barrel-Datei, die zweitausend davon hereinzieht.

Die Review-Regel, die wir anwenden

In jeder Codebasis, in der wir arbeiten, wird use client behandelt wie ein dangerously-Präfix: erlaubt, manchmal richtig, und es besteht nie ein Review ohne einen Satz, der erklärt, warum die Grenze genau in diese Datei gehört und nicht eine Ebene tiefer.

Diese eine Konvention ist mehr wert als jedes Bundle-Size-Werkzeug, weil sie das Problem in dem Moment abfängt, in dem es entsteht, statt achtzehn Monate später, wenn endlich jemand den Analyzer öffnet.

Zurück zu allen Artikeln