Używamy cookies, żeby zwiększyć Twoje doświadczenia na stronie
CodeWorlds
Powrót do kolekcji
Przewodnik11 min czytania

Next.js 16, jawne buforowanie i nowy domyślny bundler

Next.js 16 odwraca buforowanie: po włączeniu opcji cacheComponents nic nie jest buforowane domyślnie. Komponenty serwerowe, use cache, Turbopack i migracja.

Next.js 16, czyli koniec buforowania, którego nikt nie rozumiał

Największym źródłem frustracji przy pracy z poprzednimi wersjami tego frameworka nie były komponenty serwerowe ani akcje serwerowe, tylko buforowanie. Działało domyślnie, w kilku warstwach naraz, a odpowiedź na pytanie „dlaczego widzę stare dane" wymagała znajomości czterech różnych mechanizmów.

Szesnasta wersja odwraca ten model. Po włączeniu jednej opcji w konfiguracji domyślnie nic nie jest buforowane, a to, co ma być, oznaczasz wprost. Zmiana brzmi drobno i jest największą zmianą w tym frameworku od czasu wprowadzenia routera aplikacji.

Jawne buforowanie

Zanim cokolwiek z tego zadziała, model trzeba włączyć w konfiguracji, i to jest najczęstszy powód, dla którego przykłady z sieci kończą się błędem budowania. Cały mechanizm kryje się za jedną opcją.

Code
TypeScript
import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
  cacheComponents: true
}

export default nextConfig

Bez niej dyrektywa buforująca, czas życia i znacznik są niedostępne, a projekt zachowuje się jak w poprzedniej wersji, gdzie o buforowaniu decydowały ustawienia segmentu trasy. Ta sama opcja włącza częściowe generowanie opisane niżej, bo osobna flaga, która odpowiadała za nie wcześniej, została usunięta. Model wymaga też środowiska uruchomieniowego Node, więc trasy oznaczone jako brzegowe trzeba najpierw przenieść.

Dyrektywa umieszczona w funkcji albo komponencie mówi, że wynik ma zostać zapamiętany.

Code
TypeScript
async function pobierzArtykuly(kategoria: string) {
  'use cache'
  cacheLife('hours')
  cacheTag(`artykuly-${kategoria}`)

  const dane = await db.artykuly.findMany({ where: { kategoria } })
  return dane
}

Trzy rzeczy dzieją się tu jawnie. Pierwsza to sam fakt buforowania. Druga to czas życia wpisu, podany nazwanym profilem zamiast liczbą sekund rozsianą po kodzie. Trzecia to znacznik pozwalający unieważnić ten konkretny wpis po zmianie danych.

Code
TypeScript
'use server'

export async function zapiszArtykul(dane: FormData) {
  await db.artykuly.create({ data: przetworz(dane) })
  revalidateTag(`artykuly-${dane.get('kategoria')}`)
}

Ta konstrukcja rozwiązuje najczęstszy problem poprzednich wersji: dane zmienione przez użytkownika, które nie pojawiają się na liście, bo warstwa buforująca o zmianie nie wie. Teraz unieważnienie jest widoczne w kodzie obok zapisu.

Cena tej jawności jest jedna: strona, która wcześniej była szybka przez przypadek, teraz jest wolna, dopóki nie oznaczysz jej wprost. Przy migracji to najczęstsza niespodzianka.

Częściowe wstępne generowanie

Drugi mechanizm współpracuje z pierwszym i wart jest zrozumienia, bo z niego bierze się większość zysku.

Strona składa się zwykle z części niezmiennej i części zależnej od użytkownika. Nagłówek, stopka, opis produktu i treść artykułu są takie same dla wszystkich. Koszyk, powitanie po imieniu i lista ostatnio oglądanych są indywidualne.

Klasyczne podejście zmusza do wyboru: albo cała strona jest statyczna i nie ma części indywidualnych, albo cała jest dynamiczna i generuje się przy każdym żądaniu. Częściowe generowanie pozwala mieć oba: statyczna powłoka trafia do użytkownika natychmiast, a fragmenty zależne od żądania dopływają zaraz potem.

Code
TypeScript
export default function StronaProduktu({ params }) {
  return (
    <>
      <OpisProduktu id={params.id} />
      <Suspense fallback={<KoszykSzkielet />}>
        <KoszykUzytkownika />
      </Suspense>
    </>
  )
}

Granica jest wyznaczona przez opakowanie zawieszające. Wszystko poza nim musi dać się wygenerować z góry, więc sięgnięcie po nagłówki żądania albo ciasteczka poza tą granicą jest błędem, który framework zgłosi przy budowaniu.

Turbopack domyślnie

Nowy bundler przestał być opcją włączaną flagą i jest domyślny zarówno w trybie deweloperskim, jak i przy budowaniu produkcyjnym.

Różnica jest odczuwalna przy dużych projektach. Odświeżanie po zmianie w kodzie liczy się w dziesiątkach milisekund zamiast w sekundach, a budowanie produkcyjne skraca się kilkukrotnie. Przy projekcie na dwadzieścia komponentów tego nie zauważysz, przy projekcie na dwieście różnica zmienia rytm pracy.

Doszła też pamięć podręczna na dysku, dzięki której kolejne uruchomienie po ponownym starcie nie zaczyna od zera. To ma znaczenie przy pracy z gałęziami, gdzie przełączanie między nimi wcześniej oznaczało pełne przebudowanie.

Warto sprawdzić własną konfigurację przy przejściu. Projekty korzystające z niestandardowych ustawień poprzedniego bundlera wymagają przełożenia ich na nowy, a część wtyczek nie ma odpowiednika. Przy typowym projekcie bez własnej konfiguracji przejście jest niezauważalne, bo działa domyślnie.

Nazwa myli przy monorepozytoriach. Turbopack pakuje kod jednej aplikacji, a Turborepo rozdziela zadania między pakietami i zapamiętuje ich wyniki, więc pakiet, którego nikt nie tknął, nie jest budowany drugi raz. Zysk pojawia się dopiero wtedy, gdy w repozytorium leży kilka aplikacji albo bibliotek; przy jednej to sama konfiguracja.

Zmiany łamiące zgodność

Trzy rzeczy wymagają uwagi przy migracji i warto je znać przed rozpoczęciem.

Pierwsza to warstwa pośrednicząca. Plik obsługujący żądania przed dotarciem do trasy zmienił nazwę, żeby jasne było, że działa na granicy sieciowej, a nie wewnątrz aplikacji. To zmiana nazwy pliku plus przejrzenie tego, co się w nim znajduje, bo część rzeczy nie należała tam nigdy.

Druga to wymagana wersja biblioteki interfejsu. Framework wymaga wydania zawierającego mechanizmy, na których opiera się częściowe generowanie, więc podniesienie jednego pociąga za sobą drugie.

Trzecia to buforowanie, o którym wyżej. Samo podniesienie wersji jeszcze go nie zmienia, bo nowy model włącza się osobną opcją. Po jej włączeniu kod polegający na dawnym zachowaniu nadal działa, tylko wolniej, bo nie buforuje nic. To nie jest błąd, który zobaczysz w konsoli, tylko wzrost czasu odpowiedzi i rachunku za funkcje, zauważany zwykle po tygodniu albo przy pierwszej fakturze. Przy okazji sprawdź wersję środowiska uruchomieniowego, bo szesnasta wersja wymaga Node co najmniej 20.9.

Praktyczna kolejność migracji: podnieś wersje, uruchom, przejrzyj czasy odpowiedzi, a potem dodawaj oznaczenia buforowania tam, gdzie widzisz pogorszenie. Odwrotna kolejność, czyli oznaczanie wszystkiego z góry, prowadzi do buforowania rzeczy, które buforowania nie powinny mieć.

Komponenty serwerowe w praktyce

Podział na komponenty serwerowe i klienckie jest z nami od kilku wersji, a nadal bywa źródłem nieporozumień.

Domyślnie komponent jest serwerowy. Wykonuje się na serwerze, ma dostęp do bazy i sekretów, a do przeglądarki trafia wynik, nie kod. Dyrektywa oznaczająca komponent jako kliencki jest potrzebna dopiero wtedy, gdy potrzebujesz stanu, efektów albo zdarzeń przeglądarki.

Najczęstszy błąd polega na oznaczaniu jako klienckie zbyt wysoko w drzewie. Dyrektywa działa na całe poddrzewo, więc oznaczenie układu strony przenosi do przeglądarki wszystko pod nim, razem z kodem, który mógłby zostać na serwerze.

Właściwe podejście polega na schodzeniu z tą granicą jak najniżej. Interaktywny przycisk jest komponentem klienckim, a lista, w której się znajduje, zostaje serwerowa. Przy bibliotekach komponentów, na przykład Mantine, oznacza to zwykle opakowanie ich we własne komponenty klienckie zamiast oznaczania całych stron.

Akcje serwerowe

Mechanizm pozwalający wywołać funkcję serwerową wprost z komponentu, bez pisania trasy interfejsu programistycznego. Wygodny i wart zrozumienia, bo bywa używany nieostrożnie.

Code
TypeScript
'use server'

import { auth } from '@/lib/auth'

export async function usunKomentarz(id: string) {
  const sesja = await auth()
  if (!sesja) throw new Error('Brak dostepu')

  const komentarz = await db.komentarze.findUnique({ where: { id } })
  if (komentarz.autorId !== sesja.userId) throw new Error('Brak uprawnien')

  await db.komentarze.delete({ where: { id } })
  revalidateTag('komentarze')
}

Dwa sprawdzenia na początku nie są nadmiarowe. Akcja serwerowa jest punktem końcowym dostępnym z zewnątrz, więc ktokolwiek może ją wywołać z dowolnym identyfikatorem. Ukrycie przycisku w interfejsie nie chroni niczego.

Druga rzecz to walidacja wejścia. Argumenty przychodzą z przeglądarki, więc typ w sygnaturze funkcji jest deklaracją, a nie gwarancją. Schemat sprawdzający dane na wejściu jest tu obowiązkowy tak samo jak w zwykłej trasie.

Trzecia to obsługa błędu widoczna dla użytkownika. Wyjątek rzucony w akcji dociera do komponentu, ale bez obsługi kończy się komunikatem ogólnym, więc warto zwracać wynik opisujący, co poszło nie tak.

Obrazy i czcionki

Dwa mechanizmy wbudowane, które najbardziej wpływają na wyniki pomiaru szybkości, a bywają pomijane.

Komponent obrazu dobiera rozmiar do urządzenia, konwertuje format i rezerwuje miejsce, żeby układ nie skakał po załadowaniu. Ostatnia rzecz ma znaczenie największe, bo przesuwanie się treści przy ładowaniu jest jednym z mierzonych wskaźników.

Code
TypeScript
import Image from 'next/image'

<Image src="/produkt.jpg" alt="Widok produktu" width={800} height={600} priority />

Oznaczenie priorytetu dotyczy obrazów widocznych bez przewijania. Bez niego przeglądarka ładuje je razem z resztą, przez co największy element strony pojawia się później, niż mógłby, a to jest mierzone i wpływa na ocenę szybkości.

Mechanizm czcionek pobiera je przy budowaniu i serwuje z Twojej domeny, zamiast odpytywać zewnętrznego dostawcę przy każdym wejściu. To usuwa jedno połączenie sieciowe i migotanie tekstu przy zmianie czcionki zastępczej na docelową, a przy okazji jedno miejsce, w którym dane odwiedzających wychodzą na zewnątrz.

Next.js kontra alternatywy

FrameworkMocna stronaSłabośćKiedy wybrać
Next.jsKomponenty serwerowe, ekosystem, wdrożenie u twórcyZłożoność, częste zmiany modeluAplikacja z interfejsem i zapleczem
AstroNajlżejszy wynik, treść na pierwszym miejscuMniej wygodny przy dużej interaktywnościStrony treściowe, blogi, dokumentacja
RemixProstszy model danych, bliżej standardów sieciMniejszy ekosystemAplikacja z formularzami i danymi
Hono z osobnym frontemRozdzielenie warstw, lekkośćDwa wdrożenia zamiast jednegoInterfejs programistyczny i front osobno

Wybór między pierwszymi dwoma wierszami zależy od proporcji treści do interaktywności. Blog albo dokumentacja nie potrzebuje warstwy serwerowej ani komponentów klienckich na każdej stronie, a lżejszy wynik przekłada się wprost na czas ładowania.

Ostatni wiersz warto rozważyć, gdy zaplecze rośnie w samodzielny produkt. Trzymanie interfejsu programistycznego w tym samym projekcie co front jest wygodne na początku i staje się ograniczeniem, gdy z tego interfejsu zaczyna korzystać coś jeszcze.

Warto też uczciwie powiedzieć, że ten framework zmienia model pracy częściej niż konkurencja. Przejście na router aplikacji, potem na komponenty serwerowe, teraz na jawne buforowanie: każda z tych zmian wymagała przemyślenia projektu na nowo. To cena za bycie blisko tego, co dzieje się w bibliotece interfejsu, i warto ją uwzględnić przy projekcie, który ma żyć bez większych zmian przez kilka lat.

Z drugiej strony żadna z tych zmian nie unieważniła poprzedniej wersji z dnia na dzień. Router stron nadal działa, a aplikacje z poprzednich generacji da się rozwijać bez migracji. Koszt polega raczej na tym, że materiały i przykłady w sieci opisują różne wersje, więc wyszukanie odpowiedzi wymaga sprawdzenia, do której się odnosi.

Struktura projektu

Router oparty o katalogi oznacza, że struktura plików jest jednocześnie strukturą adresów, i to wymaga kilku ustaleń.

Pliki o ustalonych nazwach mają znaczenie specjalne: strona, układ, stan ładowania, obsługa błędu, brak zasobu. Reszta plików w katalogu trasy jest zwykłym kodem, więc komponenty używane tylko przez jedną stronę mogą leżeć obok niej zamiast we wspólnym katalogu.

Grupy tras, zapisywane nazwą w nawiasach, pozwalają dzielić projekt bez wpływu na adresy. Typowe zastosowanie to rozdzielenie części publicznej od panelu za logowaniem, gdzie każda ma własny układ, a adresy pozostają płaskie.

Code
TEXT
app/
  (publiczne)/
    layout.tsx
    page.tsx
  (panel)/
    layout.tsx
    ustawienia/page.tsx

Warto od początku ustalić, gdzie leży kod niebędący trasą. Trzymanie wszystkiego w katalogu tras działa przy dziesięciu stronach i staje się nieczytelne przy pięćdziesięciu, bo trudno odróżnić plik obsługujący adres od pomocniczego.

Osobną sprawą jest obsługa błędów. Plik obsługujący błąd łapie wyjątki z poddrzewa, więc jego umieszczenie decyduje o tym, czy użytkownik zobaczy komunikat zamiast całej strony, czy tylko zamiast jednej sekcji.

Typowe błędy

Pierwszy to dyrektywa kliencka w układzie strony. Przenosi do przeglądarki całe poddrzewo, przez co komponenty serwerowe przestają być serwerowe.

Drugi to sięganie po nagłówki żądania w części, która ma być wygenerowana z góry. Framework zgłosi to przy budowaniu, ale przyczyna bywa nieoczywista, bo wywołanie leży kilka poziomów niżej.

Trzeci to migracja bez sprawdzenia czasów odpowiedzi. Kod działa, tylko przestał buforować, a rachunek za funkcje bezserwerowe rośnie po cichu.

Czwarty to unieważnianie całych ścieżek zamiast znaczników. Odświeżenie wszystkiego po każdej zmianie niweluje zysk z buforowania i obciąża zaplecze.

Piąty to akcje serwerowe bez sprawdzania uprawnień. To zwykłe punkty końcowe dostępne z zewnątrz, więc sprawdzenie, kto wywołuje, musi być w nich, a nie w interfejsie, który je pokazuje.

Szósty to pobieranie danych w komponencie klienckim tam, gdzie serwerowy zrobiłby to lepiej. Efektem jest dodatkowa podróż do serwera i migotanie stanu ładowania, którego nie musiałoby być.

FAQ

Co zmieniło się w szesnastej wersji?

Buforowanie stało się jawne: po włączeniu opcji cacheComponents w konfiguracji nic nie jest buforowane domyślnie, a to, co ma być, oznaczasz dyrektywą z czasem życia i znacznikiem. Ta sama opcja zastąpiła wcześniejszą flagę częściowego generowania. Nowy bundler stał się domyślny w trybie deweloperskim i produkcyjnym, a warstwa pośrednicząca zmieniła nazwę pliku.

Czy migracja z piętnastej wersji jest trudna?

Zwykle nie, bo kod polegający na dawnym zachowaniu nadal działa, tylko przestaje buforować. Główna praca polega na przejrzeniu czasów odpowiedzi i dodaniu oznaczeń tam, gdzie widać pogorszenie, plus przemianowaniu pliku warstwy pośredniczącej.

Kiedy komponent musi być kliencki?

Gdy używa stanu, efektów, zdarzeń przeglądarki albo interfejsów dostępnych tylko po stronie klienta. We wszystkich pozostałych przypadkach zostaw go serwerowym, bo dzięki temu kod nie trafia do przeglądarki i ma dostęp do bazy bez pośrednictwa.

Czy Next.js nadaje się do stron treściowych?

Tak, choć przy blogu albo dokumentacji lżejsze frameworki dają mniejszy wynik i prostszy model. Ten framework wygrywa, gdy poza treścią jest logowanie, panel użytkownika albo zaplecze, bo trzyma to wszystko w jednym projekcie.

Czy trzeba wdrażać u twórcy frameworka?

Nie, choć integracja jest tam najgłębsza i część funkcji działa bez konfiguracji. Alternatywy obejmują Netlify, Cloudflare i wdrożenie w kontenerze na własnym serwerze, przy czym warto sprawdzić obsługę częściowego generowania u wybranego dostawcy.

Dokumentacja stoi na stronie projektu, a opis zmian w szesnastej wersji znajdziesz we wpisie o jej wydaniu.