Kamil Owczarek
Opublikowano

MegaMenu, które zajęło 50 commitów: lekcja o złożoności komponentów

Autorzy

Liczby nie kłamią

Odpaliłem git log na naszym komponencie MegaMenu:

git log --oneline --since="3 months ago" -- src/components/MegaMenu.vue | wc -l
# 50

Pięćdziesiąt commitów. W jednym komponencie. W trzy miesiące.

Czyli mniej więcej jeden commit co dwa dni, wciąż w tym samym pliku. W niektóre dni było ich kilka. W innych zdarzały się cykle revert, a potem reapply. Bałagan.

Ale sedno jest takie: ten komponent działa dziś świetnie. Użytkownicy lubią nawigację. Kod da się utrzymywać. Te 50 commitów nie poszło na marne — to był po prostu proces.

Wzorce revertów

Przeglądając historię gita, znalazłem kilka cykli „Revert X” → „Reapply X”:

7816b65 Update MegaMenu.vue
21165e7 Revert "Update MegaMenu.vue"
082dc64 Update MegaMenu.vue
d57502c Revert "Update MegaMenu.vue"
3a4288c Reapply "Update MegaMenu.vue"
...

To nie jest oznaka słabego planowania. To zwyczajny development w prawdziwym świecie:

  1. Wypychasz zmianę → działa na stagingu
  2. Deploy na produkcję → wysypuje się jakiś edge case
  3. Szybki revert → produkcja znów stabilna
  4. Naprawiasz edge case → reapply razem z poprawką

Alternatywa — wyłapanie każdego edge case'a przed wypuszczeniem — oznaczałaby półroczną fazę projektowania zamiast iterowania na realnym feedbacku użytkowników.

Co czyniło ten komponent złożonym

MegaMenu są zwodniczo trudne:

1. Dynamiczne wyliczanie wysokości

<script setup>
const baseModalHeight = computed(() => {
  // Calculate based on content
  return contentItems.value.length * ITEM_HEIGHT + PADDING
})

const modalHeight = ref(`${baseModalHeight.value}px`)

onMounted(() => {
  const updateHeight = () => {
    const calculated = baseModalHeight.value
    const viewport = window.innerHeight
    const max = Math.min(calculated, viewport * 0.8)
    modalHeight.value = `${max}px`
  }

  updateHeight()
  window.addEventListener('resize', updateHeight)
})
</script>

Wysokość musi:

  • Zmieścić treść
  • Nie wyjść poza viewport
  • Aktualizować się przy resize
  • Nie powodować layout shiftów

Każdy z tych wymogów dokłada złożoności. Każdy edge case (mobile, tablet, dziwne proporcje viewportu) wymagał osobnych testów.

2. Zarządzanie stanem hovera

Kiedy pokazać dropdown? Kiedy go schować?

<script setup>
let hoverTimeout = null

const handleMouseEnter = () => {
  clearTimeout(hoverTimeout)
  isOpen.value = true
}

const handleMouseLeave = () => {
  // Delay closing so users can move to submenu
  hoverTimeout = setTimeout(() => {
    isOpen.value = false
  }, 150)
}
</script>

Za krótkie opóźnienie → zamyka się, zanim użytkownik dojedzie do submenu Za długie opóźnienie → sprawia wrażenie, że nic nie reaguje Brak opóźnienia → chaos przy każdym mouseoverze

Zmienialiśmy to co najmniej 5 razy.

3. Zachowanie na urządzeniach dotykowych

Na mobile użytkownicy tapują, nie najeżdżają kursorem. Ten sam komponent potrzebuje zupełnie innej interakcji:

<script setup>
const isTouchDevice = ref(false)

onMounted(() => {
  isTouchDevice.value = 'ontouchstart' in window
})

const handleClick = (category) => {
  if (isTouchDevice.value) {
    if (activeCategory.value === category.id) {
      // Already open, navigate
      navigateTo(category.path)
    } else {
      // First tap opens, second navigates
      activeCategory.value = category.id
    }
  } else {
    // Desktop: always navigate
    navigateTo(category.path)
  }
}
</script>

4. Animacja plus ładowanie treści

Menu musi:

  • Płynnie animować otwarcie
  • Załadować treść (asynchronicznie)
  • Nie migotać (żadnych layout shiftów w trakcie ładowania)
<script setup>
const menuState = ref('closed') // 'closed' | 'opening' | 'open' | 'closing'

const open = async () => {
  menuState.value = 'opening'
  await loadContent()
  await nextTick()
  menuState.value = 'open'
}
</script>

Bug w cyklu życia komponentu

Commit 63a72ef naprawił subtelny błąd:

Przed (źle)

onMounted(() => {
  const updateHeight = () => {
    /* ... */
  }
  window.addEventListener('resize', updateHeight)

  onUnmounted(() => {
    window.removeEventListener('resize', updateHeight)
  })
})

Po (poprawnie)

let updateHeight: (() => void) | null = null

onMounted(() => {
  updateHeight = () => {
    /* ... */
  }
  window.addEventListener('resize', updateHeight)
})

onUnmounted(() => {
  if (updateHeight) {
    window.removeEventListener('resize', updateHeight)
  }
})

Zagnieżdżenie onUnmounted wewnątrz onMounted łapie niewłaściwy kontekst cyklu życia. Funkcja sprzątająca może się w ogóle nie wykonać albo wykonać w złym momencie.

Ten błąd powodował wycieki pamięci i „duchy” w postaci wiszących event handlerów. Znalezienie go i naprawa zajęły 3 commity.

Kiedy NIE refaktoryzować

Około trzydziestego commita komponent zaczynał być nie do ogarnięcia. Pokusa, żeby „przepisać wszystko od zera”, była silna.

Nie uległem. Oto dlaczego:

Komponent dowoził wartość

Każdy commit naprawiał realny problem użytkowników albo dodawał zamówioną funkcję. Zatrzymanie się na refactor oznaczałoby:

  • Opóźnienie poprawek
  • Zepsucie działającego zachowania
  • Ryzyko regresji

Refactor bez testów jest niebezpieczny

Nie mieliśmy kompletnych testów dla każdego stanu interakcji. Refactor wprowadziłby błędy, których nikt by nie wyłapał.

„Bałagan” to nie to samo co „zepsute”

Kod działał. Użytkownicy nie narzekali na nawigację. Bałagan był wewnętrzny — widoczny dla programistów, niewidoczny dla użytkowników.

Kiedy JEDNAK refaktoryzować

Około czterdziestego piątego commita wzorce stały się oczywiste:

  1. Powtórzona logika: wyliczanie wysokości było skopiowane w 3 miejscach
  2. Wyłaniająca się maszyna stanów: isOpen, isAnimating i isLoading powinny być jednym stanem
  3. Wyraźne granice: obsługa dotyku powinna być osobnym composable'em

Wtedy wyodrębniliśmy:

// composables/useMegaMenuState.ts
export function useMegaMenuState() {
  const state = ref<'closed' | 'opening' | 'open' | 'closing'>('closed')

  const open = async () => {
    /* ... */
  }
  const close = async () => {
    /* ... */
  }

  return { state, open, close }
}

// composables/useTouchInteraction.ts
export function useTouchInteraction() {
  const isTouchDevice = ref(false)
  // ...
  return { isTouchDevice, handleTap }
}

Refactor przyszedł PO tym, jak wzorce się potwierdziły, a nie przed.

Problem z opisami commitów

Patrząc wstecz, spora część commitów to po prostu „Update MegaMenu.vue”. Zero wartości.

Podejście, które stosuję teraz:

git commit -m "MegaMenu: Fix hover timeout on submenu transition"
git commit -m "MegaMenu: Handle touch device first-tap-opens pattern"
git commit -m "MegaMenu: Extract height calculation to computed"

Za pół roku sam zrozumiem, co się zmieniło, bez wczytywania się w diffy.

Metryki, które miały znaczenie

Mimo bałaganiarskiej historii komponent stawał się coraz lepszy:

MetrykaStartKoniec
Zgłoszenia problemów z nawigacją12/mies.0/mies.
Bounce rate na mobile na stronach nawigacji34%18%
Time to interactive (nawigacja)450ms180ms
Rozmiar komponentu w bundle'u42KB28KB

Te 50 commitów to nie był chaos — to był postęp.

Wnioski

1. Iteracja to nie porażka

50 commitów to 50 usprawnień. Każde robiło komponent lepszym. „Perfekcyjnie za pierwszym razem” to mit.

2. Revertuj wcześnie, revertuj często

Gdy produkcja się sypie, najpierw revert, debugowanie potem. Użytkownika nie obchodzi twoja poprawka — obchodzi go działający soft.

3. Refaktoryzuj, gdy wzorce są już widoczne

Nie refaktoryzuj na zapas. Poczekaj, aż zaimplementujesz daną rzecz 3 razy i zobaczysz, co naprawdę warto wyabstrahować.

4. Złożone interakcje wymagają iteracji

Stany hovera, obsługa dotyku, animacje, stany ładowania — każde z nich ma edge case'y, których nie przewidzisz. Wypuść, obserwuj, popraw.

5. Śledź, które komponenty zmieniają się najczęściej

# Which components change most?
git log --oneline --since="3 months ago" -- "*.vue" | \
    sed 's/.*\(src\/.*\.vue\)/\1/' | \
    sort | uniq -c | sort -rn | head -10

Komponenty, które zmieniają się najczęściej, zasługują na dodatkową uwagę — albo je uprość, albo zainwestuj w testy.

Komponent dzisiaj

Po 50 commitach MegaMenu.vue to:

  • 380 linii (z 520 w szczytowym momencie)
  • Zero zgłoszonych problemów w ostatnim miesiącu
  • Logika wyniesiona do 2 composable'i
  • Pełne typowanie
  • Dokumentacja w komentarzach w kodzie

Czy zrobiłbym to inaczej? Może. Ale to iteracyjne podejście doprowadziło nas tutaj, dowożąc wartość po drodze. Użytkownicy nie czekali miesiącami na „idealną nawigację”. Dostawali usprawnienia co tydzień.

I to jest prawdziwa lekcja: 50 bałaganiarskich commitów, które dowożą wartość, bije 1 perfekcyjny commit, który nie dowozi jej nigdy.


Prawdziwa historia commitów z produkcyjnego sklepu e-commerce. MegaMenu obsługuje dziś miliony interakcji nawigacyjnych miesięcznie.