← Wszystkie wpisy

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:

  1. Ładuje się krytyczny CSS: --gutter: 48px
  2. Sekcja hero renderuje się z 48px bocznego paddingu
  3. Pełny CSS ładuje się asynchronicznie
  4. Media query ustawia --gutter: 24px
  5. Padding sekcji hero kurczy się z 48px do 24px
  6. 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 Lighthouse na desktopie pokazujący wyniki 100/100/100/100

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

Audyt Lighthouse na urządzeniu mobilnym pokazujący wyniki 100/100/100/100


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

Powiązane artykuły

Midjourney V8 Killed Your V7 Workflow: What Actually Changed

V8 isn't a better V7. It's a different creative loop: personalization over prompting, native 2K, conversation mode, and …

15 min czytania

Every Iteration Makes Your Code Less Secure

43.7% of LLM iteration chains introduce more vulnerabilities than baseline. Adding SAST scanners makes it worse. SCAFFOL…

11 min czytania

Your Agent Sandbox Is a Suggestion

An attacker opened a GitHub issue and shipped malware in Cline's next release. Agent sandboxes fail at three levels. Her…

18 min czytania