W poprzednich modułach omawialiśmy różne strategie renderowania w Next.js, w tym streamowanie danych i implementację stanów ładowania. W tym module skupimy się na jednej z najnowszych i najbardziej ekscytujących funkcji Next.js - Częściowym Prerenderowaniu (Partial Prerendering, PPR), które łączy zalety statycznego generowania i dynamicznego renderowania w jednym hybrydowym podejściu.
Częściowe Prerenderowanie (Partial Prerendering) to wzorzec renderowania wprowadzony w Next.js 14, który pozwala na statyczne generowanie części strony podczas budowania, podczas gdy inne części mogą być dynamicznie renderowane podczas żądania.
Najprościej mówiąc, PPR pozwala na:
Taka hybrydowa architektura łączy zalety obu światów: szybkość statycznego generowania z aktualnością dynamicznego renderowania.
Przed wprowadzeniem PPR, deweloperzy musieli wybierać między kilkoma podejściami, każde z własnymi ograniczeniami:
PPR stara się rozwiązać te problemy, wprowadzając nowy model hybrydowy, w którym podejścia te mogą współistnieć na jednej stronie w sposób bardziej zintegrowany.
PPR wprowadza nową jednostkę renderowania - "hole" (dziury) lub "slots" (gniazda), które są umieszczane w statycznie wygenerowanej treści. Te dziury są później wypełniane dynamicznie renderowanymi treściami po stronie klienta.
Process ten można podzielić na cztery etapy:
W Next.js 14 i nowszych, PPR można włączyć poprzez konfigurację w pliku
next.config.js:1// next.config.js
2module.exports = {
3 experimental: {
4 ppr: true
5 }
6}Alternatywnie, możesz włączyć PPR dla konkretnych stron za pomocą dyrektywy w komponencie strony:
1export const runtime = 'nodejs';
2export const preferredRegion = 'auto';
3export const dynamic = 'force-dynamic';
4export const dynamicParams = true;
5
6export const generateStaticParams = async () => {
7 //
8}Oto przykład implementacji PPR w Next.js 15:
1// app/products/[category]/page.tsx
2import { Suspense } from 'react';
3import { CategoryHeader } from '@/components/category-header';
4import { ProductGrid } from '@/components/product-grid';
5import { ProductFilters } from '@/components/product-filters';
6import { LoadingSkeleton } from '@/components/loading-skeleton';
7
8// Statyczne parametry dla prerendrowanych stron
9export async function generateStaticParams() {
10 const categories = await fetchTopCategories();
11 return categories.map(category => ({ category: category.slug }));
12}
13
14// Statyczne dane, które są znane podczas budowania
15async function getCategoryData(categorySlug: string) {
16 // Pobierz podstawowe dane kategorii, które rzadko się zmieniają
17 const data = await fetch(`https://api.example.com/categories/${categorySlug}`, {
18 // Te dane będą pobrane podczas budowania aplikacji
19 // i będą częścią statycznego Shella
20 });
21
22 return data.json();
23}
24
25export default async function CategoryPage({ params }: { params: Promise<{ category: string }> }) {
26 // Pobierz statyczne dane kategorii (renderowane podczas budowania)
27 const categoryData = await getCategoryData((await params).category);
28
29 return (
30 <div className="container mx-auto py-8">
31 {/* Statycznie renderowany nagłówek - część Shella */}
32 <CategoryHeader category={categoryData} />
33
34 <div className="grid grid-cols-4 gap-6 mt-8">
35 <div className="col-span-1">
36 {/* Dynamicznie renderowane filtry z danymi zależnymi od sesji użytkownika */}
37 <Suspense fallback={<LoadingSkeleton type="filters" />}>
38 <ProductFilters categorySlug={(await params).category} />
39 </Suspense>
40 </div>
41
42 <div className="col-span-3">
43 {/* Dynamicznie renderowana lista produktów ze świeżymi danymi */}
44 <Suspense fallback={<LoadingSkeleton type="products" />}>
45 <ProductGrid categorySlug={(await params).category} />
46 </Suspense>
47 </div>
48 </div>
49 </div>
50 );
51}W komponencie
ProductFilters i ProductGrid, będziemy używać opcji cache: 'no-store' aby zapewnić, że dane będą zawsze świeże:1// components/product-grid.tsx
2async function getProducts(categorySlug: string, filterParams?: Record<string, string>) {
3 // Tworzymy URL z parametrami filtrów
4 const queryParams = new URLSearchParams(filterParams);
5 const url = `https://api.example.com/products?category=${categorySlug}&${queryParams}`;
6
7 // Używamy opcji no-store, aby zawsze pobierać świeże dane
8 const res = await fetch(url, { cache: 'no-store' });
9
10 if (!res.ok) {
11 throw new Error('Failed to fetch products');
12 }
13
14 return res.json();
15}
16
17export async function ProductGrid({ categorySlug }: { categorySlug: string }) {
18 // Pobierz aktualne parametry filtrowania z URL
19 const searchParams = useSearchParams();
20 const filterParams = Object.fromEntries(searchParams.entries());
21
22 // Pobierz produkty z uwzględnieniem filtrów
23 const products = await getProducts(categorySlug, filterParams);
24
25 return (
26 <div className="grid grid-cols-3 gap-4">
27 {products.map(product => (
28 <ProductCard key={product.id} product={product} />
29 ))}
30 </div>
31 );
32}Jeden z problemów asynchronicznego renderowania to "wodospad danych", który możemy zminimalizować za pomocą wstępnego ładowania:
1// app/products/[category]/page.tsx
2import { Suspense } from 'react';
3import { ProductGrid } from './product-grid';
4
5// Funkcja preloadera inicjująca ładowanie danych
6function preloadProducts(categorySlug: string) {
7 // Inicjuje pobieranie danych, ale nie czeka na wynik
8 void getProducts(categorySlug);
9}
10
11export default async function CategoryPage({ params }: { params: Promise<{ category: string }> }) {
12 // Rozpocznij ładowanie danych wcześniej
13 preloadProducts((await params).category);
14
15 return (
16 <div>
17 {/* Statycznie prerendrowany nagłówek */}
18 <h1>Kategoria: {(await params).category}</h1>
19
20 {/* Dynamiczna część */}
21 <Suspense fallback={<div>Wczytywanie produktów...</div>}>
22 <ProductGrid categorySlug={(await params).category} />
23 </Suspense>
24 </div>
25 );
26}
27
28// Oto jak wygląda funkcja getProducts w tym przypadku
29async function getProducts(categorySlug: string) {
30 // Funkcja z cache'owaniem wyników
31 // Można użyć biblioteki SWR lub React Query dla bardziej zaawansowanego cache'owania
32 const cacheKey = `products-${categorySlug}`;
33 if (!cache.has(cacheKey)) {
34 const promise = fetch(`https://api.example.com/products?category=${categorySlug}`, {
35 cache: 'no-store'
36 }).then(res => res.json());
37
38 cache.set(cacheKey, promise);
39 }
40
41 return cache.get(cacheKey);
42}Możemy ustawić priorytet ładowania różnych części strony za pomocą zagłêbionego komponenta Suspense:
1// app/dashboard/page.tsx
2import { Suspense } from 'react';
3import { Header } from '@/components/header';
4import { UserWidget } from '@/components/user-widget';
5import { RecentActivity } from '@/components/recent-activity';
6import { PerformanceMetrics } from '@/components/performance-metrics';
7import { Recommendations } from '@/components/recommendations';
8
9import {
10 UserWidgetSkeleton,
11 RecentActivitySkeleton,
12 PerformanceMetricsSkeleton,
13 RecommendationsSkeleton
14} from '@/components/skeletons';
15
16export default function DashboardPage() {
17 return (
18 <div className="container mx-auto py-8">
19 {/* Statyczna część - nagłówek */}
20 <Header title="Dashboard" />
21
22 <div className="grid grid-cols-12 gap-6 mt-8">
23 {/* Priorytet 1: Widget użytkownika - ładowany pierwszy */}
24 <div className="col-span-3">
25 <Suspense fallback={<UserWidgetSkeleton />}>
26 <UserWidget />
27 </Suspense>
28 </div>
29
30 <div className="col-span-9">
31 {/* Priorytet 2: Ostatnia aktywność - ładowana jako druga */}
32 <Suspense fallback={<RecentActivitySkeleton />}>
33 <RecentActivity />
34
35 <div className="grid grid-cols-2 gap-6 mt-6">
36 {/* Priorytet 3: Metryki wydajności - ładowane jako trzecie */}
37 <Suspense fallback={<PerformanceMetricsSkeleton />}>
38 <PerformanceMetrics />
39 </Suspense>
40
41 {/* Priorytet 4: Rekomendacje - ładowane jako ostatnie */}
42 <Suspense fallback={<RecommendationsSkeleton />}>
43 <Recommendations />
44 </Suspense>
45 </div>
46 </Suspense>
47 </div>
48 </div>
49 </div>
50 );
51}Możemy połączyć statyczne generowanie i dynamiczne parametry:
1// app/products/[id]/page.tsx
2import { Suspense } from 'react';
3
4// Generujemy statycznie tylko określone strony produktów
5export async function generateStaticParams() {
6 // Pobieramy tylko popularne produkty do prerenderowania
7 const popularProducts = await fetchPopularProducts();
8
9 return popularProducts.map(product => ({
10 id: product.id.toString()
11 }));
12}
13
14// Po włączeniu dynamicParams: true, produkty bez statycznego generowania
15// będą renderowane na żądanie
16export const dynamicParams = true;
17
18export default async function ProductPage({ params }: { params: Promise<{ id: string }> }) {
19 // Ta funkcja pobiera podstawowe informacje o produkcie
20 // Dla popularnych produktów te dane zostaną pobrane podczas budowania
21 // Dla pozostałych - podczas żądania
22 const product = await getProductBasicInfo((await params).id);
23
24 if (!product) {
25 return <div>Produkt nie znaleziony</div>;
26 }
27
28 return (
29 <div className="product-page">
30 <h1>{product.name}</h1>
31 <div className="product-image">
32 <img src={product.imageUrl} alt={product.name} />
33 </div>
34
35 <div className="product-info">
36 <p className="product-price">{product.price} zł</p>
37
38 {/* Dynamiczna część z zawsze aktualnymi informacjami */}
39 <Suspense fallback={<div>Sprawdzanie dostępności...</div>}>
40 <ProductAvailability productId={(await params).id} />
41 </Suspense>
42
43 <Suspense fallback={<div>Ładowanie opcji dostawy...</div>}>
44 <DeliveryOptions productId={(await params).id} />
45 </Suspense>
46 </div>
47
48 <div className="product-details">
49 {/* Statyczna część - opis produktu */}
50 <div dangerouslySetInnerHTML={{ __html: product.description }} />
51
52 {/* Dynamiczna część - recenzje, często aktualizowane */}
53 <Suspense fallback={<div>Ładowanie recenzji...</div>}>
54 <ProductReviews productId={(await params).id} />
55 </Suspense>
56 </div>
57 </div>
58 );
59}
60
61async function getProductBasicInfo(id: string) {
62 // Pobieranie podstawowych informacji o produkcie
63 // Może być cachowane, ponieważ te informacje rzadko się zmieniają
64 const res = await fetch(`https://api.example.com/products/${id}/basic`);
65 if (!res.ok) return null;
66 return res.json();
67}Możemy również użyć PPR z dynamicznymi routami, np. dla zaawansowanego filtrowania:
1// app/products/[[...slug]]/page.tsx
2import { Suspense } from 'react';
3
4// Generujemy popularne kombinacje filtrów/kategorii dla prerenderowania
5export async function generateStaticParams() {
6 return [
7 { slug: [] }, // /products
8 { slug: ['category', 'electronics'] }, // /products/category/electronics
9 { slug: ['deals'] }, // /products/deals
10 // ... inne popularne kombinacje
11 ];
12}
13
14export const dynamicParams = true; // Pozwala na dynamiczne renderowanie innych kombinacji
15
16export default async function ProductsPage({ params }: { params: Promise<{ slug?: string[] }> }) {
17 // Parsowanie parametrów slug
18 const { category, filters } = parseSlug((await params).slug || []);
19
20 // Pobranie statycznych metadanych
21 const pageMetadata = await getPageMetadata(category);
22
23 return (
24 <div className="container mx-auto py-8">
25 {/* Statyczna część - nagłówek i metadane */}
26 <h1>{pageMetadata.title}</h1>
27 <p>{pageMetadata.description}</p>
28
29 <div className="flex mt-8">
30 {/* Dynamiczna część - filtry */}
31 <div className="w-1/4">
32 <Suspense fallback={<div>Ładowanie filtrów...</div>}>
33 <ProductFilters category={category} activeFilters={filters} />
34 </Suspense>
35 </div>
36
37 {/* Dynamiczna część - lista produktów */}
38 <div className="w-3/4">
39 <Suspense fallback={<div>Ładowanie produktów...</div>}>
40 <ProductList category={category} filters={filters} />
41 </Suspense>
42 </div>
43 </div>
44 </div>
45 );
46}
47
48// Funkcja pomocnicza do parsowania parametrów slug
49function parseSlug(slug: string[]) {
50 let category = '';
51 const filters: Record<string, string> = {};
52
53 for (let i = 0; i < slug.length; i += 2) {
54 if (slug[i] === 'category' && slug[i+1]) {
55 category = slug[i+1];
56 } else if (slug[i] && slug[i+1]) {
57 filters[slug[i]] = slug[i+1];
58 }
59 }
60
61 return { category, filters };
62}Optymalne buforowanie danych jest kluczowe dla wydajności PPR. Oto kilka wzorów buforowania:
1// utils/cache.ts
2export const dataCache = new Map();
3
4export async function getCachedData(key: string, fetcher: () => Promise<any>) {
5 if (!dataCache.has(key)) {
6 try {
7 const promise = fetcher();
8 dataCache.set(key, promise);
9 const data = await promise;
10 return data;
11 } catch (error) {
12 dataCache.delete(key);
13 throw error;
14 }
15 }
16
17 return dataCache.get(key);
18}Użycie:
1// components/product-list.tsx
2import { getCachedData } from '@/utils/cache';
3
4async function getProducts(category: string) {
5 return getCachedData(`products-${category}`, () => {
6 return fetch(`https://api.example.com/products?category=${category}`, {
7 cache: 'no-store'
8 }).then(res => res.json());
9 });
10}1// lib/fetch-client.ts
2import { cache } from 'react';
3
4// Wykorzystujemy memoizację React do buforowania wyników zapytań
5export const fetchAPI = cache(async (url: string, options: RequestInit = {}) => {
6 const res = await fetch(url, options);
7
8 if (!res.ok) {
9 throw new Error(`Failed to fetch ${url}`);
10 }
11
12 return res.json();
13});Użycie:
1// components/product-data.tsx
2import { fetchAPI } from '@/lib/fetch-client';
3
4export async function ProductData({ id }: { id: string }) {
5 // Zapytanie zostanie wykonane tylko raz na renderowanie,
6 // nawet jeśli komponent jest używany wielokrotnie z tym samym id
7 const product = await fetchAPI(`https://api.example.com/products/${id}`);
8
9 return (
10 <div className="product-data">
11 <h2>{product.name}</h2>
12 <p>{product.description}</p>
13 <p className="price">{product.price} zł</p>
14 </div>
15 );
16}PPR jest idealny dla stron produktowych e-commerce, gdzie możemy:
Dla witryn informacyjnych, PPR pozwala na:
Dla dashboardów analitycznych:
Debugging PPR może być wymagający ze względu na hybrydową naturę renderowania. Oto kilka wskazek:
React DevTools pozwala na inspekcję komponentów i ich stanu renderowania.
1NEXT_DEBUG=1 next buildTa komenda pokazuje szczegółowe informacje o tym, które strony są generowane statycznie.
Zaimplementuj narzędzia do monitorowania wydajności, takie jak Lighthouse, PageSpeed Insights lub narzędzia monitorowania Core Web Vitals, aby ocenić wpływ PPR na doświadczenie użytkownika końcowego.
Określ jasno, które dane można renderować statycznie, a które muszą być dynamiczne:
Używaj zagłębionego
Suspense do priorytetyzacji ładowania treści:Starannie zarządzaj cache'owaniem danych, aby uniknąć niepotrzebnych ponownych renderowań:
fetchAPI z memoizacją ReactProjektuj szkielety ładowania, które odpowiadają wymiarom i proporcjom końcowej zawartości, aby zminimalizować przeskoki układu.
Częściowe Prerenderowanie (PPR) to potężna funkcja Next.js, która umożliwia łączenie wydajności statycznego generowania z elastycznością dynamicznego renderowania. Pozwala to na tworzenie szybkich, interaktywnych aplikacji z optymalną wydajnością dla różnych typów treści.
Główne korzyści PPR to:
Choć PPR jest wciąż w fazie eksperymentalnej, oferuje ekscytującą wizję przyszłości renderowania w Next.js, łącząc zalety rozproszonych dotąd podejść do generowania treści webowych.
W następnym module omówimy szczegółowo zarządzanie cache i zaawansowane techniki rewalidacji danych, które są kluczowe dla optymalnego działania PPR i innych strategii renderowania w Next.js.