Od 76 do 100: Osiągnięcie idealnego wyniku Lighthouse
TL;DR: Osobista strona portfolio przeszła z wyniku wydajności mobilnej Lighthouse 76 z CLS 0.493 do idealnych 100/100/100/100 we wszystkich kategoriach. Ta podróż ujawniła subtelne problemy z ładowaniem CSS, błędy w wyrażeniach regularnych i 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 opartym na FastAPI + Jinja2 z HTMX i Alpine.js dla interaktywności. Początkowy audyt mobilny Lighthouse:
| Metryka | Wynik |
|---|---|
| Wydajność | 76 |
| Dostępność | 91 |
| Najlepsze praktyki | 100 |
| SEO | 100 |
| CLS | 0.493 |
Ta wartość CLS jest brutalna. Google uznaje wszystko powyżej 0.1 za „słabe”. Przy 0.493 strona wyraźnie 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
Zmiana dekoracyjnych spanów na właściwe elementy <label>:
<!-- Przed: span z aria-describedby -->
<span class="contact-form__label">Email</span>
<input id="email" aria-describedby="...">
<!-- Po: właściwe powiązanie etykiety -->
<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% przezroczystości). Zwiększono do --color-text-secondary (65% przezroczystości), aby spełnić wymogi kontrastu WCAG AA 4.5:1.
Nadmiarowy tekst alternatywny
Ikony mediów społecznościowych miały tekst alternatywny typu „Ikona LinkedIn”, gdy znajdowały się wewnątrz linków, które już miały aria-labels. Zmieniono na alt="" z aria-hidden="true", aby zapobiec powtórzeniom przez czytniki ekranu.
Identyczny tekst linków
Wiele linków „Zobacz Case Study” było nierozróżnialnych dla czytników ekranu. Dodano atrybuty aria-label z nazwami projektów:
<a href="/work/introl" aria-label="Zobacz case study brandingu Introl">
Zobacz Case Study
</a>
Wynik: Dostępność skoczyła z 91 do 100.
Faza 2: Problem blokującego renderowanie CSS
Lighthouse pokazał 2460 ms „Opóźnienia renderowania elementu” w rozkładzie LCP. Winowajca: synchroniczny plik CSS blokujący pierwsze malowanie.
<!-- Problem: arkusz stylów blokujący renderowanie -->
<link rel="stylesheet" href="/static/css/styles.min.css">
Przeglądarka musiała pobrać i przeanalizować cały arkusz stylów o rozmiarze 25KB przed namalowaniem czegokolwiek.
Rozwiązanie: Krytyczny CSS + asynchroniczne ładowanie
Krok 1: Wyodrębnij krytyczny (powyżej linii zagięcia) CSS do oddzielnego pliku, który jest osadzony w <head>.
Krok 2: Załaduj pełny arkusz stylów asynchronicznie, używając sztuczki media="print":
<!-- Krytyczny CSS osadzony dla natychmiastowego pierwszego malowania -->
<style>{% include "components/_critical.css" %}</style>
<!-- Pełny CSS ładowany asynchronicznie (nie blokuje renderowania) -->
<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" mówi przeglądarce „ten arkusz stylów nie jest potrzebny do renderowania ekranu”, więc nie blokuje. Po załadowaniu handler onload przełącza go na media="all".
Skrypt ekstrakcji krytycznego CSS
Napisałem skrypt w Pythonie do automatycznego wyodrębniania krytycznego 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:
# Usuń komentarze, aby zapobiec zanieczyszczeniu wyrażeń regularnych
css_no_comments = re.sub(r'/\*[\s\S]*?\*/', '', css_content)
# Wyodrębnij zmienne :root
# Wyodrębnij podstawowe reguły resetowania
# Wyodrębnij reguły komponentów według prefiksu
# Wyodrębnij zapytania media zawierające krytyczne selektory
...
Wynik: Opóźnienie renderowania elementu LCP spadło z 2460 ms do ~300 ms.
Faza 3: Koszmar CLS się zaczyna
Po wdrożeniu asynchronicznego CSS, CLS faktycznie się pogorszył—skoczył do 0.119. Lighthouse pokazał <main> jako przesuwający się element.
Błąd #1: Komentarze CSS zanieczyszczające wyrażenia regularne
Skrypt ekstrakcji używał wyrażenia regularnego do dopasowywania selektorów:
rule_pattern = r"([.#\w][^{]+)\{([^}]+)\}"
Problem: Komentarze CSS nie były usuwane przed ekstrakcją. Komentarz taki jak:
/* Hero Section - Editorial */
.hero { ... }
Powodował, że wyrażenie regularne dopasowywało od „Hero Section” do otwierającego nawiasu klamrowego, tworząc zniekształcone selektory, które nie przechodziły sprawdzenia prefiksu. Krytyczne style .hero były po cichu pomijane.
Poprawka: Usuń komentarze przed jakimikolwiek operacjami na wyrażeniach regularnych:
css_no_comments = re.sub(r'/\*[\s\S]*?\*/', '', css_content)
Błąd #2: Reguły zapytań media wyodrębniane jako samodzielne
Wyrażenie regularne dopasowywało reguły wewnątrz zapytań media i wyodrębniało je jako samodzielne reguły:
/* Pełna struktura CSS */
.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:
/* Uszkodzony krytyczny CSS */
.hero__title { font-size: 5rem; }
.hero__title { font-size: 1.875rem; } /* Nadpisuje desktop! */
Na urządzeniach mobilnych tytuł renderował się w rozmiarze mobilnym z krytycznego CSS, a potem… zostawał taki, ponieważ pełny CSS miał te same reguły. Ale na desktopie mobilne nadpisanie było nieprawidłowo stosowane.
Poprawka: Śledź pozycje zapytań media i pomijaj reguły wewnątrz nich:
# Znajdź wszystkie zakresy zapytań media
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)
# Pomijaj reguły wewnątrz zapytań media podczas ekstrakcji
for match in re.finditer(rule_pattern, css_no_comments):
if is_inside_media_query(match.start()):
continue # Zostanie uwzględnione z pełnym blokiem zapytania media
# ... wyodrębnij regułę
Faza 4: Hipoteza 100vh
CLS nadal wynosił 0.116. Teoria: jeśli nic poniżej linii zagięcia nie jest widoczne przy początkowym malowaniu, nie może przyczyniać się do CLS.
Zmiana hero z 85vh na 100vh:
.hero {
/* 100vh zapewnia, że nic poniżej zagięcia nie jest widoczne przy początkowym malowaniu */
min-height: 100vh;
}
Wynik: Brak zmiany. CLS nadal 0.116. Przesunięcie następowało wewnątrz widocznego obszaru.
Faza 5: Dymiąca lufa
Taśma filmowa Lighthouse pokazała tekst hero wyraźnie przesuwający pozycję między klatkami. Nie zanikanie—przesuwanie się poziomo.
Dogłębna analiza stylów wpływających na pozycjonowanie poziome:
- .hero__content używa padding: 0 var(--gutter)
- --gutter jest zdefiniowany jako 48px w :root
Potem to znalazłem:
/* W pełnym CSS, ale NIE w krytycznym CSS */
@media (max-width: 768px) {
:root {
--gutter: var(--spacing-md); /* 24px */
}
}
Sekwencja na urządzeniach mobilnych:
- Ładuje się krytyczny CSS:
--gutter: 48px - Hero renderuje się z 48px bocznego paddingu
- Asynchronicznie ładuje się pełny CSS
- Zapytanie media ustawia
--gutter: 24px - Padding hero kurczy się z 48px do 24px
- Tekst się przeformatowuje i przesuwa = CLS 0.116
Poprawka
Wyodrębnij zapytania media :root jako część krytycznego CSS:
# Wyodrębnij zapytania media :root (nadpisania zmiennych są krytyczne)
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); }
}
Mobile renderuje się z prawidłowym 24px gutter od początku. Gdy ładuje się pełny CSS, --gutter już wynosi 24px—brak zmiany, brak przesunięcia.
Faza 6: CSP i Alpine.js
Jeszcze jeden problem: Alpine.js wyrzucał błędy konsoli dotyczące naruszeń Content Security Policy. Alpine wewnętrznie używa dynamicznej ewaluacji wyrażeń.
# W middleware nagłówków bezpieczeństwa
CSP_DIRECTIVES = {
"script-src": "'self' 'unsafe-inline' 'unsafe-eval'",
# ...
}
Dyrektywa 'unsafe-eval' jest wymagana do parsowania wyrażeń Alpine.js. Alternatywą jest użycie buildu Alpine kompatybilnego z CSP, który ma pewne ograniczenia.
Końcowe wyniki
| Metryka | Przed | Po |
|---|---|---|
| Wydajność | 76 | 100 |
| Dostępność | 91 | 100 |
| Najlepsze praktyki | 100 | 100 |
| SEO | 100 | 100 |
| CLS | 0.493 | 0.033 |
| LCP | 2.6s | 0.8s |
Dowód
Audyt desktopowy pokazujący idealne wyniki we wszystkich czterech kategoriach:

Audyt mobilny—ten, który liczy się najbardziej—również osiągający idealne wyniki:

Kluczowe wnioski
1. Zmienne CSS mogą powodować CLS
Nadpisania zmiennych niestandardowych CSS w zapytaniach media są niewidoczne w panelu obliczonych stylów DevTools—widzisz tylko końcową wartość. Jeśli twój krytyczny CSS nie zawiera nadpisań zmiennych, układ przesunie się, gdy załaduje się pełny arkusz stylów.
2. Ekstrakcja krytycznego CSS jest trudna
Proste podejścia z wyrażeniami regularnymi zawodzą przy:
- Komentarzach CSS (mogą zanieczyszczać dopasowywanie selektorów)
- Regułach wewnątrz zapytań media (muszą pozostać w swoich blokach)
- Zapytaniach media :root (zmiany zmiennych wpływają na wszystko)
3. Sztuczka media="print" działa
Ładowanie niekrytycznego CSS z media="print" onload="this.media='all'" to uzasadniony sposób na odroczenie ładowania arkuszy stylów bez złożoności JavaScript.
4. Debugowanie CLS wymaga taśmy filmowej
Widok taśmy filmowej Lighthouse pokazuje dokładnie, kiedy następują przesunięcia. Bez niego zgadujesz. Element podświetlony jako „przesuwający się” może być tylko objawem—szukaj tego, co powoduje jego przesunięcie.
5. Sekcje hero 100vh mają korzyści wykraczające poza estetykę
Jeśli twoje hero wypełnia widoczny obszar, zawartość poniżej zagięcia nie może przyczyniać się do CLS, ponieważ nie jest mierzona, dopóki użytkownik nie przewinie.
Stos technologiczny
- Backend: FastAPI + Jinja2
- Frontend: HTMX + Alpine.js (self-hosted, bez CDN)
- CSS: Czysty CSS ze zmiennymi niestandardowymi, bez preprocesorów
- Optymalizacja: Niestandardowy skrypt Python do ekstrakcji krytycznego CSS
- Hosting: Railway z kompresją GZip i niezmiennym cache’owaniem
Użyte narzędzia
- Lighthouse (Chrome DevTools)
- Widok taśmy filmowej Lighthouse do debugowania CLS
grepiregex101.comdo analizy CSS- Python do skryptu ekstrakcji krytycznego CSS
- iTerm 2
- Claude Code CLI z Opus 4.5 i dużą ilością ultrathink