Szybkie mega-menu na Shoper Storefront - jak naprawić zamulający hover
Mega-menu z 700 kategoriami potrafi zamulać przy najechaniu. Przyczyną nie jest „za duży sklep", tylko layout thrashing. Pokazuję, co go wywołuje i jak go usunąć bez utraty SEO.
Mega-menu w sklepie internetowym ma jedno zadanie: pokazać klientowi całą strukturę sklepu i nie przeszkadzać. W większości przypadków to się udaje. Problem zaczyna się wtedy, gdy menu robi się naprawdę duże, a sklep nadal ma działać płynnie.
W Shoperze ten ból widać wyjątkowo wyraźnie. Im głębsze i bardziej wielopoziomowe drzewo kategorii, tym mocniej dają się we znaki gotowe menu, które serwuje sam Shoper albo szablon - wdrożeniowcy regularnie wspominają, że przy większych katalogach robią się toporne i wolne. To nie jest „wina sklepu, który urósł”. To kwestia tego, jak takie menu jest zbudowane.
Opiszę realny przypadek z customowego modułu nawigacji dla Shoper Storefront. Menu renderowało całe drzewo kategorii sklepu: około 738 kategorii i blisko 1300 linków. Wszystko jako statyczny HTML po stronie serwera, czyli zwykłe linki <a href>, widoczne dla Googlebota i robotów LLM bez wykonywania JavaScriptu. Wydajnościowo i SEO-owo to był punkt wyjścia lepszy niż wiele gotowych szablonów.
A mimo to na desktopie pojawił się objaw, który łatwo zbagatelizować: lekkie zamulanie przy najeżdżaniu na sekcje menu. Strona ładowała się szybko, ale interakcja potrafiła „przyciąć”. To dobry przykład problemu, który nie wynika z „za dużego sklepu”, tylko z tego, jak przeglądarka liczy układ. I da się go naprawić bez rezygnacji z pełnej, indeksowalnej struktury.
Skąd się bierze zamulanie, którego nie widać w ładowaniu strony
Wydajność strony i wydajność interakcji to dwie różne rzeczy. Pierwsza ładowarka strony może być świetna, a mimo to pojedyncza akcja użytkownika potrafi zablokować wątek na kilkadziesiąt milisekund.
Przy najechaniu na sekcję mega-menu przeglądarka musi pokazać panel z kartami kategorii, ustawić ich szerokości, zmierzyć wysokości i ułożyć je w kolumnach. Przy kilkunastu kartach to niezauważalne. Przy drzewie 700+ kategorii i ponad tysiącu linków każda taka operacja robi się kosztowna.
Najważniejsze: koszt nie rośnie liniowo z liczbą kart, jeśli kod jest napisany nieoptymalnie. Rośnie znacznie szybciej, bo przeglądarka jest zmuszana do liczenia układu wielokrotnie zamiast raz.
Główny winowajca: layout thrashing
Layout thrashing to sytuacja, w której skrypt przeplata zapisywanie i odczytywanie właściwości układu w taki sposób, że zmusza przeglądarkę do synchronicznego przeliczania layoutu raz za razem.
Przeglądarka jest leniwa i to jej zaleta. Gdy zmieniasz styl elementu, ona nie liczy od razu nowego układu. Odkłada to na później i kumuluje zmiany, żeby przeliczyć wszystko jednym przebiegiem przed namalowaniem klatki. Ten przebieg nazywa się reflow.
Problem pojawia się, gdy w kodzie od razu po zapisie poprosisz o odczyt wartości zależnej od układu, na przykład offsetHeight. Przeglądarka nie ma wyboru: musi natychmiast przeliczyć layout, żeby zwrócić aktualną wysokość. Cała kumulacja idzie do kosza.
Jeden zapis i jeden odczyt to nic. Zapis i odczyt na przemian, w pętli po setkach elementów, to setki wymuszonych reflow pod rząd.
Dokładnie to robił skrypt układający karty w stylu masonry, czyli dopasowujący kolumny według wysokości kart. W jednej pętli, dla każdej karty po kolei: ustaw szerokość i pozycję, a zaraz potem odczytaj jej wysokość przez offsetHeight. Każdy taki odczyt wymuszał przeliczenie układu całego dużego menu. Przy ponad tysiącu elementów to przestaje być detal.
Schematycznie wyglądało to tak:
// Antywzorzec: zapis i odczyt przeplatane w pętli
cards.forEach((card) => {
card.style.width = columnWidth + 'px'; // zapis (zmienia layout)
const h = card.offsetHeight; // odczyt -> wymusza reflow TERAZ
card.style.transform = positionFor(h); // zapis ...
});
To wygląda niewinnie. Trzy linijki, jedna pętla. A przy dużym DOM to jest właśnie to miejsce, w którym hover zaczyna „przycinać”.
Drugi winowajca: liczenie układu paneli, których nikt nie widzi
Layout thrashing był przyczyną główną, ale nie jedyną. Drugi problem dotyczył tego, ile razy ta kosztowna praca się wykonywała.
Menu liczyło układ kart dla wszystkich paneli sekcji przy najechaniu, także tych niewidocznych. Powód jest podstępny: panele były ukrywane przez visibility: hidden, a nie display: none. To istotna różnica.
display: nonewyjmuje element z układu. Przeglądarka go nie liczy.visibility: hiddenzostawia element w układzie. Jest niewidoczny, ale nadal ma wymiary, nadal zajmuje miejsce i nadal trzeba dla niego liczyć layout.
Efekt: praca, która i tak była zbyt droga przez layout thrashing, była dodatkowo zwielokrotniana przez panele, których użytkownik w danym momencie w ogóle nie oglądał.
Do tego dochodziły czynniki współwinne, które same w sobie nie kładły wydajności, ale podbijały koszt każdego przeliczenia: bardzo duży DOM oraz selektory CSS :has(), które przeglądarka musi przeliczać przy każdej zmianie stanu hover. Im więcej elementów i im bardziej złożone reguły, tym droższy pojedynczy reflow, który i tak wykonywał się zbyt często.
Naprawa, część pierwsza: batchowanie odczytów i zapisów
Pierwsza zmiana nie dotyczyła „mniej kart” ani „prostszego menu”. Dotyczyła kolejności operacji.
Zasada jest prosta: najpierw wszystkie odczyty, potem wszystkie zapisy. Jeśli nie przeplatasz tych dwóch faz, przeglądarka może skumulować zmiany i przeliczyć układ jeden raz zamiast raz na element.
Algorytm masonry rozbiłem na trzy fazy:
- Ustaw szerokości wszystkich kart (faza zapisu).
- Zmierz wysokości wszystkich kart jednym przebiegiem (faza odczytu).
- Ustaw pozycje wszystkich kart na podstawie zebranych wysokości (faza zapisu).
Kluczowe jest to, że krok drugi czyta wysokości dopiero po tym, jak wszystkie szerokości są już ustawione. Jeden wymuszony reflow obsługuje cały pomiar, zamiast jednego reflow na kartę.
// Wzorzec: rozdziel fazy zapisu i odczytu
cards.forEach((card) => {
card.style.width = columnWidth + 'px'; // faza 1: same zapisy
});
const heights = cards.map((card) => card.offsetHeight); // faza 2: jeden reflow na cały pomiar
cards.forEach((card, i) => {
card.style.transform = positionFor(heights[i]); // faza 3: same zapisy
});
To ten sam efekt wizualny i ten sam układ kart. Zmienia się wyłącznie to, ile razy przeglądarka liczy layout. Z liczby proporcjonalnej do liczby kart schodzimy do jednego przeliczenia na całą operację.
Naprawa, część druga: licz tylko to, co widać, i licz to raz
Druga zmiana adresowała problem zwielokrotnienia. Skoro panele niewidoczne i tak nie są oglądane, nie ma powodu liczyć ich układu z góry.
Wprowadziłem leniwe liczenie z cache. Układ danego panelu liczony jest dopiero przy jego pierwszym pokazaniu, a nie na starcie strony i nie dla całej sekcji naraz. Po policzeniu wynik trafia do cache i przy kolejnych najechaniach panel pojawia się od razu, bez ponownej pracy.
Dwa szczegóły, które robią różnicę:
- Liczymy układ konkretnego panelu, który użytkownik właśnie otwiera, a nie wszystkich paneli sekcji. Praca zawęża się do tego, co naprawdę jest na ekranie.
- Resize okna nie przelicza wszystkiego natychmiast. On tylko unieważnia cache. Ponowne policzenie następuje dopiero przy następnym pokazaniu panelu, czyli znowu wtedy, gdy jest faktycznie potrzebne.
Dzięki temu start strony nie płaci za układ menu, którego nikt jeszcze nie otworzył, a powtórne najechania są praktycznie darmowe.
Efekt: płynny hover przy drzewie 700+ kategorii
Po obu zmianach hover stał się płynny nawet przy pełnym drzewie kategorii. Menu reaguje od razu, panele pojawiają się bez przycięcia, a powtórne wejścia w tę samą sekcję są natychmiastowe dzięki cache.
Co ważne, nie trzeba było niczego upraszczać kosztem klienta ani robota wyszukiwarki. Liczba kategorii została. Pełny statyczny HTML z prawdziwymi linkami został. Zmieniła się tylko inżynieria interakcji: kiedy i ile razy przeglądarka liczy układ. To dokładnie ten rodzaj pracy, którą trudno zobaczyć w gotowym szablonie, dopóki ktoś nie zajrzy w to, co dzieje się przy hover. Gotowe, wielopoziomowe menu w Shoperze zwykle przegrywa właśnie tutaj: nie przy ładowaniu strony, tylko przy interakcji, gdy liczy układ zbyt często i dla zbyt wielu rzeczy naraz.
Dlaczego nie poszedłem w „lekkie” menu doładowywane JavaScriptem
Najprostszy sposób na szybkie menu to nie renderować wszystkiego od razu. Można doładowywać kategorie skryptem po kliknięciu i trzymać front lekki. Tylko że w sklepie internetowym ta decyzja ma cenę, której nie widać od razu.
Menu to nie wyłącznie interfejs. To także linkowanie wewnętrzne. Jeśli kategorie istnieją w HTML jako prawdziwe linki, strukturę sklepu czyta nie tylko użytkownik, ale i robot wyszukiwarki oraz crawler modelu językowego, bez wykonywania JavaScriptu. Menu dociągane skryptem często tę widoczność traci.
To ten sam typ kompromisu, który wraca w pracy z zamkniętym frontem Shoper Storefront: co można odłożyć na później, a co musi być w HTML od razu. Tutaj odpowiedź była jasna. Linki muszą zostać w HTML ze względu na SEO i GEO. Optymalizować trzeba więc nie strukturę, tylko koszt interakcji. Layout thrashing dało się usunąć bez ruszania jednego linka.
Co z tego ma czytelnik, który nie pisze frontu
Janky menu rzadko trafia do raportu jako problem. Klient nie napisze, że sklep ma layout thrashing przy hover. On poczuje, że „coś tu mule”, i część użytkowników wyjdzie bez słowa. To realnie tracone konwersje, tylko trudniej je przypisać do przyczyny niż spadek z konkretnej kampanii.
Dlatego nawigację warto traktować jak element sprzedaży, a nie ozdobę szablonu. Wolne menu pogarsza odczucie sklepu, a menu dociągane skryptem osłabia indeksację. Jedno uderza w konwersję, drugie w widoczność.
Wniosek jest ten sam, który stoi za specjalizacją w Shoperze: platforma daje ramy, a różnicę robi inżynieria w tych ramach, czy chodzi o server-side tracking, czy o płynny front sklepu. Ta sama staranność, którą wkłada się w poprawny pomiar i tagowanie, opłaca się też na froncie. Bo dane bez płynnego sklepu pokażą tylko, gdzie klienci odpadają. Takie mega-menu i inne elementy frontu Storefront przebudowuję na zlecenie - zobacz dodatki i przebudowę frontu Shopera.
Podsumowanie
Duże mega-menu nie musi zamulać. „Za dużo kategorii” prawie nigdy nie jest prawdziwą przyczyną. Prawdziwą przyczyną jest to, jak kod każe przeglądarce liczyć układ: przeplatane zapisy i odczyty, liczenie paneli, których nikt nie widzi, i przeliczanie wszystkiego z góry zamiast na żądanie.
Naprawa nie polegała na obcinaniu zawartości. Polegała na rozdzieleniu odczytów i zapisów na fazy, na liczeniu tylko widocznego panelu i na cache. Struktura, linki i SEO zostały nietknięte, a hover stał się płynny.
Wydajność menu to nie kwestia liczby kategorii, tylko kolejności, w jakiej każesz przeglądarce liczyć układ.