Dziesięć zasad JavaScriptu, które psują twoje rozumienie tego języka
Omawia dziesięć subtelnych zachowań JavaScriptu – od zmienności zmiennych const po zamknięcia funkcji i obsługę błędów asynchronicznych – które potajemnie powodują błędy w kodzie doświadczonych programistów.
Język JavaScript przestaje wydawać się groźny, gdy opanujesz jego składnię. Przestajesz potykać się o brakujące średniki, asynchroniczne funkcje zwrotne stają się czymś rutynowym, a różnica między let a const staje się czymś oczywistym. Po zrealizowaniu wystarczającej liczby projektów język ten zaczyna przypominać starego przyjaciela. Od razu dostrzegasz powszechne błędy, masz solidną intuicję co do sposobu realizacji obietnic i wiesz, że null oraz undefined to nie to samo, bez względu na to, jak swobodnie niektóre API je ze sobą utożsamiają.
Ale znajomość nie eliminuje wszystkich niespodzianek, jedynie zmienia ich formę. Zamieszanie na poziomie początkującego zostaje zastąpione subtelniejszymi, bardziej niebezpiecznymi założeniami. Wiesz, że obiekty są przekazywane przez referencję, a mimo to zapominasz, że płytka kopia sprawia, iż dane nawarstwione są współdzielone. Wiesz, że obietnice opierają się na mik zadaniach, a jednak nadal błędnie oceniasz kolejność wykonywania zadań, gdy wiele kolejków zaczyna ze sobą współpracować. Wiesz o istnieniu przymusu, a jednak nadal przeoczasz tę jedyną, cichą konwersję ukrytą w pozornie niewinnej porównaniu, wywołaniu funkcji sort lub wyszukiwaniu właściwości.
Najtrudniejsze aspekty JavaScript rzadko dotyczą egzotycznych przypadków krawędziowych czy błahostek językowych. Są to zwykłe reguły łączące się w nieoczekiwany sposób. Każdy pojedynczy wiersz wygląda dobrze, jest łatwy do odczytania i przeszedłby przy pobieżnej weryfikacji. Zaskoczenie pochodzi stąd, że silnik wykonawczy łączy te wiersze w sposób, który nie odpowiada modelowi mentalnemu, jaki mieliśmy podczas czytania kodu.
Dziesięć poniższych zachowań nadal sprawia problem doświadczonym zespołom właśnie dlatego, że ukrywają się w kodzie, który wygląda zupełnie normalnie.
1. const chroni powiązanie, a nie obiekt
Powszechnym skrótem wyjaśniającym pojęcie const jest stwierdzenie, że „tworzy wartość, której nie można zmienić”. To dobra uproszczenie w przypadku prymitywów, ale nie odnosi się do tablic i obiektów. To, co const faktycznie gwarantuje, to to, że zmienna nie może zostać przypisana do innego obiektu. Nie mówi ono nic o tym, czy obiekt, do którego odnosi się zmienna, może ulec modyfikacji.
Obiekt user zadeklarowany za pomocą const może nadal otrzymywać nowe właściwości. Jego zagnieżdżony obiekt ustawień może nadal być aktualizowany. Tablica zadeklarowana za pomocą const może nadal przyjmować nowe elementy, być sortowana lub opróżniana. Z punktu widzenia JavaScriptu nic z tego nie wpływa na powiązanie zmiennej – zmienna nadal odnosi się do dokładnie tego samego obiektu co zawsze.
To rozdźwięk pomiędzy oczekiwaniami a rzeczywistością nadal powoduje prawdziwe błędy w bazach kodu, które w dużej mierze opierają się na koncepcji niezmienności. Obiekt konfiguracji jest importowany jako stała, a następnie jeden z modułów potajemnie go edytuje, przez co nagle wszystkie inne moduły korzystające z tej referencji dostrzegają zmianę. Tablica przechowywana w stanie aplikacji jest sortowana na miejscu, co zmienia zarówno „obecną” wartość, jak i starszą wartość, o której inna część kodu zakładała, że jest to zamrożony zapis historyczny. Test modyfikuje wspólny obiekt testowy, w wyniku czego zupełnie niepowiązany test zaczyna zawodzić, ale tylko wtedy, gdy narzędzie do wykonywania testów przypadkowo uruchamia je w innej kolejności.
Słowo kluczowe const daje fałszywe poczucie bezpieczeństwa – wygląda jak ochronna osłona wokół wartości, ale czas działania systemu chroni jedynie wskaźnik do zmiennej, a nie samą strukturę, na którą on wskazuje.
Jeśli naprawdę potrzebujesz niezmienności, musisz ją sam stworzyć. Może to oznaczać zwracanie zupełnie nowych obiektów zamiast edytowania istniejących, stosowanie Object.freeze do konkretnych wartości, które są ważne, wykorzystanie biblioteki opartej na niezmiennej strukturze danych lub zaprojektowanie funkcji tak, aby od początku nie przekazywały referencji do wewnętrznych, zmiennych danych. Nawet Object.freeze blokuje tylko poziom najwyższy – jeśli nie zamrożysz również wszystkich zagnieżdżonych obiektów, warstwy wewnętrzne pozostaną tak samo zmienne jak wcześniej.
Większość doświadczonych programistów potrafi recytować tę zasadę z pamięci, a mimo to zaskoczenie pojawia się za każdym razem, gdy wizualny styl kodu sugeruje silniejsze gwarancje, niż faktycznie oferuje JavaScript.
2. Kopia rozszerzana obiektów daje mniej, niż się wydaje
Rozprzestrzenianie obiektu za pomocą { ...obj } stało się jednym z najczęściej używanych idiomów w JavaScript. Jest czytelne, doskonale nadaje się do łączenia wartości domyślnych z tymi zmienionymi, a także pozwala na stworzenie modyfikowanej wersji wartości bez dotykania oryginału.
Wizualnie wygląda to jak pełna, niezależna kopia. W rzeczywistości kopia obejmuje tylko jeden poziom głębokości.
const original = {
profile: {
name: "Umar",
skills: ["JavaScript", "Node.js"],
},
};
const copy = { ...original };
copy.profile.skills.push("TypeScript");
Gdy ten fragment kodu zostanie uruchomiony, nowo dodana umiejętność pojawi się również w original.profile.skills. Obiekt najwyższego poziomu rzeczywiście jest nowy, ale obiekt profile w nim zawarty oraz tablica skills jeszcze głębiej są dokładnie tymi samymi obiektami, na które wskazywał oryginał.
To właśnie sprawia, że ten błąd jest tak uporczywy: pierwszy poziom zachowuje się dokładnie tak, jak można by oczekiwać. Sprawdzenie copy !== original zwraca wartość true, co wydaje się dowodem na to, że udało się osiągnąć czyste oddzielenie. Problem wspólnej mutacji ujawnia się dopiero wtedy, gdy przechodzi się o jeden poziom niżej.
Rozprzestrzenianie się tego problemu przynosi również drugie zaskoczenie, gdy jest używane do łączenia obiektów konfiguracyjnych. Zapisanie { ...defaults, ...options } zastępuje całe zagnieżdżone obiekty, zamiast łączyć ich poszczególne klucze. Jeśli options zmienia jedną ustawienie wewnątrz zagnieżdżonego obiektu, wszystkie inne ustawienia znajdujące się obok niego w defaults znikają razem z nim, mimo że osoba wywołująca funkcję wcale nie zamierzała ich edytować.
Rozwiązanie nie polega automatycznie na „głębokim sklonowaniu wszystkiego”. Pełne głębokie sklonowanie może być kosztowne, może usunąć identyfikator obiektu, od którego faktycznie zależysz w innych miejscach, oraz może skopiować dane, które miały pozostać wspólne. Najważniejsze jest ustalenie, które konkretne zagnieżdżone ścieżki wymagają niezależnych kopii. Traktuj je wyraźnie oddzielnie, skorzystaj z odpowiedniej narzędzia do niezmiennej aktualizacji lub przeprojektuj głęboko zagnieżdżone dane tak, aby granice własności były od razu widoczne.
Operacja rozszerzania obiektów działa dokładnie tak, jak obiecano, gdy rzeczywiście potrzebujesz płytkiego sklonowania. Staje się natomiast problemem w momencie, gdy jej zwięzła składnia zostanie błędnie potraktowana jako pełne, głębokie sklonowanie.
3. Zwykła równość może ukrywać kilka konwersji
Najbardziej doświadczeni programiści preferują ===, aby uniknąć problemów związanych z przymusem konwersji typów, które występują przy użyciu ==. Ten nawyk jest rzeczywiście przydatny, ale nie chroni przed wszystkimi ukrytymi konwersjami typów wykonywanymi przez JavaScript – wiele z nich pojawia się w miejscach, które nie mają nic wspólnego z operatorem równości.
Dobre przykładem są klucze właściwości obiektów. Z wyjątkiem symboli, każdy klucz obiektu w rzeczywistości jest łańcuchem znaków. Dlatego ustawienie object[1] oraz późniejsze odczytanie object["1"] odnoszą się do tej samej właściwości. Kod, który w umyśle traktuje identyfikatory liczbowe i łańcuchowe jako dwa odrębne rodzaje danych, może zostać tu zaskoczony.
Porównania relacyjne przeprowadzają własne konwersje w zależności od tego, co jest porównywane. Dwa łańcuchy są porównywane leksykograficznie (znak po znaku), natomiast porównanie liczby z łańcuchem numerycznym może skutkować porównaniem liczbowym. Dlatego "20" < "100" daje wynik false, podczas gdy 20 < "100" daje wynik true. Jeśli zmieni się źródło wartości, logika sortowania lub walidacji może zmienić swoje zachowanie, mimo że wyświetlane wartości wyglądają identycznie.
Operator + jest szczególnie trudny w obsłudze, ponieważ pełni jednocześnie funkcję dodawania liczb i łączenia ciągów znaków, a JavaScript decyduje, który z tych trybów użyć, w zależności od kontekstu. Jeden tylko ciąg znaków umieszczony na początku łańcucha operacji dodawania może zmienić interpretację wszystkich kolejnych elementów. Ponieważ wartości pobierane z pól formularza, parametrów zapytania URL oraz innych źródeł HTML zazwyczaj przychodzą w formie ciągów znaków, wyrażenie, które działało doskonale z wewnętrznymi wartościami liczbowymi, może bez żadnych objawów przejść na łączenie ciągów znaków w momencie, gdy zostanie połączone z danymi wprowadzanymi przez użytkownika.
Zespoły doświadczone unikają tych pułapek poprzez normalizację wartości już na samym początku, zamiast polegać na operatorach, aby ci poprawnie interpretowali surowe dane w trakcie wykonywania zadań. Identyfikator zostaje raz na zawsze określony jako ciąg znaków lub liczba. Kwota pieniężna jest konwertowana na zweryfikowany typ liczbowy. Data jest analizowana i przekształcana w odpowiedni typ czasowy lub stały ciąg ISO przed jakimikolwiek porównaniami.
Sama przymusowa konwersja nie jest prawdziwym problemem – zasady JavaScript w tym zakresie są dobrze określone i spójne. Prawdziwym problemem jest to, że te zasady nadal działają w miejscach, gdzie nic w kodzie nie wskazuje na to, że dochodzi do jakiejkolwiek konwersji.
4. Array.prototype.sort przestawia elementy na miejscu i domyślnie porównuje tekst
Sorowanie wydaje się jedną z najprostszych operacji dostępnych na tablicy, ale w rzeczywistości łączy dwa zachowania, które ciągle wprowadzają ludzi w błąd.
Pierwszym zaskoczeniem jest to, że ta operacja mutuje tablicę. Wywołanie .sort() przestawia elementy tablicy, na której zostało ono wywołane, i zwraca odniesienie do tej samej tablicy. Łatwo jest przechować wartość zwróconą do nowej zmiennej i założyć, że oryginalna tablica nadal ma swój wcześniejszy porządek, by potem odkryć, że obie zmienne wskazują na tę samą, przetasowaną listę.
Drugim zaskoczeniem jest sposób działania porównań, gdy nie podano funkcji porównującej. W takim przypadku JavaScript przekształca każdy element w ciąg znaków i porządkuje je leksykalnie. Jeśli wywołać to na tablicy liczb, można otrzymać coś w rodzaju 1, 100, 20, 3 zamiast porządku rosnącego według wartości liczbowych.
To połączenie staje się niebezpieczne w zarządzaniu stanem frontendu. Wyobraź sobie komponent, który sortuje tablicę tuż przed renderowaniem, nie zdając sobie sprawy, że ta tablica to wspólna referencja do danych z pamięci podręcznej lub dostarczonych przez serwer. W rezultacie modyfikuje tę wspólną bazę danych. Komponent siostrzany, który odczytuje te same dane, również widzi nowy porządek, choć sam nigdy nie wywołał metody sortowania. Logika wykrywania zmian i memoizacji może również zawieść w takiej sytuacji, ponieważ tożsamość tablicy (jej referencja) nigdy się nie zmieniła, mimo że jej zawartość tak — więc powierzchowne porównanie nie wykryje żadnych różnic.
Współczesny JavaScript oferuje alternatywy nemutujące: funkcja toSorted() jest dostępna w środowiskach, które ją obsługują, natomiast klonowanie tablicy przed sortowaniem pozostaje standardowym rozwiązaniem tam, gdzie ta funkcja nie istnieje. W przypadku sortowania liczb nadal trzeba przekazać metodzie .sort() wyraźny komparator, który opisuje dokładną zasadę porównywania.
Ogólnie rzecz biorąc, nazwa metody nie informuje nas o jej pełnych możliwościach. Słowo „sort” opisuje wynik końcowy, ale nic nie mówi o tym, czy metoda zmienia tablicę na miejscu, w jaki sposób konwertuje wartości podczas porównywania, jakie gwarancje stabilności oferuje ani jakiego rodzaju sortowanie specyficzne dla danej dziedziny może być potrzebne. Nawet doświadczeni programiści popełniają błędy, gdy metoda wydaje im się na tyle rutynowa, że nie zatrzymują się, by ponownie przemyśleć to, co faktycznie robi w tle.
5. Obiekt Date reprezentuje pojedynczy moment — większość danych wejściowych nie odpowiada mu w sposób jednoznaczny
Błędy związane z strefami czasowymi w JavaScript nie wynikają tak naprawdę z trudności koncepcyjnych związanych ze strefami czasowymi. Chodzi raczej o to, że krótki ciąg znaków zawiera znacznie więcej domyśleń, niż się wydaje.
W rzeczywistości obiekt Date to zapis czasu: dokładny moment mierzony względem UTC. Jednak większość dat, z którymi faktycznie pracujemy — urodziny, data terminowa, cykl rozliczeniowy, umówione spotkanie — to koncepcje kalendarzowe, a nie stałe punkty w czasie uniwersalnym, przy czym każda z nich ma inny związek ze strefami czasowymi.
Jeśli przetwarzasz tylko ciąg z datą, a następnie wyświetlasz ją według czasu lokalnego, data widziana przez użytkownika może się zmieniać w zależności od jego lokalizacji. Wartość przeznaczona do reprezentowania „3 sierpnia” może zostać zinterpretowana jako północ UTC, co przy lokalnym wyświetlaniu w innym miejscu oznacza 2 sierpnia. Podobnie, data zapisana bez wyraźnego odchylenia UTC może być odczytywana inaczej w zależności od dokładnego formatu ciągu znaków oraz sposobu jego przetwarzania w czasie wykonywania programu.
Godziny letnie dodają kolejną złożoność. Dodanie określonej liczby milisekund do obiektu Date nie gwarantuje, że będzie to równoznaczne z dodaniem jednego dnia kalendarzowego według czasu lokalnego — niektóre dni w strefach, gdzie obowiązują zmiany czasu, trwają właściwie dwadzieścia trzy lub dwadzieścia pięć godzin.
Zdolni programiści nadal napotykają ten problem, ponieważ ich kod opiera się na jednym typie Date, aby jednocześnie reprezentować kilka niespowiązanych ze sobą koncepcji. W systemie typów nie ma żadnej informacji wskazującej, czy dana wartość ma przedstawiać dokładny moment w czasie, zwykłą datę kalendarzową, czy czas lokalny przeznaczony do interpretacji w określonym regionie.
Bezawaryjne systemy rozwiązują ten problem, czyniąc intencję jasną, a nie domyślną. Momenty w czasie są podawane z odchyleniem UTC lub bezpośrednio w formacie UTC. Wartości dotyczące wyłącznie kalendarza pozostają takimi, jakie są, zamiast być niepotrzebnie przekształcane w pełne zapisy czasowe. Harmonogramy specyficzne dla danego regionu zachowują kontekst strefy czasowej, w której zostały utworzone. Procesy analizy i formatowania odbywają się w wyraźnie określonych punktach systemu, a nie tam, gdzie akurat wyświetla się lub odczytuje data.
Zatem zaskoczeniem zazwyczaj nie jest to, że chodzi o strefy czasowe — lecz fakt, że jakaś na pierwszy rzut oka nieszkodliwa konwersja już podjęła bezgłośną decyzję co do tej właśnie strefy.
6. Obietnica może zostać spełniona przed tym, jak wykonywany zostanie jej callback
Obietnica, która została rozwiązana, wydaje się sprawą zakończoną, ale funkcja przypisana do niej za pomocą .then — lub kod znajdujący się po instrukcji await — nie jest wykonywana wprost w trakcie bieżącego kodu synchronicznego. Zamiast tego trafia na kolejkę mikrozadań i jest wykonywana później.
To kolejkowanie tworzy określony porządek wykonywania, który nadal może zaskoczyć doświadczonych programistów, gdy zaczynają się mieszać obietnice (promises), timerzy, obsługi zdarzeń oraz zwykły kod synchroniczny. Obietnica, która została już rozwiązana, planuje swoje dalsze wykonywanie na później, podczas gdy aktualnie uruchamiany kod synchroniczny kontynuuje pracę bez przerwy. Ta zaimprowizowana kontynuacja z kolejki zazwyczaj zostanie wykonyana przed jakimkolwiek powrotem po timerze zaplanowanym na następne zadanie – nawet przed setTimeout z opóźnieniem wynoszącym zero milisekund.
Prawdziwym praktycznym zagrożeniem nie jest odgadywanie kolejności wyświetlania informacji w konsoli podczas testu. Chodzi raczej o zrozumienie, że „obietnica została spełniona” oraz „jej obsłużnik faktycznie został wykonywany” to dwa odrębne momenty w czasie. Aktualizacja stanu przekazana przez obsłużnik obietnicy może jeszcze nie być widoczna dla kodu uruchamianego później w tym samym synchronicznym stosie. Test może sprawdzać wartość, zanim jego czekające mikrozadania zdążą zostać wykonane. Ponadto wystarczająco długi łańcuch powiązanych mikrozadań może opóźnić działanie timerów i renderowanie bardziej, niż się spodziewa, ponieważ silnik wykonawczy zawsze najpierw opróżnia całą kolejkę mikrozadań, zanim przejdzie do czegokolwiek innego.
Kod staje się kruchy, gdy polega na tym przypadkowym uporządkowaniu zamiast na wyraźnej sekwencji. Dlatego doświadczeni programiści wolą zwracać i oczekiwać na obietnice, gdy jeden krok rzeczywiście musi nastąpić po drugim, korzystają z dostępnych w ich frameworku hooków cyklu życia i unikają traktowania timera z zerowym opóźnieniem jako niezawodnego sposobu synchronizacji z innymi operacjami.
Sam pętla wydarzeń zachowuje się spójnie — to składnia sprawia, że kilka oddzielnych ścieżek czasowych wydają się bliżej siebie, niż w rzeczywistości są. Obietnica może już przechowywać swoją ostateczną wartość, podczas gdy reszta aplikacji jeszcze nie zdążyła o tym dowiedzieć się.
7. Otoczenie wywołania async w bloku try/catch nie gwarantuje złapania żadnego błędu
Blok try/catch otaczający wywołanie funkcji wydaje się chronić to wywołanie przed awarią. W przypadku funkcji asynchronicznych to, czy ta ochrona faktycznie działa, zależy wyłącznie od tego, czy czekamy na zwróconą obietnicę.
try {
saveAuditLog(record);
} catch (error) {
reportError(error);
}
Jeśli saveAuditLog rzuci błędem synchronicznie, zanim w ogóle zwróci obietnicę, blok catch poradzi sobie z tym bez problemu. Ale jeśli saveAuditLog jest funkcją asynchroniczną, która zawiedzie później, to w momencie odrzucenia już zwróciła obietnicę, a wykonywanie programu opuściło już blok try. Odrzucenie znajduje się teraz w tej obietnicy — nie ma ono nic wspólnego z wywołaniem synchronicznym, które już się zakończyło.
Dodanie await ponownie łączy odrzucenie z otaczającym blokiem try/catch, ale działa to tylko wtedy, gdy funkcja, którą piszemy, sama może czekać na coś. Przekierowanie obietnicy do innej łańcuchowej struktury również może zachować tę zależność błędów. Proste wywołanie funkcji asynchronicznej z wnętrza wciętego kodu obsługi błędów, bez jej oczekiwania lub zwracania, nie przynosi żadnego efektu.
To błąd pojawia się ciągle w obsługownikach zdarzeń, funkcjach callback dla tablic oraz hookach bibliotekowych, gdzie otaczająca API często nie wie, co zrobić z obietnicą zwróconą przez funkcję callback asynchroniczną — i często po prostu ją ignoruje. Funkcja callback nadal poprawnie odrzuca błąd, ale nic go nie obsługuje. W zależności od środowiska może to objawić się jako ostrzeżenie o nierozwiązanym odrzuceniu, błąd zapisany w logach, awaria procesu lub sytuacja, w której praca zostaje po cichu nigdy niezauważona.
Rozwijający, którzy ucierpieli z powodu tych błędów wynikających z zarządzania obietnicami zamiast użycia wcięć w kodzie. Pytają, jaki zakres faktycznie oczekuje na pracę asynchroniczną, gdzie odrzucenie jest przekształcane w coś możliwego do podjęcia działań, oraz czy jakiekolwiek intencjonalne wywołania typu „wyślij i zapomnij” nadal mają rzeczywisty sposób na zgłaszanie błędów.
Błędy asynchroniczne przemieszczają się wzdłuż łańcuchów obietnic, a nie w zależności od tego, jak głęboko są ułożone nawiasy.
8. Wartości domyślne przy destrukcji działają tylko dla undefined
Wartości domyślne podczas destrukcji stanowią praktyczną ochronę przed brakiem danych wejściowych.
const { timeout = 5000 } = options;
Ta wartość domyślna jest stosowana tylko wtedy, gdy timeout ma wartość undefined lub po prostu nie występuje w obiekcie. Nie zostanie zastosowana dla null, 0, pustej strony lub jakiejkolwiek innej wartości, która została wyraźnie podana.
Często jest to dokładnie takie zachowanie, jakiego oczekujemy. Zero może być celowym sposobem na wyłączenie opóźnienia, a null może mieć własne, odrębne znaczenie. Problemy pojawiają się wtedy, gdy ktoś zakłada, że wartość domyślna obejmuje wszystko, co „nie nadaje się do użycia”. API może zwrócić null, a kod poniżej próbuje wykonać z nim operacje matematyczne. Pole formularza może zostać wysłane jako pusta strona, więc wartość domyślna nigdy nie zostanie uruchomiona. Ładowarka konfiguracji może starannie odróżniać przypadek „zmienna nie ustawiona” od przypadku „zmienna wyraźnie ustawiona na pustą wartość”, podczas gdy kod używający tej konfiguracji traktuje oba przypadki identycznie i ucieka się do wartości domyślnej.
Parametry domyślne w funkcjach działają zgodnie z tą samą logiką: jeśli wezwiesz funkcję bez argumentu, stosuje się wartość domyślną, ale jeśli wyraźnie przekażesz null, ta wartość nie zostanie użyta. Różnica ta ma ogromne znaczenie, gdy dane są przekazywane przez payloady JSON, wiersze bazy danych, formularze oraz API firm trzecich — wszędzie tam, gdzie null występuje regularnie.
Rozwijający, którzy polegają na tym wzorcu, zazwyczaj traktują ustawianie wartości domyślnych i walidację jako odrębne kwestie. Wartość domyślna odpowiada na pytanie „co się dzieje, gdy nic nie zostało podane?”, natomiast walidacja na pytanie „czy to, co zostało podane, jest rzeczywiście do przyjęcia?”. Połączenie tych dwóch zadań w jedną, wygodną strukturę składniową może sprawić, że błędne dane będą wyglądać, jakby zostały prawidłowo sformatowane.
Zasada języka w tym przypadku jest dokładna i spójna. Zamieszanie wynika wyłącznie ze słowa „default”, które sugeruje szerszy zakres działania, niż faktycznie zapewnia surowe zachowanie oparte wyłącznie na undefined.
9. Brakująca właściwość, usunięta właściwość i undefined to trzy różne rzeczy
JavaScript pozwala właściwości obiektu przechowywać wartość undefined, albo po prostu w ogóle nie istnieć w obiekcie. Bezpośrednie odczytanie którejkolwiek z nich zwraca undefined, więc wydają się wymienne — ale tak nie jest.
Narzędzia takie jak operator in, Object.hasOwn, Object.keys, składnia rozszerzania, iteracja, walidatory schematów oraz serializacja JSON mogą pomóc odróżnić te przypadki. Na przykład funkcja JSON.stringify całkowicie pomija właściwości o wartości undefined, natomiast obsługa pozycji w tablicy zawierającej undefined jest inna. Łączenie obiektów może również nadpisać poprawną, istniejącą wartość przez undefined, nawet jeśli osoba tworząca operację łączenia zamierzała pozostawić to pole nietknięte.
To rozbieżność jest częstym źródłem subtelnych błędów podczas aktualizacji. Zawartość PATCH w warstwie backendu może traktować pominięte pole jako „nie zmieniaj tego”, a pole wyraźnie ustawione na null jako „usuń to”. Z kolei formularz w warstwie frontendu może zwracać wartość undefined dla każdego pola, którego użytkownik w ogóle nie edytował – ale przeniesienie tych danych do obiektu aktualizacji nadal wprowadza te właściwości typu undefined, które następnie zastępują istniejące wartości podczas łączenia.
Aby tego uniknąć, należy celowo zdefiniować semantykę aktualizacji, a nie pozwalać, by pominięcia, wartości undefined, null czy legalnie puste wartości automatycznie nabierały znaczenia ze względu na stosowany mechanizm serializacji lub łączenia obiektów.
To ma szczególne znaczenie w TypeScript, gdzie właściwość opcjonalna oraz właściwość obowiązkowa, której typ przypadkowo obejmuje undefined, wyrażają dwa strukturalnie różne zamiary — nawet jeśli otaczający je kod aplikacji ostatecznie traktuje je tak samo później. Surowsze ustawienia kompilatora mogą egzekwować tę różnicę na poziomie typu, ale rzeczywiste dane przepływające przez aplikację w czasie wykonywania nadal wymagają własnej walidacji.
Dwie wartości, które wyglądają identycznie podczas odczytu, mogą nadal reprezentować zupełnie różne kontrakty po przekroczeniu granicy sieci lub w wyniku operacji aktualizacji.
10. Zamknięcie przechowuje zmienną, a nie zastygły w czasie obraz
Zamknięcia to jeden z najpotężniejszych narzędzi, jakie daje JavaScript. Funkcja zachowuje dostęp do zmiennych z obszaru, w którym została zdefiniowana, i to właśnie sprawia, że funkcje zwrotne, funkcje fabryczne, obsługi zdarzeń oraz wzory modułowe działają tak naturalnie.
Powszechnie używanym skrótem jest stwierdzenie, że zamknięcie „pamięta wartość”. Dokładniejszym opisem jest to, że zachowuje ono dostęp do powiązania zmiennej. Jeśli wartość tej zmiennej ulegnie zmianie przed uruchomieniem zamknięcia, to zamknięcie zobaczy nową wartość — a nie tę, która istniała w momencie jego utworzenia.
To jest klasyczne wyjaśnienie problemów z pętlami związanymi z używaniem var, ale programiści o większym doświadczeniu często napotykają subtelniejsze wersje tego samego problemu w kodzie asynchronicznym i logice interfejsu użytkownika. Funkcja callback może odczytywać obiekt konfiguracji, który uległ zmianie po rozpoczęciu operacji. Obsługujący zdarzenia mechanizm może korzystać ze stanu, który jest już przestarzały. Funkcja wykonywana z opóźnieniem może operować na bieżącym stanie obiektu zmiennego, podczas gdy programista zakładał, że będzie on używał tego obiektu dokładnie tak, jak wyglądał w momencie planowania jego wykonania.
Frameworki dodają na to swoje własne zasady cyklu życia, co sprawia, że efekt staje się bardziej widoczny. W React, na przykład, funkcja zwrotna utworzona podczas określonego renderowania zachowuje wartości istniejące w tym konkretnym momencie. Może to prowadzić do zachowań przypominających użycie przestarzałego stanu, mimo że podstawowa struktura zamknięcia w JavaScript działa dokładnie zgodnie z projektem. Zaskoczenie pochodzi z oczekiwania, że funkcja zwrotna jakoś automatycznie uzyska najnowszy stan — a tak właśnie nie funkcjonują zamknięcia.
Prawidłowe rozwiązanie zależy od tego, czego faktycznie potrzebujesz. Czasami naprawdę chcesz mieć wartość taką, jaka istniała w momencie rozpoczęcia operacji, w takim przypadku właściwym krokiem jest utworzenie stabilnego zrzutu stanu na wstępie. Czasami chcesz mieć najnowszą dostępną wartość, co wymaga użycia aktualnego referencji typu ref lub funkcjonalnej aktualizacji. A czasami właściwą odpowiedzią jest to, aby zmieniające się zależności całkowicie odtworzyły funkcję powrotną.
Korzystne pytanie brzmi: czy praca odroczona powinna odzwierciedlać stan świata w momencie jej zaplanowania, czy też stan świata w momencie rzeczywistego wykonania. Sam zamykacz nie ma żadnej opinii na ten temat — wiernie zachowuje wszelkie relacje ustanowione przez twój kod, nawet jeśli nie były to te, które zamierzałeś stworzyć.
JavaScript zachowuje się spójnie — nasze mentalne skróty nie
Żadne z opisanych tutaj zachowań nie jest arbitralną dziwactwem. const zamyka binding, a nie wartość w nim zawartą. Operacja rozszerzania tworzy kopie tylko na jednym poziomie głębokości. Standardowe sortowanie tablic najpierw przekształca elementy na łańcuchy tekstowe. Obsługi obietnic działają jako mikrozadania. Destructuring domyślnie reaguje wyłącznie na undefined. Zamknięcia przechowują bindingi leksykalne, a nie stałe wartości. Każde z tych reguł jest stosowane przez język z pełną spójnością.
Zaskoczenia pochodzą od programistów, którzy polegają na modelach mentalnych prostszych od tych, które faktycznie egzekwuje silnik języka. Mówimy, że stała „nie może się zmienić”, operacja rozszerzania „tworzy kopię”, wywołanie asynchroniczne „znajduje się w bloku try/catch”, a zamknięcie „pamięta wartość”. Te uproszczenia są przydatne dopóki jakaś nowa wymóg nie będzie zależeć dokładnie od szczegółów, które pominęliśmy.
To, co chroni doświadczonych programistów, to nie zapamiętywanie coraz dłuższej listy drobnych faktów — lecz nawyk jasnego określania warunków w każdym momencie, gdy dane przekraczają jakąś granicę. Oznacza to normalizację wartości pochodzących ze świata zewnętrznego, traktowanie „brakujących” i „nieprawidłowych” jako odrębnych koncepcji, unikanie przypadkowej modyfikacji wspólnych referencji, jasne określenie tego, kto jest odpowiedzialny za obsługę błędów w operacjach asynchronicznych, zachowanie pierwotnego znaczenia danych czasowych i stref czasowych oraz wcześniejszą decyzję o tym, czy opóźniona funkcja powrotna ma działać na podstawie starego czy aktualnego stanu.
Pisanie niezawodnego JavaScript nie polega na unikaniu każdej elastycznej funkcji oferowanej przez ten język. Chodzi o wykorzystywanie tych funkcji bez zakładania, że ich zwięzła składnia gwarantuje więcej, niż faktycznie dostarcza.
JavaScript wciąż zaskakuje doświadczonych programistów właśnie dlatego, że doświadczenie buduje zaufanie do kodu, który wydaje się znajomy. Ryzyko polega na tym, że składnia o znajomym wyglądzie może nadal potajemnie ukrywać decyzje dotyczące referencji, konwersji typów, czasu wykonywania oraz odpowiedzialności za błędy — decyzje, które stają się widoczne dopiero wtedy, gdy coś innego w systemie ulegnie zmianie.
Język niemal zawsze robi dokładnie to, co zostało mu poleczone.
Zaskoczeniem jest uświadomienie sobie, co naprawdę poleciłeś swojemu kodowi zrobić.
Literatura pokrewna
- Powszechne pułapki JavaScript i TypeScript, które potajemnie psują kod — Wyjaśnia subtelne problemy w JavaScript i TypeScript — od porównań z NaN po kwestie związane z asynchronicznym wykonywaniem i przymusową konwersją typów — które powodują błędy mimo pozornie poprawnego wyglądu.