Optimierung von Datenabruf und Caching mit React Server Components
Wenhao Wang
Dev Intern · Leapcell

Einleitung
In der sich ständig weiterentwickelnden Landschaft der Frontend-Entwicklung bleibt das Streben nach schnelleren, effizienteren und benutzerfreundlicheren Webanwendungen von größter Bedeutung. Traditionelles clientseitiges Rendering (CSR) stößt oft auf Herausforderungen bei der Leistung der anfänglichen Seitenladung, SEO und komplexen Datenabhängigkeiten. Serverseitiges Rendering (SSR) bietet eine gewisse Erleichterung, delegiert aber oft erhebliche Datenabrufanforderungen nach dem anfänglichen Rendern zurück an den Client. Hier kommen React Server Components (RSC) als transformatives Paradigma ins Spiel. RSCs führen einen neuartigen Ansatz für Rendering und Datenmanagement ein, der mehr Renderingarbeit auf den Server verlagert und grundlegend verändert, wie wir über Datenabruf und Caching denken. Dieser Wandel verspricht erhebliche Leistungssteigerungen und vereinfacht die Anwendungsarchitektur, was es für moderne Entwickler unerlässlich macht, seine Auswirkungen zu verstehen. Dieser Artikel wird sich mit den Datenabrufmuster und Caching-Strategien befassen, die durch RSCs ermöglicht werden, und praktische Einblicke und Codebeispiele liefern, um ihre Leistungsfähigkeit zu demonstrieren.
Kernkonzepte
Bevor wir uns mit den Besonderheiten des Datenabrufs und Caching befassen, ist es wichtig, einige Kernkonzepte zu verstehen, die für RSCs wesentlich sind:
-
React Server Components (RSC): Ausschließlich auf dem Server gerendert, ermöglichen RSCs Entwicklern den direkten Zugriff auf serverseitige Ressourcen wie Datenbanken oder Dateisysteme, ohne sensible Anmeldeinformationen an den Client weiterzugeben. Sie rendern eine spezielle Nutzlast (nicht direkt HTML), die Anweisungen für den Client enthält, die Benutzeroberfläche zu rekonstruieren. Sie sind leichtgewichtig, haben keinen Status oder Lebenszyklusmethoden und sind für statische und dynamische Inhalte konzipiert, die keine clientseitige Interaktivität erfordern, bis sie angefordert werden.
-
Client Components (CC): Dies sind traditionelle React-Komponenten, die im Browser ausgeführt werden. Sie haben Zugriff auf Browser-APIs, Status und Effekte. Wenn sie in einem RSC-Baum verwendet werden, werden sie als Platzhalter behandelt, die auf dem Client hydriert werden.
-
"use client"
Direktive: Diese spezielle Kommentar am Anfang einer Datei kennzeichnet eine Komponente explizit als Client Component. Ohne diese Direktive werden alle Komponenten innerhalb einer React-Anwendung in einer RSC-Umgebung standardmäßig als Server Components betrachtet. -
Suspense: Eine React-Funktion, die es Ihnen ermöglicht, Teile Ihrer Benutzeroberfläche erst dann zu rendern, wenn bestimmte Bedingungen erfüllt sind, typischerweise beim Abrufen von Daten. Im Kontext von RSC spielen Suspense und die Behandlung asynchroner Daten eine entscheidende Rolle beim Streaming von Antworten.
-
Server Actions: Ein neues Primitiv, das in React eingeführt wurde und es Ihnen ermöglicht, serverseitigen Code direkt von einer Client Component (oder sogar einer anderen Server Component) durch einen einfachen Funktionsaufruf auszuführen. Dies ermöglicht nahtlose Mutationsmuster und Formularübermittlungen ohne explizite API-Aufrufe.
Datenabrufmuster in RSC
RSC vereinfacht den Datenabruf radikal, indem es Ihnen ermöglicht, Daten direkt in der Komponente abzurufen, die diese Daten am dringendsten benötigt. Dies eliminiert das "Wasserfallproblem", das häufig beim clientseitigen Abruf auftritt, bei dem Komponenten Daten seriell abrufen könnten, was zu Verzögerungen führt.
Direkter serverseitiger Datenzugriff
Das geradlinigste Muster ist der direkte Datenabruf innerhalb Ihrer Server Component. Da RSCs auf dem Server ausgeführt werden, haben sie direkten Zugriff auf serverseitige Ressourcen.
// app/page.tsx (Server Component) import { Product } from '@/lib/types'; import { getProducts } from '@/lib/db'; // Eine serverseitige Funktion zum Abrufen von Produkten export default async function HomePage() { const products: Product[] = await getProducts(); // Direkter serverseitiger Datenabruf return ( <div> <h1>Unsere Produkte</h1> <ul> {products.map((product) => ( <li key={product.id}>{product.name} - ${product.price}</li> ))} </ul> </div> ); } // lib/db.ts (Beispiel für serverseitigen Datenabruf) interface Product { id: string; name: string; price: number; } export async function getProducts(): Promise<Product[]> { // Simuliert einen Datenbankaufruf await new Promise(resolve => setTimeout(resolve, 500)); return [ { id: '1', name: 'Laptop', price: 1200 }, { id: '2', name: 'Tastatur', price: 75 }, { id: '3', name: 'Maus', price: 25 }, ]; }
In diesem Beispiel ruft die HomePage
Server Component direkt getProducts()
auf, waseine Datenbankabfrage oder ein interner API-Aufruf sein könnten. Die Daten sind streng auf dem Server verfügbar, bevor die Komponente gerendert wird.
Kolloziertes Daten-Fetching
Dieses Muster erweitert den direkten serverseitigen Zugriff, indem es jeder Server Component ermöglicht, nur die benötigten Daten abzurufen. Dieser feingranulare Datenabruf verhindert übermäßigen Abruf und vereinfacht die Verwaltung von Datenabhängigkeiten.
// app/products/[id]/page.tsx (Server Component) import { Product } from '@/lib/types'; import { getProductById } from '@/lib/db'; import ProductDetailsClient from '@/components/ProductDetailsClient'; // Eine Client-Komponente export default async function ProductPage({ params }: { params: { id: string } }) { const product: Product | null = await getProductById(params.id); if (!product) { return <p>Produkt nicht gefunden.</p>; } return ( <div> <h1>{product.name}</h1> <ProductDetailsClient product={product} /> {/* An server-seitig abgerufene Daten zu Client-Komponente übergeben */} </div> ); } // components/ProductDetailsClient.tsx "use client"; import { Product } from '@/lib/types'; import { useState } from 'react'; interface ProductDetailsClientProps { product: Product; } export default function ProductDetailsClient({ product }: ProductDetailsClientProps) { const [quantity, setQuantity] = useState(1); return ( <div> <p>Preis: ${product.price}</p> <p>{product.description}</p> <button onClick={() => setQuantity(q => q + 1)}>In den Warenkorb ({quantity})</button> </div> ); }
Hier ruft die ProductPage
Komponente spezifische Produktdetails vom Server ab und übergibt diese Daten dann an ProductDetailsClient
für interaktive Elemente. Die ProductDetailsClient
selbst muss keine Daten abrufen.
Streaming mit Suspense
Für Komponenten, die Daten abrufen, dauert das Rendern Zeit. Suspense ermöglicht es Ihnen, eine Fallback-Benutzeroberfläche anzuzeigen, während Daten abgerufen werden, und dann den eigentlichen Inhalt zu streamen, wenn er bereit ist. Dies verbessert die wahrgenommene Leistung Ihrer Anwendung.
// app/dashboard/page.tsx (Server Component) import { Suspense } from 'react'; import UserProfile from '@/components/UserProfile'; import RecentOrders from '@/components/RecentOrders'; export default function DashboardPage() { return ( <div> <h1>Dashboard</h1> <Suspense fallback={<p>Benutzerprofil wird geladen...</p>}> <UserProfile /> </Suspense> <Suspense fallback={<p>Kürzliche Bestellungen werden geladen...</p>}> <RecentOrders /> </Suspense> </div> ); } // components/UserProfile.tsx (Server Component) import { getUserData } from '@/lib/api'; // Serverseitiger Datenabruf export default async function UserProfile() { const user = await getUserData(); // Langsamen Datenabruf simulieren return ( <div> <h2>Willkommen, {user.name}!</h2> <p>E-Mail: {user.email}</p> </div> ); } // components/RecentOrders.tsx (Server Component) import { getRecentOrders } from '@/lib/api'; // Serverseitiger Datenabruf export default async function RecentOrders() { const orders = await getRecentOrders(); // Langsamen Datenabruf simulieren return ( <div> <h2>Ihre letzten Bestellungen</h2> <ul> {orders.map(order => ( <li key={order.id}>{order.item} - ${order.price}</li> ))} </ul> </div> ); } // lib/api.ts (Beispiel für serverseitige API-Aufrufe) interface User { name: string; email: string; } interface Order { id: string; item: string; price: number; } export async function getUserData(): Promise<User> { await new Promise(resolve => setTimeout(resolve, 1500)); // Verzögerung simulieren return { name: 'Alice', email: 'alice@example.com' }; } export async function getRecentOrders(): Promise<Order[]> { await new Promise(resolve => setTimeout(resolve, 2000)); // Verzögerung simulieren return [ { id: 'a1', item: 'Buch', price: 30 }, { id: 'a2', item: 'Stift', price: 5 }, ]; }
Hier rufen UserProfile
und RecentOrders
Daten gleichzeitig ab. Dank Suspense
werden anfänglich die "Laden..."-Meldungen angezeigt, und jede Komponente streamt ihren Inhalt unabhängig ein, sobald ihre Daten bereit sind.
Caching-Strategien mit RSC
Caching ist entscheidend für die Leistung, und RSC integriert sich nahtlos in die integrierten Caching-Mechanismen von React, insbesondere durch die Verwendung der fetch
-API und des React-Memoization-Mechanismus.
Automatische Anforderungsdeduplizierung und Caching (mit fetch
)
Wenn die native fetch
-API in Server Components verwendet wird, dedupliziert React (insbesondere in Frameworks wie dem Next.js App Router) automatisch Anfragen und speichert Antworten im Cache. Das bedeutet, wenn mehrere Komponenten auf dem Server dieselbe URL mit denselben Optionen anfordern, wird fetch
die Anfrage nur einmal ausführen und das Ergebnis teilen.
// lib/api.ts export async function fetchPosts() { // Wenn mehrmals mit derselben URL und denselben Optionen aufgerufen, wird dies dedupliziert. const res = await fetch('https://jsonplaceholder.typicode.com/posts'); if (!res.ok) throw new Error('Posts konnten nicht abgerufen werden'); return res.json(); } // app/blog/page.tsx (Server Component) import { fetchPosts } from '@/lib/api'; import PostsList from '@/components/PostsList'; export default async function BlogPage() { const posts = await fetchPosts(); // Dieser Aufruf wird im Cache gespeichert return ( <div> <h1>Blog-Beiträge</h1> <PostsList posts={posts} /> </div> ); } // app/components/RelatedPosts.tsx (Server Component) import { Like } from '@/lib/types'; import { fetchPosts } from '@/lib/api'; // Dies greift auf den Cache zu, wenn bereits aufgerufen export default async function RelatedPosts() { const allPosts = await fetchPosts(); // Dieser spezifische Aufruf wird die gecachte Promise wiederverwenden // Logik zum Filtern verwandter Beiträge const relatedPosts = allPosts.slice(0, 3); return ( <div> <h2>Verwandte Beiträge</h2> <ul> {relatedPosts.map(post => ( <li key={post.id}>{post.title}</li> ))} </ul> </div> ); }
In diesem Beispiel, wenn sowohl BlogPage
als auch RelatedPosts
(oder jede andere Server Component) fetchPosts()
während desselben Renderzyklus aufrufen, wird die tatsächliche Netzwerkanforderung für https://jsonplaceholder.typicode.com/posts
nur einmal gemacht.
cache()
für benutzerdefinierten Datenabruf und Memoization
Für Datenabrufe, die fetch
nicht verwenden (z. B. direkte Datenbankaufrufe, GraphQL-Clients oder andere Drittanbieter-Bibliotheken), stellt React eine cache()
-Funktion aus react
bereit, die es Ihnen ermöglicht, die Ergebnisse von Funktionen auf dem Server manuell zu deduplizieren und zu cachen.
// lib/db.ts import { cache } from 'react'; import { User } from '@/lib/types'; // getUser-Funktion memoizifizieren export const getUser = cache(async (userId: string): Promise<User | null> => { // Simuliert einen Datenbankaufruf await new Promise(resolve => setTimeout(resolve, 300)); if (userId === '123') return { id: '123', name: 'Jane Doe', email: 'jane@example.com' }; return null; }); // app/profile/page.tsx (Server Component) import { getUser } from '@/lib/db'; import UserProfileDetails from '@/components/UserProfileDetails'; export default async function ProfilePage() { const user = await getUser('123'); // Erster Aufruf, berechnet und cached if (!user) { return <p>Benutzer nicht gefunden</p>; } return ( <div> <h1>Profil</h1> <UserProfileDetails user={user} /> <RecentActivity userId={user.id} /> </div> ); } // components/RecentActivity.tsx (Server Component) import { getUser } from '@/lib/db'; // Gecachte Daten für dieselbe Benutzer-ID wiederverwenden export default async function RecentActivity({ userId }: { userId: string }) { const user = await getUser(userId); // Dieser Aufruf ruft aus dem Cache ab, wenn die Benutzer-ID '123' bereits abgerufen wurde if (!user) return null; return ( <div> <h2>Letzte Aktivitäten für {user.name}</h2> {/* ... Benutzeraktivitäten anzeigen ... */} </div> ); }
Durch das Umwickeln von getUser
mit cache()
werden nachfolgende Aufrufe von getUser('123')
innerhalb desselben Server-Renderings das memoizierte Ergebnis abrufen, ohne die Datenbanklogik erneut auszuführen.
Revalidierung gecachter Daten
Caching ist effektiv, aber Daten werden irgendwann veraltet. Frameworks, die RSCs integrieren, bieten oft Mechanismen zur Revalidierung von gecachten Daten. Zum Beispiel können Sie in Next.js verwenden:
- Zeitbasierte Revalidierung: Für globale
fetch
-Anfragen bewirkt die Einstellung einer "revalidate"-Option (z. B.fetch('...', { next: { revalidate: 60 } })
) , dass der Cache nach einer bestimmten Anzahl von Sekunden revalidiert wird. - Bedarfsgesteuerte Revalidierung: Die Verwendung von
revalidatePath
oderrevalidateTag
-Funktionen ermöglicht es Ihnen, spezifische gecachte Dateneinträge programmatisch zu invalidieren, typischerweise nach Datenmutationen (z. B. ein Benutzer aktualisiert sein Profil und invalidiert die gecachten Benutzerdaten). Dies geschieht oft innerhalb von Server Actions.
// app/actions.ts (Server Action) "use server"; import { revalidatePath, revalidateTag } from 'next/cache'; import { updateUserInDb } from '@/lib/db'; export async function updateUser(formData: FormData) { const userId = formData.get('userId') as string; const newName = formData.get('name') as string; await updateUserInDb(userId, newName); // Cache für die Benutzerprofilseite und alle Daten mit dem Tag 'users' invalidieren revalidatePath(`/profile/${userId}`); revalidateTag('users'); // Für Datenabrufe, die 'getaggt' wurden }
Dadurch wird sichergestellt, dass die Benutzeroberfläche die aktuellsten Daten widerspiegelt, auch bei umfangreichem Caching.
Fazit
React Server Components stellen einen bedeutenden Fortschritt bei der Optimierung der Leistung und der Entwicklererfahrung von Webanwendungen dar. Indem sie Datenabruf und einen erheblichen Teil des Renderings auf den Server verlagern, ermöglichen RSCs direktere, effizientere Datenabrufmuster, reduzieren die Größe von Client-Bundles und verbessern die anfängliche Seitenladezeit. Gekoppelt mit intelligenten Caching-Strategien durch automatische fetch
-Deduplizierung und der cache()
-API können Entwickler hochperformante Anwendungen erstellen, die sowohl schnell als auch wartbar sind. Diese Synergie aus serverseitiger Leistung und clientseitiger Interaktivität definiert die Herangehensweise an die Full-Stack-React-Entwicklung neu und positioniert RSCs fest als Eckpfeiler für die nächste Generation von Webanwendungen.