← Wszystkie wpisy

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:

  1. Ładuje się krytyczny CSS: --gutter: 48px
  2. Hero renderuje się z 48px bocznego paddingu
  3. Asynchronicznie ładuje się pełny CSS
  4. Zapytanie media ustawia --gutter: 24px
  5. Padding hero kurczy się z 48px do 24px
  6. 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 Lighthouse desktop pokazujący wyniki 100/100/100/100

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

Audyt Lighthouse mobile pokazujący wyniki 100/100/100/100


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
  • grep i regex101.com do analizy CSS
  • Python do skryptu ekstrakcji krytycznego CSS
  • iTerm 2
  • Claude Code CLI z Opus 4.5 i dużą ilością ultrathink