- 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:
- Wypychasz zmianę → działa na stagingu
- Deploy na produkcję → wysypuje się jakiś edge case
- Szybki revert → produkcja znów stabilna
- 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:
- Powtórzona logika: wyliczanie wysokości było skopiowane w 3 miejscach
- Wyłaniająca się maszyna stanów:
isOpen,isAnimatingiisLoadingpowinny być jednym stanem - 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:
| Metryka | Start | Koniec |
|---|---|---|
| Zgłoszenia problemów z nawigacją | 12/mies. | 0/mies. |
| Bounce rate na mobile na stronach nawigacji | 34% | 18% |
| Time to interactive (nawigacja) | 450ms | 180ms |
| Rozmiar komponentu w bundle'u | 42KB | 28KB |
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.