Strona główna / Artykuły / Zdjęcie stanu czy aktualna wartość? Jak zdecydować, co widzą opóźnione callbacki JavaScripta

Zdjęcie stanu czy aktualna wartość? Jak zdecydować, co widzą opóźnione callbacki JavaScripta

Dowiedz się, dlaczego zamykania zachowują dostęp do zmiennych oryginalnych, a nie ich kopii, oraz jak wybierać między zrzutem stanu a danymi w czasie rzeczywistym w timerach, efektach Reacta, słuchaczach i kodzie asynchronicznym.

3701 słów

Klawisz działa na danych z poprzedniej renderizacji. Czasownik rejestruje wartość, której nie było w momencie jej ustalenia. Każdy obsługiwaniec utworzony w pętli wydaje się należeć do ostatniego elementu. Powolna odpowiedź zastępuje ekran wynikami strony, którą użytkownik już opuścił. Te błędy są zazwyczaj klasyfikowane jako „problemy z zamknięciami”, ale ta nazwa rzadko pomaga komukolwiek w ich naprawie. Ten artykuł zastępuje tę nazwę dokładnym modelem: zamknięcie utrzymuje dostęp do powiązań zmiennych, a nie kopii wartości, a każda praca opóźniona wymaga wyraźnej decyzji o tym, czy ma uzyskać zrzut stanu, czy aktualną wartość.

Zasada stojąca za każdym błędem z zamknięciami

Zamknięcia nie są nieprzewidywalne. Funkcja w JavaScript zachowuje odniesienie do środowiska leksykalnego, w którym została stworzona, a za każdym razem, gdy jej ciało odwołuje się do zmiennych zewnętrznych, to nazwa jest wyszukiwana w środowisku w momencie wykonywania kodu. Kluczowym pojęciem jest dostęp. Funkcja nie otrzymuje zamrożonej kopii wszystkiego, co było widoczne w momencie jej definicji; zachowuje te same powiązania, a jeśli jedno z tych powiązań zostanie później przypisane na nowo, funkcja odczyta nową wartość.

Istnieją dwa przeciwne sposoby, w jaki można to zrobić źle. Pierwszy polega na oczekiwaniu zdjęcia w momencie, gdy kod faktycznie utworzył żywy link do zmiennej, która się zmienia. Drugi, powszechny w frameworkach renderujących, polega na oczekiwaniu najnowszej wartości, gdy funkcja zwrotna została utworzona w starszym kontekście, którego powiązania już nigdy się nie zmienią. Obie błędy wynikają z tej samej pominiętej kwestii: nikt nie zdecydował, do którego momentu powinna należeć opóźniona operacja.

Gdy ta decyzja stanie się jasna, zachowanie przestaje wydawać się tajemnicze. Funkcja zwrotna robi dokładnie to, co program jej polecił. Program po prostu powiedział coś innego, niż zamierzał rozwijający oprogramowanie.

Dostęp, a nie zdjęcie

Przykład domykania książki podręcznej zawiera funkcję wewnętrzną, która nadal używa zmiennej zewnętrznej po tym, jak funkcja zewnętrzna się zakończyła. Pokazuje to, że środowisko funkcji przetrwa jej wywołanie, ale potajemnie sprzyja błędnemu przekonaniu, że funkcja wewnętrzna przechowuje wartość widzianą w momencie jej utworzenia. Niewielka modyfikacja ujawnia tę różnicę. Tutaj logger jest tworzony, gdy message ma wartość "Starting", a zmienna jest przypisywana na nowo przed powrotem funkcji.

function createLogger() {
  let message = "Starting";

  const logMessage = () => {
    console.log(message);
  };
  message = "Finished";
  return logMessage;
}
const logger = createLogger();
logger();

Wywołanie logger() wyświetla Finished. Funkcja strzałkowa nigdy nie przechowywała ciągu znaków "Starting"; przechowywała związek z message, a w momencie jej wykonywania ten związek wskazywał już na inny ciąg znaków.

To jest funkcja, a nie wada. Prywatny, zmienialny stan w licznikach, funkcjach fabrycznych, buforach memoizacji oraz wzorcu modułowym wszystkie polegają na tym, że zamykanie musi móc widzieć aktualizacje swoich własnych zmiennych. Problemy pojawiają się dopiero wtedy, gdy ktoś uważa, że funkcja powrotna jest powiązana z wcześniejszą wartością, a nie z samą zmienną.

Prymitywy ułatwiają to nieporozumienie, ponieważ łańcuchy znakowe i liczby wydają się samodzielne, więc naturalnym wydaje się, że funkcja po prostu zachowuje „ten łańcuch znakowy”. Jednak ciało funkcji zawiera nazwę, a nie wartość, a nazwy są rozstrzygane podczas wykonywania kodu. Jeśli chcesz lepiej zrozumieć, w jaki sposób to rozstrzyganie odbywa się poprzez nawarstwione zakresy, jak łańcuch zakresów w JavaScript faktycznie rozstrzyga zmienne opisuje tę mechanikę.

Praktycznym nawykiem, który powinien być przyjęty, jest zadawanie jednego pytania za każdym razem, gdy callback ma zostać wywołany później i odnosi się do czegoś z zewnątrz: czy powinien używać wartości takiej, jaka była w momencie utworzenia callbacka, czy takiej, jaka jest w momencie jego wywołania? Odpowiedź podpowiada, czy należy zrobić zrzut stanu, odczytać aktualną wartość, czy przekazać wartość jako argument. Bez tego pytania kod nadal jest poprawnym JavaScriptem, ale jego zachowanie jest przypadkowe.

Błąd w pętli dotyczy powiązania tożsamości

Najbardziej znany błąd związany z zamknięciami funkcji dotyczy pętli for zadeklarowanej za pomocą słowa kluczowego var, która planuje kilka wyzwalaczy czasowych. Każdy callback wydaje się być powiązany ze swoją własną iteracją.

for (var index = 0; index < 3; index++) {
  setTimeout(() => {
    console.log(index);
  }, 100);
}

Drukuje 3 trzy razy. Deklaracja var ma zasięg funkcji, więc cały pętla dzieli się dokładnie jedną wartością index. Wszystkie trzy funkcje callback korzystają z tej samej wartości, a gdy wybuchną timery, pętla już się zakończyła i pozostawiła wartość na poziomie 3.

Zmiana deklaracji na let zmienia wynik, ponieważ język tworzy nową wartość dla każdej iteracji pętli for z użyciem klauzuli let i kopiuje do niej aktualną wartość.

for (let index = 0; index < 3; index++) {
  setTimeout(() => {
    console.log(index);
  }, 100);
}

W tej wersji drukuje się 0, 1 i 2, ponieważ każda funkcja callback odnosi się teraz do innej wartości index.

Zwykłe podsumowanie brzmi „let naprawia zamykanie funkcji”, ale to ukrywa prawdziwą lekcję. Zamykane funkcje zachowywały się identycznie w obu pętlach. W pierwszej trzy funkcje callback zostały celowo wyposażone w jedną wspólną, zmienialną zmienną; w drugiej każda otrzymała swoją własną. To, co się zmieniło, to tożsamość powiązania, a nie zachowanie zamykania funkcji.

To rozróżnienie jest ważne, ponieważ ten sam błąd występuje również bez użycia var. Jeśli zadeklarujesz zmienną zmienialną za pomocą let poza pętlą, zaktualizujesz ją przy każdej iteracji i odczytasz jej wartość w funkcjach callback, to każda z nich nadal będzie widzieć tylko ostateczną wartość. Zamiana słowa kluczowego nie pomoże, jeśli kod nadal wskazuje kilka funkcji callback na jedno źródło zmienne.

Lepszym nawykiem jest pytanie, czy funkcje zwrotne powinny dzielić się stanem. Jeśli wszystkie mają obserwować jedną zmieniającą się wartość, właściwe jest użycie pojedynczego powiązania. Jeśli każda z nich musi pamiętać dane specyficzne dla swojej iteracji, potrzebuje własnego powiązania lub wyraźnego argumentu. Widok kilku funkcji zwrotnych w kodzie skłania do założenia, że każda z nich posiada zmienne, które nazwała; zakres leksykalny określa jedynie miejsce wyszukiwania nazw, a nie to, kto je posiada.

Gdy czas oddziela tworzenie od wykonywania

Niespodzianki związane z zamknięciami stają się gorsze, gdy istnieje przerwa między utworzeniem funkcji a jej uruchomieniem: timer, runda sieciowa, działanie użytkownika, zadanie czekające w kolejce. Wszystko, co funkcja odczytuje z zewnątrz, może ulec zmianie w tym czasie. Rozważmy rutynę zapisu, która polega na użyciu identyfikatora projektu na poziomie modułu.

let activeProjectId = 42;

async function saveChanges(changes) {
  await saveProject(activeProjectId, changes);
}

activeProjectId = 84;

Czy to jest poprawne, zależy od momentu wywołania. Argumenty są ewaluowane w momencie wezwania funkcji, więc jeśli saveChanges zostanie wywołany przed ponownym przypisaniem, activeProjectId jest odczytywany synchronicznie i to liczba 42 jest przekazywana do saveProject, mimo że to wezwanie musi wtedy czekać. Jednak każdy callback, który odczytuje activeProjectId po pewnym opóźnieniu, zobaczy wartość, jaką wtedy ma ta zmienna – być może 84 – i zapisa dane do zupełnie innego projektu.

Dlatego zwrot „zamknięcia przechwytują wartości” jest niebezpiecznym skrótem. W niektórych kodach wartość jest kopiowana do argumentu przed pauzą, natomiast w innych kodach odczytywana jest wspólna zmienna po niej. Dwie funkcje mogą wyglądać niemal identycznie, choć kierują się różnymi zasadami dotyczącymi czasu.

Interfejsy użytkownika są pełne takiego wzorca. Wyobraź sobie okno potwierdzenia otwarte dla jednej rekordu. Użytkownik przechodzi do innego rekordu, a następnie klikuje „Potwierdź”. Jeśli obsługa odczytuje zmienną „obecny rekord”, usuwa rekord widoczny na ekranie, a nie ten, dla którego otwarto okno dialogowe. To rozwiązanie funkcjonuje doskonale; problem polega na tym, że produkt jest błędny, ponieważ działanie powinno zachować kontekst, od którego zaczęło.

Rozwiązaniem nie jest automatyczne „skopiowanie zmiennej”. Najpierw trzeba zdecydować, w którym momencie ma mieć miejsce operacja:

  • Działanie niszczące zazwyczaj należy do momentu, w którym zostało rozpoczęte, i powinno wykorzystywać wtedy uchwycony identyfikator.
  • Wskaźnik stanu wymaga najnowszej wartości za każdym razem, gdy się aktualizuje.
  • Ponowna próba zaplanowana na później może łączyć obie opcje: oryginalny identyfikator operacji oraz token autoryzacyjny ważny w momencie jej wykonania.

Zamknięcia zmuszają programistów JavaScript do wyraźnego myślenia o czasie. Ważne nie jest tylko to, jaką zmienną odczytuje funkcja zwrotna, ale także to, którą wersję tej zmiennej ma być użyta w danym procesie.

Obiekty utrzymują stabilną referencję pomimo zmian w ich zawartości

Gdy zapisana wiązka odnosi się do obiektu, pojawia się nowy poziom zamieszania. Użycie słowa kluczowego const dla wiązki nie zamraża nic poza samą wiązką. Jeśli obiekt jest zmienny, opóźniona funkcja zwrotna nadal może zaobserwować każdą zmianę dokonaną poprzez wspólną referencję.

const settings = {
  retries: 2,
};

setTimeout(() => {
  console.log(settings.retries);
}, 100);

settings.retries = 5;

Zegar wyświetla wartość 5. Stała settings nigdy nie zmieniła obiektu, do którego odnosi się, ale właściwość retries tego obiektu została zaktualizowana przed uruchomieniem funkcji zwrotnej.

Konsola przeglądarki może dodatkowo wprowadzać zamieszanie. Zapisujesz obiekt przed rozpoczęciem jakiejś pracy asynchronicznej, później rozwijasz go w narzędziach programisty i widzisz pola, które zostały zmienione po wykonywaniu instrukcji logowania. W niektórych konsolach po rozwinięciu obiektu widnieje jego aktualny stan, a nie stan z chwili logowania, co sprawia wrażenie, jakby czas w logach posuwał się do przodu. Używanie JSON.stringify(obj) lub zbudowania strukturalnego klonu to szybki sposób na uzyskanie dokładnego zrzutu stanu obiektu podczas debugowania.

Stworzenie rzeczywistego zrzutu stanu w kodzie wymaga czegoś więcej niż tylko nowej zmiennej, a to, na jaką głębokość należy dokonać kopiowania, zależy od struktury tego, co chcemy chronić. Płytkie kopiowanie, przy użyciu operacji spread lub Object.assign, oddziela właściwości najwyższego poziomu, ale nadal dzieli obiekty i tablice zagnieżdżone. Głębokie klonowanie oddziela jeszcze więcej elementów, ale może być kosztowne, może usunąć prototypy klas i metody oraz może duplikować referencje, które miały być współdzielone.

Często lepszą opcją jest w ogóle nie klonować, a jedynie wybrać te małe, niezmienne wartości, których operacja potrzebuje.

const retryLimit = settings.retries;

setTimeout(() => {
  console.log(retryLimit);
}, 100);

Callback teraz odczytuje wartość, która nigdy się nie zmieni, a co równie ważne, kod wskazuje, od jakiego fragmentu kontekstu historycznego zależy timer.

Traktuj z podejrzliwością prace opóźnione, które manipulują dużymi obiektami zmienialnymi. Przykładami są żądania kontekstów, pojemników konfiguracji, obiektów stanu komponentów oraz wspólnych pamięci cache. Funkcja zwrotna, która uzyska dostęp do jednego z nich później, będzie w tajemnicy polegać na każdej mutacji, która miała miejsce pomiędzy tymi momentami. Przekazywanie wąskiego pakietu danych do pracy opóźnionej stanowi znacznie jaśniejszą umowę.

React pokazuje przeciwny przypadek awarii

W React błędy związane z zamknięciem funkcji zazwyczaj wskazują w innym kierunku. Funkcja zwrotna nie odczytuje wartości zbyt nowej; nadal odczytuje tę, która jest zbyt stara.

Każde wyświetlenie komponentu funkcyjnego to nowe wezwanie funkcji z własnymi lokalnymi powiązaniami dla propów, stanu i wartości pochodnych. Funkcja zwrotna utworzona podczas wyświetlenia odnosi się do powiązań z tego konkretnego wyświetlenia. Gdy stan się zmienia, React ponownie wywołuje komponent i tworzy nowe powiązania, ale każda funkcja zwrotna z poprzedniego wyświetlenia, która nadal istnieje, wskazuje na stare powiązania.

Symptomy są znajome:

  • Interwał utworzony podczas jednego wyświetlenia nieprzerwanie loguje przestarzałą liczbę.
  • Słuchacz zdarzeń zarejestrowany raz nadal używa propu z pierwszego wyświetlenia.
  • Efekt z niekompletną listą zależności nadal wywołuje funkcję, która odnosi się do przestarzałego stanu.

Nazywa się to przestarzałą zamkniętością, ale sama zamkniętość nie jest uszkodzona. Pozostaje lojalna wobec swojego pierwotnego wyświetlenia, a nic w języku nie sprawia, że przesunie się do nowszego stanu podczas kolejnego wyświetlenia przez React.

To może wydawać się sprzeczne z wcześniejszymi przykładami, w których zamknięcia pomyślnie reagowały na aktualizacje. Różnica polega ponownie na tożsamości powiązań. W przykładzie logera istniało jedno powiązanie, które zostało przypisane na nowo, a zamknięcie dostrzegło nową wartość. React nie przypisuje na nowo starych powiązań; tworzy zupełnie nowe środowiska przy każdym renderowaniu. Stary callback pozostaje przypisany do starego środowiska, którego wartości nigdy się nie zmieniają.

Gdy spojrzeć na to w ten sposób, rozwiązania wynikają z tego, co callback ma za zadanie wykonać:

  • Aktualizacja stanu, która zależy od bieżącej wartości, może korzystać z formy funkcyjnej, takiej jak setCount(c => c + 1), dzięki czemu React dostarcza najnowszy stan.
  • Subskrypcja, która potrzebuje najnowszej wartości, może zostać odtworzona, gdy zmieniają się jej zależności, lub może czytać z celowo utrzymywanego ref.
  • Kallback, który ma działać na wartościach z renderowania, które go stworzyły, może być już poprawny w obecnej formie.
  • Pytanie pozostaje takie samo jak wcześniej: czy to opóźnione zachowanie powinno wykorzystywać stan historyczny, czy bieżący? Wiele błędów w React wynika z przypadkowej odpowiedzi na to pytanie poprzez edycję tablicy zależności, zamiast rozważenia tego, co funkcja powinna robić. Nowsze wersje React oferują również useEffectEvent do odczytywania najnowszych wartości wewnątrz efektu bez ponownego jego uruchamiania; usuwanie refa latest-value za pomocą useEffectEvent wyjaśnia ten wzorzec.

    Tablice zależności opisują rzeczywistość, a nie preferencje

    Powszechnym sposobem na walkę ze starymi zamknięciami jest dostosowywanie tablicy zależności efektu, aż zachowanie będzie poprawne. Wartość trafia do tablicy, ponieważ narzędzie sprawdzające kod wydaje komunikat o błędzie, a znika, gdy efekt jest uruchamiany zbyt często; w końcu tablica staje się pusta, ponieważ efekt „powinien być uruchamiany tylko raz”.

    To traktuje tablicę jak pokrętło regulujące częstotliwość uruchamiania efektu. W rzeczywistości jest to deklaracja tego, jakie wartości renderowania są odczytywane przez zamknięcie efektu.

    Jeśli efekt używa zmiennej z otaczającego renderowania, ta zmienna stanowi część jego zamknięcia, niezależnie od tego, czy pojawiła się w tablicy, czy nie. Jej pominięcie nie usuwa zależności – gwarantuje jedynie, że efekt będzie nadal korzystał z wersji ustawionej przez ostatnie renderowanie.

    Włączenie wszystkich zależności może prowadzić do innego problemu: efekt ten rozbija i tworzy na nowo subskrypcję, restartuje timer lub wysyła żądanie znacznie częściej, niż to zamierzone. Łatwo wtedy uznać to za zbyt ścisłe działanie narzędzia do sprawdzania kodu. Częściej jest to jednak sygnał, że efekt pełni więcej funkcji lub że obiekt lub funkcja, od których zależy, jest bez powodu tworzony na nowo przy każdym renderowaniu.

    Typowe rozwiązania to:

    • ustabilizowanie funkcji zwrotnej tak, aby zmieniała się tylko wtedy, gdy zmieniają się jej własne dane wejściowe
    • rozdzielenie jednego efektu na kilka o węższych zadaniami
    • umieszczenie funkcji pomocniczej wewnątrz efektu, aby nie była już zewnętrzną zależnością
    • używanie funkcjonalnego aktualizowania stanu zamiast bezpośredniego odczytu stanu
    • zastanowienie się, czy ta logika w ogóle musi być efektem

    Celem nie jest zmuszanie React do uruchamiania efektu w preferowanym tempie. Chodzi o to, aby efekt miał zamykanie, którego czas trwania odpowiada zachowaniu, za które jest odpowiedzialny. Gdy efekt potrzebuje aktualnych wartości bez konieczności ich usuwania za każdym razem, gdy się zmieniają, należy dać mu świadomy sposób na ich odczyt, np. ref lub zdarzenie efektu. Jeśli efekt powinien się ponownie uruchomić po zmianie wartości, ta wartość powinna znajdować się w tablicy. A jeśli efekt rzeczywiście nie potrzebuje danej wartości, należy usunąć sposób jej odczytu, a nie zależność.

    Problemy z zależnościami to informacja zwrotna dotycząca projektu. Problematyczne zachowanie pojawia się wtedy, gdy kod deklaruje jeden czas trwania dla zamykania, podczas gdy funkcja wymaga innego.

    Słuchacze zdarzeń przetrwają kontekst, który je stworzył

    Słuchacze celowo tworzą przerwę pomiędzy rejestracją a wykonywaniem. Dodaje się obsługę raz, a ona uruchamia się za każdym razem, gdy wydarzenie ma miejsce, być może długo po tym, jak zmieniły się otaczające je zmienne.

    W zwykłym JavaScriptie słuchacz, który odczytuje zmienną na poziomie modułu, widzi jej najnowszą wartość. W frameworku komponentów słuchacz dodany podczas wczesnego renderowania zachowuje powiązania z tym renderem. W obu przypadkach ta relacja łatwo umyka uwadze, ponieważ obsługa uruchamia się tylko wtedy, gdy użytkownik później coś zrobi.

    Czyszczenie dodaje kolejny wymiar. Jeśli przy każdym renderowaniu dodawany jest nowy słuchacz bez usuwania poprzedniego, kilka zamykających funkcji kończy przez to reagując na ten sam event, przy czym każda z nich przechowuje inną wersję stanu. Jeden kliknięcie może więc doprowadzić do kilku różnych wyników pochodzących z różnych momentów w historii aplikacji. Widocznym efektem może być podwójna aktualizacja, powrót starej wartości lub uruchomienie obsługiwanego eventu po tym, jak komponent został usunięty. Podstawową przyczyną we wszystkich przypadkach jest to, że czas trwania słuchacza nigdy nie był zgodny z czasem trwania stanu, od którego zależy.

    Niezawodny kod słuchaczy sprawia, że odpowiedzialność jest jasno określona:

    • Zachowaj odniesienie do dokładnej funkcji, którą zarejestrowałeś, ponieważ removeEventListener potrzebuje tego samego odniesienia.
  • Odtwórz słuchacz, gdy zmieni się zachowanie, od którego zależy, lub spraw, aby odczytywał aktualne wartości za pośrednictwem specjalnego kanału, takiego jak ref lub store.
  • Usuń słuchacz, gdy jego właściciel – czy to komponent, moduł czy funkcja – przestanie istnieć.
  • To wszystko nie jest tylko formalnością. Rejestracja funkcji zwrotnej tworzy połączenie pomiędzy przyszłymi zdarzeniami a środowiskiem dostępnym w danym momencie. Jeśli to połączenie ma zostać przerwane, kod musi je zakończyć.

    Odpowiedzi asynchroniczne przenoszą stary kontekst na nową stronę

    Zapytania sieciowe powodują jedne z najdroższych w utrzymaniu błędów związanych ze starym kontekstem. Zapytanie rozpoczyna się, gdy użytkownik przegląda określone zapytanie wyszukiwania, projekt lub trasę. Zanim zostanie zakończone, użytkownik przechodzi gdzie indziej. Gdy przychodzi odpowiedź, jej funkcja zwrotna zapisuje dane do wspólnego stanu, używając kontekstu uchwyconego na początku.

    Czasami ten kontekst historyczny jest dokładnie prawidłowy. Prośba złożona w ramach projektu 42 powinna pozostać powiązana z projektem 42 nawet po tym, jak aktywny projekt zmieni się na 84. Zagrożenie polega na tym, że taki wynik może zaktualizować ekran, który już przeniósł się na projekt 84. Pamiętanie o pierwotnej prośbie to właśnie funkcja domknięcia; błąd w logice pojawia się, gdy zakłada się, że ukończona odpowiedź jest nadal automatycznie pożądana.

    Dlatego też rozumowanie oparte na domknięciu idzie w parze z asynchronicznym zarządzaniem. Funkcja zwrotna może przechowywać całkowicie poprawne wartości historyczne, ale nadal nie ma prawa do aktualizacji celu, do którego jest skierowana. Powszechne mechanizmy ochronne to:

    • identyfikator lub numer sekwencyjny prośby, który jest porównywany przed zastosowaniem wyniku
    • sygnał AbortController, który anuluje działania, gdy użytkownik przechodzi dalej
    • weryfikacja, czy aktualna trasa lub wybór nadal odpowiadają prośbie
  • przechowywanie wyników pod kluczem odpowiadającym zasobowi, do którego należą, zamiast w pojedynczej „bieżącej” pozycji
  • Jedynie przepisanie funkcji zwrotnej tak, by czytała najnowszy aktywny projekt, może pogorszyć sytuację: odpowiedź dla projektu 42 zostałaby przechowana pod projektem 84. Czytanie bieżącego stanu nie jest uniwersalnym rozwiązaniem. Zachowaj integralność identyfikatora żądania i sprawdź, czy destynacja nadal potrzebuje tego wyniku, zanim go zapisazesz.

    Zachowaj oddzielenie dwóch kontekstów. Kontekst operacji odnosi się do danych, dla których złożono żądanie. Kontekst interfejsu odnosi się do tego, na co obecnie patrzy użytkownik. Ekran powinien być aktualizowany tylko wtedy, gdy oba konteksty nadal się pokrywają. Bez tego rozdzielenia funkcje zwrotne wydają się podróżować w czasie, dostarczając ważnych informacji z wcześniejszego momentu do widoku, który już się zmienił.

    Wybór między zrzutem stanu a dostępem w czasie rzeczywistym

    Bardzo wiele z tych błędów staje się proste, gdy zespół określi pożądaną relację między elementami.

    Zdjęcie stanu oznacza, że praca wykonywana z opóźnieniem korzysta z danych w takim stanie, w jakim były przy ich utworzeniu. Dotyczy to identyfikatorów transakcji, identyfikatora wybranego rekordu, wartości złożonych formularzy, kontekstu audytu oraz poleceń, które muszą zachować swoją pierwotną intencję. Można to zaimplementować poprzez przekazywanie wartości jako argumentów, tworzenie niezmienialnych pakietów danych lub kopiowanie tylko tych pól, których operacja potrzebuje.

    Dostęp w czasie rzeczywistym oznacza, że praca wykonywana z opóźnieniem korzysta z najnowszej wartości w momencie jej wykonywania. Dotyczy to statusu połączenia, najnowszej konfiguracji, niektórych aktualnych stanów interfejsu użytkownika w obsługiwanym wydarzeniach oraz wartości wymagających modyfikacji. Można to zaimplementować za pomocą wspólnego powiązania, referencji, dostępu do magazynu danych lub innego źródła, które jest wyraźnie określone jako „aktualne”.

    Żaden z nich nie jest ogólnie bezpieczniejszy. Błędy pojawiają się, gdy kod implementuje jeden z nich, podczas gdy programista zakłada istnienie drugiego.

    Dwa nawyki sprawiają, że wybór staje się widoczny w kodzie. Po pierwsze, unikaj zamykających funkcji, które przypadkowo dostępują wszystkiego z zakresu; szerokie zamykanie ukrywa ich zależności, więc czytelnik nie może stwierdzić, które z nich powinny być zamrożone, a które pozostać aktualne. Po drugie, preferuj małe funkcje o wąskim zakresie działania, co zmniejsza liczbę zmiennych, których semantyka czasowa wymaga analizy. Oba podejścia poprawiają niezawodność operacji asynchronicznych z tego samego powodu: mniej ukrytych związków z czasem.

    Szybka lista kontrolna dla każdej funkcji zwrotnej uruchamianej później:

    • Które zmiennye zewnętrzne są przez nią odczytywane?
    • Dla każdej z nich – czy powinna widzieć ich wartość przy tworzeniu, czy podczas wykonywania?
    • Czy któraś z nich to obiekt zmienialny, którego zawartość może ulec zmianie w międzyczasie?
  • Kto jest właścicielem tego callbacku i kiedy powinien przestać działać?
  • Jeśli zapisuje wyniki gdzieś indziej, czy to miejsce nadal należy do tego samego kontekstu?
  • Podsumowanie

    Zamknięcia w JavaScript są deterministyczne. Podążają za zakresem leksykalnym, przechowują powiązania i utrzymują środowiska aktywne tak długo, jak długo funkcja, do której można dotrzeć, ich potrzebuje. To, co sprawia wrażenie problemu, to traktowanie zmiennej tak, jakby miała jedną, spójną wartość na przestrzeni czasu.

    Każdy z powyższych scenariuszy to wariacja jednego pytania: do którego momentu powinien należeć ten callback? Pętla może przekazać wszystkim swoim callbackom jedno wspólne powiązanie. Timer może odczytywać obiekt po jego modyfikacji. Callback w React może pozostać powiązany z wcześniejszym renderowaniem. Słuchacz może przetrwać stan, dla którego został napisany. Asynchroniczna odpowiedź może przenieść poprawną informację historyczną do widoku, który już się zmienił.

    Zdolni programiści nadal napotykają te błędy, ponieważ nowoczesne aplikacje ciągle odkładają wykonywanie zadań. Czasochłonności, łańcuchy obietnic, zdarzenia DOM, subskrypcje, ponowne renderowanie, kolejki zadań oraz odpowiedzi HTTP powodują, że między zdefiniowaniem funkcji a jej uruchomieniem powstaje odstęp, a im dłuższy jest ten odstęp, tym bardziej zmienia się otaczający świat. Obroną nie jest zapamiętywanie kolejnej definicji zamknięć. Chodzi o to, by jasno określić czas i odpowiedzialność: celowo wybierać albo zrzut stanu, albo dostęp w czasie rzeczywistym, przechowywać tylko te wartości historyczne, których potrzebuje dane zadanie, zapewniać bieżącemu stanie jeden celowy źródło danych, powiązać czas trwania każdego callbacka z tym, co nim zarządza, oraz uniemożliwić przestarzałym zadaniom zapisywanie danych w miejscach, którymi już nie kontrolują. Gdy te decyzje są widoczne w kodzie, zamknięcia przestają być zaskoczeniem, ponieważ w końcu są powiązane z momentem, który zamierzaliśmy osiągnąć.

    Literatura pokrewna