Wydajność React Dostępność

Dlaczego moje portfolio w three.js było wolne na telefonie i czego potrzebuje strona 3D

Moje stare portfolio w three.js miało 34 punkty w mobilnym Lighthouse. Co je spowalniało i lista kontrolna dla strony 3D, która działa szybko na telefonie.

Autor: Mateusz Muszarski Zaktualizowano 6 min czytania In English

Moje stare portfolio w three.js było wolne na telefonie, bo kazało mu pobrać 13,8 MB i uruchomić całą aplikację 3D, zanim pokazało jakikolwiek tekst. Mobilny Lighthouse dał mu 34 punkty, a największe malowanie treści (LCP) przyszło po 46,9 s. Rozwiązaniem nie jest mniej 3D, tylko tekst na początku, 3D później i w budżecie oraz sterowanie bez kółka myszy.

W skrócie

  • Co poszło źle: większość pierwszego pobrania to font z emoji i model 3D, HTML nie miał tekstu, a podróżą sterowało tylko kółko myszy.
  • Czego strona 3D potrzebuje na telefonie: tekstu w HTML na start, 3D ładowanego na żądanie, budżetu bajtów, mniej pikseli i klatek, szacunku dla reduced motion i odblokowanego powiększania.
  • Co robię teraz: w nagłówku nie ma WebGL. Stary lot to leniwie ładowane wideo, a shadery na tej stronie czekają na pierwszy ruch czytelnika.

Czym była stara strona?

Portfolio, przez które się leci: 12 przystanków kamery od Ziemi do Neptuna, sterowanych przewijaniem, z jedną sekcją na każdej planecie. Zbudowałem je sam w 2025 roku na three.js 0.177, React Three Fiber 9, drei, postprocessingu, GSAP i Vite 6. Ma 13 programów shaderów w GLSL, pisanych ręcznie według podejścia z kursu Three.js Journey Bruna Simona.

Stara strona
Stare portfolio w ruchu, nagrane na komputerze. Na telefonie nigdy nie dochodziło tak daleko. Tekstury planet: Solar System Scope, CC BY 4.0.

Najbardziej lubiłem je pokazywać. Potem, 5 października 2026, je zmierzyłem.

Jak bardzo było wolne, w liczbach?

Lighthouse 13.5.0, symulowane dławienie Komputer Telefon
Wynik wydajności 60 34
Pierwsze malowanie treści (FCP) 0,8 s 42,5 s
Największe malowanie treści (LCP) 1,2 s 46,9 s
Całkowity czas blokowania 1240 ms 930 ms

Pomiar mobilny emuluje telefon ze średniej półki na łączu 1,6 Mb/s, z procesorem 4 razy wolniejszym; każdy profil zmierzyłem raz. Najgorszej części wynik i tak nie pokazuje:

  • Telefony dostawały ekran „Oops!”. Komunikat o niekompatybilnym urządzeniu widziało każde okno o szerokości do 900 px, a pod spodem i tak pobierało się te same 13,8 MB.
  • Dotyk nic nie robił. Podróż słuchała tylko zdarzenia wheel, więc telefon nie mógł opuścić pierwszego przystanku.
  • Nie było żadnej ścieżki dla klawiatury.
  • Około 85 sekund od przycisku startu do formularza kontaktowego, dla skryptu przewijającego tak szybko, jak pozwalała kamera.

Dlaczego było wolne?

Nic do namalowania w HTML

Surowy HTML nie miał tekstu do zaindeksowania. Każda sekcja żyła w nakładce drei <Html> wewnątrz sceny 3D, więc istniała dopiero wtedy, gdy aplikacja już działała. Na liście web.dev elementów, które mogą zostać LCP (obrazy, wideo, obrazy tła z CSS, tekst w elementach blokowych), nie ma <canvas>. Pierwsze malowanie czekało więc, aż uruchomi się cała aplikacja.

Bajty, których nikt nie potrzebował na start

Większość pierwszego pobrania, jeszcze przed kliknięciem startu, stanowiły dwa pliki: kolorowy font z emoji (7,6 MB po kompresji) i model asteroid (4,5 MB). Żaden z nich nie jest potrzebny, żeby pokazać imię i rolę.

Cała aplikacja 3D na głównym wątku

Pierwsze ładowanie uruchamiało cały stos 3D: three.js, React Three Fiber, drei, postprocessing i GSAP. Drogie nie było pobieranie, tylko wykonanie. Przy 4-krotnym spowolnieniu procesora Lighthouse naliczył 40,5 s uruchamiania JavaScriptu i 43,1 s pracy głównego wątku.

Bramka, a potem powolna wycieczka

Przycisk startu, animacja drzwi, potem ruchy kamery po 1-8 s każdy, a w trakcie każdego z nich sterowanie było ignorowane. Dobry showreel i słabe pierwsze spotkanie dla kogoś, kto otworzył link na telefonie.

Czego potrzebuje strona 3D, żeby była szybka na telefonie?

To lista, którą dałbym sobie z 2025 roku. Gatunek tego nie rozwiązał: w moich pomiarach Lighthouse na 16 portfolio programistów mediana stron 3D wyniosła 35,5.

1. Najpierw tekst

Każdy prawdziwy fakt (imię, rola, projekty, kontakt) renderuj na serwerze jako HTML. Elementem LCP niech będzie tekst albo obraz zastępczy (poster), nigdy napis loadera. 3D to warstwa nad treścią, a nie jej pojemnik.

2. Ładuj 3D na żądanie albo wcale

Paczkę 3D ładuj dynamicznym import() (MDN: dynamiczne importy są wykonywane dopiero wtedy, gdy są potrzebne), przy pierwszej interakcji albo gdy canvas zbliża się do ekranu. Bezczynność przeglądarki to słabszy sygnał. requestIdleCallback nie należy do Baseline (Safari ma go domyślnie wyłączonego), powinien dostać opcję timeout dla pracy, która musi się wykonać, i nie jest darmowy, co pokazał mój własny shader (niżej).

Przy reduced motion albo bez WebGL nie pobieraj niczego: pokaż poster i na tym skończ.

3. Ustal budżet przed pierwszym commitem

Budżet Cel
LCP, laboratorium, telefon do 2,5 s
Całkowity czas blokowania do 200 ms
JavaScript przed LCP (gzip) do 150 KB
Paczka 3D, ładowana leniwie (gzip) do 200 KB przy samym three.js
Zasoby 3D, ładowane leniwie, na telefonie do 1 MB

Dla skali, z testu paczek z 5 października 2026 (esbuild, minifikacja, gzip): minimalna scena three.js po tree-shakingu to około 131 KB. Z Reactem i React Three Fiber to około 310 KB, a kilka pomocników z drei daje około 400 KB, jeszcze przed jakimkolwiek modelem czy teksturą. Trzymaj więc R3F z dala od ścieżki krytycznej.

4. Rysuj mniej pikseli i rzadziej

Podręcznik three.js o ekranach o wysokiej gęstości: najlepsze telefony mają 9 pikseli urządzenia na każdy piksel zwykłego ekranu, więc muszą wykonać 9 razy więcej renderowania. Domyślne dpr w React Three Fiber to [1, 2]; na telefonach ogranicz je bardziej. Przy 1,5 karta graficzna wypełnia ćwierć pikseli, które wypełniałaby przy 3.

Potem rysuj tylko wtedy, gdy coś się zmienia. Znowu podręcznik three.js: ciągłe renderowanie marnuje energię urządzenia, a na sprzęcie przenośnym także baterię użytkownika. W R3F to jeden prop:

import { Canvas } from "@react-three/fiber";

const coarse = matchMedia("(pointer: coarse)").matches;

export function Scene() {
  return (
    <Canvas dpr={coarse ? [1, 1.5] : [1, 2]} frameloop="demand">
      {/* wywołaj invalidate(), gdy cokolwiek zmienia obraz */}
    </Canvas>
  );
}

Jeśli scena musi się animować, zatrzymuj pętlę poza ekranem. Przeglądarki wstrzymują requestAnimationFrame w kartach w tle i ukrytych ramkach iframe (MDN), ale nie dla canvasa przewiniętego poza widok. To zadanie dla IntersectionObservera.

5. Respektuj reduced motion

MDN opisuje prefers-reduced-motion jako ustawienie urządzenia, które ogranicza zbędny ruch. Przelot kamery między planetami to dokładnie taki ruch. Tym osobom pokaż nieruchomą klatkę albo poster. A wszystkim daj przycisk pauzy dla wszystkiego, co samo rusza się dłużej niż pięć sekund (WCAG 2.2.2 (otwiera się w nowej karcie)). Jak robi to ta strona, opisałem w tekście o reduced motion i przycisku pauzy.

6. Nie blokuj powiększania, obsłuż dotyk i klawiaturę

Nie wpisuj user-scalable=no ani niskiego maximum-scale do tagu viewport; Lighthouse ma test dostępności dokładnie na to. Jeśli canvas potrzebuje gestów przeciągania, ustaw touch-action na canvasie, a nie na całej stronie. MDN ostrzega, że touch-action: none i tak może zablokować powiększanie nad tym obszarem.

Każda droga przez scenę potrzebuje wersji dla dotyku i dla klawiatury: przesunięć palcem, strzałek, przycisków albo zwykłych linków. Moja miała nasłuch na kółko myszy i nic więcej.

Co robię teraz?

Ta strona nie ma WebGL w nagłówku. H1 to moje imię i nazwisko, prawdziwy tekst gotowy przy pierwszym malowaniu. Stary lot to teraz wideo z początku tego tekstu, na stronie głównej: poster i preload="none", a źródła dołączam dopiero po pierwszej interakcji czytelnika. Odtwarza się tylko wtedy, gdy widać go przynajmniej w połowie; przy reduced motion zostaje poster.

Na innych podstronach działają teraz cztery małe shadery, napisane bez żadnej biblioteki. Najprostszy to glut za tytułem strony „Moje życie”:

Glut
Glut za tytułem strony „Moje życie”, nagrany na tej stronie: plamy w kolorach marki, które idą za kursorem i pęcznieją po dotknięciu.

Pierwsza wersja trzymała się mojej własnej listy i startowała, gdy strona była bezczynna. Lighthouse się nie zgodził: kompilacja shadera w trakcie ładowania kosztowała 150-350 ms blokowania, a „Moje życie” spadło na telefonie do 83-91 punktów. Teraz glut czeka na czytelnika:

// Najpierw tekst: glut startuje po pierwszej interakcji czytelnika
// albo ruchu kursora nad nim. Nie na timerze bezczynności.
afterInteraction(() => void go());
host.addEventListener("pointermove", () => void go(), { once: true, passive: true });

Cała kolejność, której trzymają się też pozostałe trzy shadery:

  1. 01 Tekst się maluje Tytuł, a za nim te same kolory w CSS
  2. 02 Czytelnik się rusza Dotknięcie, klawisz, przewinięcie albo ruch kursora
  3. 03 Moduł się ładuje Jeden trójkąt i jeden shader, bez biblioteki
  4. 04 Rysuje, gdy go widać Gęstość pikseli do 1,5, stop poza ekranem
  5. 05 Stoi w miejscu Jedna klatka przy pauzie albo reduced motion

Strona główna, która zastąpiła lot, ma 95-96 punktów w mobilnym Lighthouse (lokalny build produkcyjny, mediana z 3 pomiarów, 7 października 2026). Jak powstała reszta, opisałem w studium przypadku tej strony.

Ograniczenia

  • Liczby starej strony to jeden pomiar laboratoryjny na profil, z Chrome w trybie headless bez karty graficznej. Nic tu nie działało na fizycznym telefonie.
  • Lista kontrolna wynika z researchu i z małych shaderów tej strony, a nie z przebudowy pełnego świata 3D jak stary.
  • Przy reduced motion glut i tak pobiera swój moduł, żeby narysować jedną nieruchomą klatkę. Moja lista mówi, żeby to pominąć; to następna poprawka.

Kosmiczne portfolio nadal działa, a jego kod jest publiczny: oba linki znajdziesz na stronie z moimi projektami.

Źródła

  1. Largest Contentful Paint (LCP), web.dev (otwiera się w nowej karcie) (dostęp: )
  2. Responsive Design (three.js manual) (otwiera się w nowej karcie) (dostęp: )
  3. Rendering on Demand (three.js manual) (otwiera się w nowej karcie) (dostęp: )
  4. Scaling performance (React Three Fiber docs) (otwiera się w nowej karcie) (dostęp: )
  5. Canvas (React Three Fiber docs) (otwiera się w nowej karcie) (dostęp: )
  6. import() (MDN) (otwiera się w nowej karcie) (dostęp: )
  7. Window: requestIdleCallback() method (MDN) (otwiera się w nowej karcie) (dostęp: )
  8. requestIdleCallback (Can I use) (otwiera się w nowej karcie) (dostęp: )
  9. Window: requestAnimationFrame() method (MDN) (otwiera się w nowej karcie) (dostęp: )
  10. prefers-reduced-motion (MDN) (otwiera się w nowej karcie) (dostęp: )
  11. Understanding Success Criterion 2.2.2: Pause, Stop, Hide (W3C) (otwiera się w nowej karcie) (dostęp: )
  12. touch-action (MDN) (otwiera się w nowej karcie) (dostęp: )
  13. Lighthouse accessibility scoring (Chrome for Developers) (otwiera się w nowej karcie) (dostęp: )
  14. Solar System Scope, tekstury planet (CC BY 4.0) użyte w starym kosmicznym portfolio (otwiera się w nowej karcie) (dostęp: )

Wszystkie wpisy