Dotarł raport o błędzie, który przeczył każdemu instynktowi debugowania. Użytkownicy budżetowych telefonów z Androidem twierdzili, że aplikacja po prostu znika. Nie przy uruchomieniu. Nie podczas konkretnego dotknięcia czy przesunięcia. Około dwudziestu minut po rozpoczęciu sesji ekran zamarzał, a proces ginął. Logi były nieskazitelne. QA nie potrafiło odtworzyć tego błędu na swoim wysokiej klasy sprzęcie. Nie było żadnych kroków, które można by podjąć. Po trzech godzinach profilowania pamięci obraz w końcu stał się jasny. Pojedynczy listener zdarzeń znajdował się wewnątrz hooka Reacta. Ten listener zamknął w domknięciu (closure) duży zestaw danych. Komponent odmontował się (unmounted). Listener pozostał. Zestaw danych pozostał w pamięci. Na urządzeniu z 2 GB RAM takie nagromadzenie wyczerpało stertę (heap), a system operacyjny zabił aplikację. To nie był błąd składni ani błąd logiczny. To był błąd zakresu (scope bug) i był on fatalny w skutkach.

Jak domknięcie staje się wyciekiem

Większość samouczków uczy o zakresie (scope) jako o akademickiej zagadce dotyczącej tego, gdzie zmienna jest widoczna. W środowisku produkcyjnym zakres jest kontraktem dotyczącym czasu życia pamięci. Gdy funkcja JavaScript tworzy domknięcie nad zmienną, silnik utrzymuje tę zmienną przy życiu tak długo, jak długo można dotrzeć do samego domknięcia. W komponencie React oznacza to, że Twoje dane przetrwają długo po tym, jak użytkownik odejdzie z danej strony, a węzeł UI zostanie usunięty.

Rozważmy hook, który rejestruje listener na obiekcie window. Komponent renderuje się, podczepia listener, a później odmontowuje. Jeśli faza czyszczenia (cleanup) zostanie pominięta lub przeprowadzona nieprawidłowo, listener pozostaje. Każde świeże zamontowanie dodaje kolejną widmową kopię uwięzionych danych do RAM-u. Na stacji roboczej programisty z obfitą pamięcią możesz nigdy nie zauważyć tego przeładowania. Na budżetowym telefonie z Android Go dwadzieścia minut zwykłego użytkowania wystarczy, aby wyczerpać dostępną stertę. System operacyjny interweniuje i przerywa proces. Nie ma żadnego wyjątku do zalogowania. System po prostu odcina zasilanie.

To dlatego zakres jest zarządzaniem pamięcią. Środowisko leksykalne nie jest granicą filozoficzną. Jest grafem retencji. Każda zmienna, którą zostawisz w nieodśmieconym domknięciu, jest cegłą w murze, który ostatecznie zamknie Twoją aplikację w pułapce.

Trzy sposoby, w jakie zakres niszczy aplikacje produkcyjne

Problemy z zakresem nie zawsze wyglądają tak samo. Niektóre powoli drenują pamięć. Inne powodują natychmiastową awarię. Oto wzorce, które niezawodnie kładą aplikacje.

Zanieczyszczenie zakresu globalnego

Architektury mikrofrontendowe pozwalają zespołom na niezależne wdrażanie zmian, ale wszystkie one współdzielą ten sam obiekt window. Gdy jedna aplikacja ustawia zmienną globalną, taką jak window.config, lub nakłada (patchuje) współdzielone narzędzie na window, nie żyje ona w izolacji. Aplikacja innego zespołu może zależeć od innej struktury tej samej zmiennej globalnej lub nadpisać ją podczas własnego procesu bootstrapowania. Rezultatem jest kolizja funkcji, która skaluje się wraz z organizacją. Programista w jednym repozytorium nie ma pojęcia, że jego skrót myślowy jest dla innego zespołu zmianą łamiącą wsteczną kompatybilność (breaking change). W miarę jak powierzchnia styku rośnie, te zmienne globalne stają się minami ukrytymi w wspólnej ziemi.

Wycieki pamięci przez domknięcia

Aplikacje typu Single Page Application są projektowane tak, aby działały przez wiele godzin. Ta trwałość jest właśnie powodem, dla którego wyciekające domknięcia stają się toksyczne. Wzorzec ten jest zdradliwie powszechny: useEffect rejestruje callback w globalnym szynie zdarzeń (event bus), handlerze WebSocket lub samym DOM. Jeśli tablica zależności (dependency array) jest niestabilna lub pominięta, czyszczenie nigdy nie trafi w pierwotną subskrypcję. Domknięcie przechwytuje wszystko, co znajduje się w jego środowisku leksykalnym, co może obejmować masywne sparsowane tablice, pobrane bloby JSON lub referencje do drzew DOM. Każda nawigacja dodaje kolejną wagę. Użytkownik nie wie, dlaczego jego karta przeglądarki zużywa 800 MB. Wie tylko, że aplikacja działa ociężale i w końcu przestaje działać.

Jest to szczególnie niebezpieczne, gdy tablice zależności zmieniają się przy każdym renderowaniu. W każdym cyklu powstaje nowa referencja funkcji, która jest rejestrowana przez listener, a stara nigdy nie zostaje zwolniona. Rezultatem jest muzeum martwych domknięć, z których każde gromadzi dane, z którymi przyszło mu się urodzić.

Błędy TDZ w modułach dynamicznych

Temporal Dead Zone nie jest teoretycznym przypadkiem brzegowym. Gdy uzyskujesz dostęp do let lub const przed wykonaniem ich deklaracji, silnik wyrzuca ReferenceError. W dużych monorepo z cyklicznymi zależnościami i dynamicznymi importami, dokładna kolejność wykonywania jest często niejawna. Moduł A importuje Moduł B, który dynamicznie importuje chunk zależny od Modułu A. Jeśli jedna gałąź dotknie zmiennej, która nie skończyła inicjalizacji, aplikacja ulegnie awarii podczas ładowania. Te błędy są irytujące, ponieważ zależą od czasu (timing-dependent). Mała zmiana w punktach podziału bundlera, opóźnienie sieci w ładowaniu kodu lub przesunięcie w buforowaniu chunków może zmienić kolejność na tyle, by wywołać TDZ. Awaria jest nieprzewidywalna, a stack trace zazwyczaj wskazuje na zupełnie niewinną linię kodu.

Taktyki defensywne

Nie możesz polegać na stack trace'ach, aby uratować Cię przed błędami zakresu (scope). Potrzebujesz zapobiegania i wykrywania.

Zacznij od analizy statycznej. Skonfiguruj ESLint, aby wymuszał ścisłe granice. Reguły takie jak no-implicit-globals i no-shadow wyłapują oczywiste grzechy. Shadowing jest szczególnie zdradliwy, ponieważ zwodzi Cię, sugerując, że mutujesz lokalną zmienną, podczas gdy w rzeczywistości tworzysz closure nad zmienną zewnętrzną lub tworzysz przypadkowy duplikat. Te reguły wymuszają wyraźny zamiar i eliminują ciche kolizje.

Profiluj pamięć z taką samą dyscypliną, jaką stosujesz w testach jednostkowych. Otwórz Chrome DevTools, wykonaj heap snapshot na swojej trasie startowej, nawiguj przez aplikację przez pięć minut i wykonaj kolejny. Porównaj oba. Przefiltruj pod kątem „Closure” i szukaj liczników, które rosną bez ograniczeń. Szukaj odłączonych węzłów DOM, które wciąż przechowują event listenery. Jeśli drugi snapshot pokazuje tysiące nowych wpisów Closure, podczas gdy liczba użytkowników pozostała bez zmian, masz funkcje-pułapki trzymające dane-pułapki. To jest Twój wyciek.

Pod względem architektury przestań sięgać do globalnego obiektu window w celu konfiguracji. Przekazuj ustawienia jako propsy lub przez typowany context. Dependency injection to tutaj nie jest tylko korporacyjnym buzzwordem; to praktyka przekazywania funkcji wszystkiego, czego potrzebuje, poprzez argumenty, zamiast pozwalać jej „wąchać” globalny zakres. Rezultatem jest kod, który możesz testować bez shimów przeglądarkowych, oraz moduły, które nie kolidują ze sobą, gdy wiele aplikacji jest montowanych wewnątrz tej samej powłoki (shell).

Na koniec, bezlitośnie przestrzegaj fazy czyszczenia. Każdy addEventListener potrzebuje odpowiadającego mu removeEventListener wewnątrz czyszczenia efektu (effect cleanup). W przypadku pracy asynchronicznej użyj AbortController i przekaż jego sygnał do fetch, aby trwające żądania zostały anulowane, gdy komponent zostanie usunięty. Te nawyki bezpośrednio kontrolują czas życia zakresu. To nie jest boilerplate. To zarządzanie pamięcią.

Co to oznacza dla Twojego zespołu

Scope to nie sztuczka gimnastyczna, którą można wykorzystać do sprawdzania kandydatów podczas rozmów kwalifikacyjnych. W produkcji scope to zarządzanie pamięcią. Każda zadeklarowana zmienna to potencjalny zakładnik. Każde closure to obietnica, którą silnik musi dotrzymać. Gdy zapominasz zwolnić listenera, nie zostawiasz po prostu zapalonego światła. Przywiązujesz ciężar do swojej aplikacji i wrzucasz go do oceanu. Na potężnym sprzęcie aplikacja i tak przepłynie. Dla użytkowników na słabych urządzeniach – zatonie. Zacznij traktować scope jako zasób skończony, którym w rzeczywistości jest. Twoi użytkownicy oraz Twoje trzygodzinne sesje debugowania będą Ci wdzięczni.