Od 76 do 100: Osiągnięcie idealnego wyniku Lighthouse
TL;DR: Osobista strona portfolio przeszła od mobilnego wyniku wydajności Lighthouse na poziomie 76 z CLS 0,493 do idealnego 100/100/100/100 we wszystkich kategoriach. Ta podróż ujawniła subtelne problemy z ładowaniem CSS, błędy w wyrażeniach regularnych oraz szczególnie podstępne nadpisanie zmiennej CSS, które powodowało przesunięcia układu na urządzeniach mobilnych.
Punkt wyjścia
Strona była portfolio zbudowanym na FastAPI + Jinja2 z HTMX i Alpine.js zapewniającymi interaktywność. Początkowy mobilny audyt Lighthouse:
| Metryka | Wynik |
|---|---|
| Wydajność | 76 |
| Dostępność | 91 |
| Najlepsze praktyki | 100 |
| SEO | 100 |
| CLS | 0,493 |
Ten wynik CLS jest brutalny. Google uznaje wszystko powyżej 0,1 za „słabe”. Przy 0,493 strona widocznie skakała podczas ładowania.
Faza 1: Szybkie poprawki dostępności
Przed zajęciem się wydajnością uporządkowałem problemy z dostępnością, aby ustalić punkt odniesienia:
Etykiety formularzy
Zamiana dekoracyjnych elementów span na właściwe elementy <label>:
<!-- Before: span with aria-describedby -->
<span class="contact-form__label">Email</span>
<input id="email" aria-describedby="...">
<!-- After: proper label association -->
<label for="email" class="contact-form__label">Email</label>
<input id="email">
Współczynniki kontrastu
Nagłówki stopki i etykiety formularzy używały --color-text-tertiary (biały z 40% kryciem). Podniesiono do --color-text-secondary (65% krycia), aby spełnić wymogi kontrastu WCAG AA 4,5:1.
Zbędny tekst alternatywny
Ikony mediów społecznościowych miały tekst alt typu „LinkedIn icon”, będąc jednocześnie wewnątrz linków posiadających już aria-labels. Zmieniono na alt="" z aria-hidden="true", aby zapobiec powtórzeniom przez czytniki ekranowe.
Identyczny tekst linków
Wiele linków „View Case Study” było nierozróżnialnych dla czytników ekranowych. Dodano atrybuty aria-label z nazwami projektów:
<a href="/work/introl" aria-label="View Introl Branding case study">
View Case Study
</a>
Rezultat: Dostępność wzrosła z 91 do 100.
Faza 2: Problem blokującego renderowanie CSS
Lighthouse pokazał 2460 ms „Element render delay” w rozkładzie LCP. Winowajca: synchroniczny plik CSS blokujący pierwsze renderowanie.
<!-- The problem: render-blocking stylesheet -->
<link rel="stylesheet" href="/static/css/styles.min.css">
Przeglądarka musiała pobrać i przeanalizować cały arkusz stylów o rozmiarze 25 KB, zanim mogła cokolwiek wyświetlić.
Rozwiązanie: krytyczny CSS + asynchroniczne ładowanie
Krok 1: Wyodrębnienie krytycznego CSS (powyżej linii zagięcia) do osobnego pliku osadzanego bezpośrednio w <head>.
Krok 2: Asynchroniczne ładowanie pełnego arkusza stylów za pomocą sztuczki z media="print":
<!-- Critical CSS inlined for instant first paint -->
<style>{% include "components/_critical.css" %}</style>
<!-- Full CSS loaded async (doesn't block render) -->
<link rel="stylesheet" href="/static/css/styles.min.css"
media="print" onload="this.media='all'">
<noscript>
<link rel="stylesheet" href="/static/css/styles.min.css">
</noscript>
Atrybut media="print" informuje przeglądarkę, że „ten arkusz stylów nie jest potrzebny do renderowania ekranowego”, więc nie blokuje renderowania. Po załadowaniu handler onload przełącza go na media="all".
Skrypt wyodrębniający krytyczny CSS
Napisałem skrypt w Python, który automatycznie wyodrębnia krytyczny CSS dla komponentów powyżej linii zagięcia:
CRITICAL_PREFIXES = [
".container",
".header",
".nav",
".hero",
".personal-photos",
]
def extract_critical_css(css_content: str) -> str:
# Strip comments to prevent regex pollution
css_no_comments = re.sub(r'/\*[\s\S]*?\*/', '', css_content)
# Extract :root variables
# Extract base reset rules
# Extract component rules by prefix
# Extract media queries containing critical selectors
...
Rezultat: Opóźnienie renderowania elementu LCP spadło z 2460 ms do ~300 ms.
Faza 3: Początek koszmaru CLS
Po wdrożeniu asynchronicznego CSS wartość CLS faktycznie się pogorszyła — wzrosła do 0,119. Lighthouse wskazał <main> jako przesuwający się element.
Błąd nr 1: Komentarze CSS zanieczyszczające wyrażenia regularne
Skrypt wyodrębniający używał wyrażenia regularnego do dopasowywania selektorów:
rule_pattern = r"([.#\w][^{]+)\{([^}]+)\}"
Problem: komentarze CSS nie były usuwane przed wyodrębnianiem. Komentarz taki jak:
/* Hero Section - Editorial */
.hero { ... }
Powodował, że wyrażenie regularne dopasowywało tekst od „Hero Section” do nawiasu otwierającego, tworząc zniekształcone selektory, które nie przechodziły sprawdzania prefiksów. Krytyczne style .hero były po cichu pomijane.
Poprawka: Usunięcie komentarzy przed jakimikolwiek operacjami z wyrażeniami regularnymi:
css_no_comments = re.sub(r'/\*[\s\S]*?\*/', '', css_content)
Błąd nr 2: Reguły z media queries wyodrębniane jako samodzielne
Wyrażenie regularne dopasowywało reguły wewnątrz media queries i wyodrębniało je jako samodzielne reguły:
/* Full CSS structure */
.hero__title { font-size: 5rem; } /* Desktop */
@media (max-width: 768px) {
.hero__title { font-size: 1.875rem; } /* Mobile */
}
Skrypt wyodrębniał obie reguły na najwyższym poziomie:
/* Broken critical CSS */
.hero__title { font-size: 5rem; }
.hero__title { font-size: 1.875rem; } /* Overrides desktop! */
Na urządzeniach mobilnych tytuł renderował się w rozmiarze mobilnym z krytycznego CSS, a potem… zostawał taki sam, ponieważ pełny CSS miał te same reguły. Natomiast na desktopie mobilne nadpisanie stosowało się nieprawidłowo.
Poprawka: Śledzenie pozycji media queries i pomijanie reguł znajdujących się wewnątrz nich:
# Find all media query ranges
media_ranges = []
for match in re.finditer(media_pattern, css_no_comments):
media_ranges.append((match.start(), match.end()))
def is_inside_media_query(pos: int) -> bool:
return any(start <= pos < end for start, end in media_ranges)
# Skip rules inside media queries during extraction
for match in re.finditer(rule_pattern, css_no_comments):
if is_inside_media_query(match.start()):
continue # Will be included with full media query block
# ... extract rule
Faza 4: Hipoteza 100vh
CLS nadal wynosił 0,116. Teoria: jeśli nic poniżej linii zagięcia nie jest widoczne przy pierwszym renderowaniu, nie może wpływać na CLS.
Zmiana sekcji hero z 85vh na 100vh:
.hero {
/* 100vh ensures nothing below fold is visible on initial paint */
min-height: 100vh;
}
Rezultat: Brak zmian. CLS nadal 0,116. Przesunięcie odbywało się wewnątrz widocznego obszaru.
Faza 5: Dowód rzeczowy
Taśma filmowa Lighthouse pokazała, że tekst sekcji hero widocznie zmienia pozycję między klatkami. Nie zanikanie — przesuwanie się w poziomie.
Dogłębna analiza tego, jakie style wpływają na pozycjonowanie w poziomie:
- .hero__content używa padding: 0 var(--gutter)
- --gutter jest zdefiniowany jako 48px w :root
Wtedy to znalazłem:
/* In full CSS, but NOT in critical CSS */
@media (max-width: 768px) {
:root {
--gutter: var(--spacing-md); /* 24px */
}
}
Sekwencja zdarzeń na urządzeniu mobilnym:
- Ładuje się krytyczny CSS:
--gutter: 48px - Sekcja hero renderuje się z 48px bocznego paddingu
- Pełny CSS ładuje się asynchronicznie
- Media query ustawia
--gutter: 24px - Padding sekcji hero kurczy się z 48px do 24px
- Tekst się przeformatowuje i przesuwa = CLS 0,116
Poprawka
Wyodrębnienie media queries dla :root jako części krytycznego CSS:
# Extract :root media queries (variable overrides are critical)
root_media_pattern = r"@media[^{]+\{\s*:root\s*\{[^}]+\}\s*\}"
for match in re.finditer(root_media_pattern, css_no_comments):
critical_rules.append(match.group())
Teraz krytyczny CSS zawiera:
:root { --gutter: 48px; /* ... */ }
@media (max-width: 768px) {
:root { --gutter: var(--spacing-md); }
}
Na urządzeniu mobilnym strona od początku renderuje się z poprawnym gutterem 24px. Gdy ładuje się pełny CSS, --gutter wynosi już 24px — brak zmiany, brak przesunięcia.
Faza 6: CSP i Alpine.js
Jeszcze jeden problem: Alpine.js generował błędy w konsoli dotyczące naruszeń Content Security Policy. Alpine wewnętrznie korzysta z dynamicznej ewaluacji wyrażeń.
# In security headers middleware
CSP_DIRECTIVES = {
"script-src": "'self' 'unsafe-inline' 'unsafe-eval'",
# ...
}
Dyrektywa 'unsafe-eval' jest wymagana do parsowania wyrażeń przez Alpine.js. Alternatywą jest użycie buildu Alpine.js kompatybilnego z CSP, który ma pewne ograniczenia.
Wyniki końcowe
| Metryka | Przed | Po |
|---|---|---|
| Wydajność | 76 | 100 |
| Dostępność | 91 | 100 |
| Najlepsze praktyki | 100 | 100 |
| SEO | 100 | 100 |
| CLS | 0,493 | 0,033 |
| LCP | 2,6 s | 0,8 s |
Dowód
Audyt desktopowy pokazujący idealne wyniki we wszystkich czterech kategoriach:

Audyt mobilny — ten, który ma największe znaczenie — również osiągający idealne wyniki:

Kluczowe wnioski
1. Zmienne CSS mogą powodować CLS
Nadpisania niestandardowych właściwości CSS w media queries są niewidoczne w panelu stylów obliczonych DevTools — widoczna jest tylko końcowa wartość. Jeśli krytyczny CSS nie zawiera nadpisań zmiennych, układ przesunie się po załadowaniu pełnego arkusza stylów.
2. Wyodrębnianie krytycznego CSS jest skomplikowane
Proste podejścia oparte na wyrażeniach regularnych zawodzą w przypadku:
- Komentarzy CSS (mogą zanieczyszczać dopasowywanie selektorów)
- Reguł wewnątrz media queries (muszą pozostać wewnątrz swoich bloków)
- Media queries dla :root (zmiany zmiennych wpływają na wszystko)
3. Sztuczka z media="print" działa
Ładowanie niekrytycznego CSS za pomocą media="print" onload="this.media='all'" to sprawdzona metoda odraczania ładowania arkuszy stylów bez złożoności JavaScript.
4. Debugowanie CLS wymaga taśmy filmowej
Widok taśmy filmowej w Lighthouse pokazuje dokładnie, kiedy zachodzą przesunięcia. Bez niego pozostaje zgadywanie. Element wskazany jako „przesuwający się” może być jedynie objawem — należy szukać tego, co powoduje jego przesunięcie.
5. Sekcje hero na 100vh mają zalety wykraczające poza estetykę
Jeśli sekcja hero wypełnia cały widoczny obszar, treść poniżej linii zagięcia nie może wpływać na CLS, ponieważ nie jest mierzona do momentu przewinięcia przez użytkownika.
Stos technologiczny
- Backend: FastAPI + Jinja2
- Frontend: HTMX + Alpine.js (self-hosted, bez CDN)
- CSS: Czysty CSS z niestandardowymi właściwościami, bez preprocesorów
- Optymalizacja: Własny skrypt Python do wyodrębniania krytycznego CSS
- Hosting: Railway z kompresją GZip i niezmiennym buforowaniem
Użyte narzędzia
- Lighthouse (Chrome DevTools)
- Widok taśmy filmowej Lighthouse do debugowania CLS
grepiregex101.comdo analizy CSS- Python do skryptu wyodrębniającego krytyczny CSS
- iTerm 2
- Claude Code CLI z Opus 4.5 i dużą ilością ultrathink