Te codzienne wzorce w JavaScript i kompromisy stojące za każdym z nich
Klauzule ochronne, tablice wyszukiwania, obiekty wynikowe, AbortController, allSettled, fabryki i małe pamięci cache: kiedy każdy wzorzec się sprawdza, a kiedy przynosi negatywne skutki.
Źródła: I Reuse These 10 JavaScript Patterns in Almost Every Project
Niezależnie od tego, czy bazą kodu jest panel sterowania React, API Node.js, czy mały skrypt automatyzacyjny, zawsze napotyka te same problemy: funkcje otrzymują niekompletne dane, odpowiedzi asynchroniczne przychodzą w niewłaściwej kolejności, warunki rosną wraz ze zmieniającymi się zasadami biznesowymi, kopie tej samej transformacji różnią się między sobą, a obsługa błędów zamazuje granicę pomiędzy „to się nie udało” a „po prostu nie było nic do zwrócenia”. Frameworki przychodzą i odchodzą, ale te problemy pozostają niezwykle stabilne. Dziesięć poniższych wzorców to celowo proste rozwiązania tych problemów. Każdy z nich ogranicza zbiór stanów, o których musi myśleć funkcja, lub ułatwia zidentyfikowanie odpowiedzialności, a przy każdym z nich dowiesz się również, gdzie przestaje być pomocny, ponieważ żaden z nich nie zastępuje osądu.
1. Klauzule ochronne zapewniają czytelność głównej ścieżki
Gdy rzeczywista logika funkcji znajduje się wewnątrz kilku nawiasowanych bloków if, czytelnik musi pamiętać o wielu otwartych pytaniach: czy dane wejściowe są dostępne, czy użytkownik ma uprawnienia, czy rekord został znaleziony, czy znajduje się on w stanie pozwalającym na tę zmianę? Każdy dodatkowy warunek zwiększa poziom wcięcia i doda kolejną rzecz do zapamiętania.
Klauzula ochronna odpowiada na każde z tych pytań od razu i kończy działanie, gdy odpowiedź jest negatywna. Poniższy przykład odrzuca brakujący ID, następnie nieuprawnionego użytkownika, potem brakujący artykuł, a także artykuł, który nie jest wersją roboczą; dopiero po sprawdzeniu wszystkich czterech warunków przeprowadza aktualizację.
async function publishArticle(articleId, currentUser) {
if (!articleId) {
throw new Error("Article ID is required");
}
if (!currentUser?.permissions.includes("article:publish")) {
throw new Error("You are not allowed to publish articles");
}
const article = await articleRepository.findById(articleId);
if (!article) {
throw new Error("Article not found");
}
if (article.status !== "draft") {
throw new Error(`Cannot publish an article in ${article.status} status`);
}
return articleRepository.update(articleId, {
status: "published",
});
}
Prawdziwą korzyścią nie jest mniej wcięć; to rosnąca pewność. Po pierwszej procedurze walidacyjnej identyfikator istnieje. Po drugiej wywołujący ma uprawnienia. Po trzeciej article jest rzeczywistym obiektem, więc opcjonalne łączenie nie musi rozprzestrzeniać się na resztę kodu. Ostateczne wywołanie update można przeczytać bez żadnych nierozwiązanych problemów. Warto zwrócić uwagę na jeden mały przypadek graniczny: currentUser?.permissions.includes(...) nadal wywołuje błąd TypeError, jeśli obiekt użytkownika nie zawiera tablicy permissions, co jest dokładnie tym rodzajem niespójności, którym zajmuje się następny wzorzec.
Warunki kontrolne stają się problemem, gdy długa funkcja zawiera liczne wyjścia dla każdego drobnego warunku. Najlepiej sprawdzają się w przypadku warunków wstępnych, nieprawidłowych stanów, braku zasobów oraz wyników już ukończonych. Jeśli warunki kontrolne zaczynają opisywać kilka różnych reguł biznesowych, funkcja prawdopodobnie podejmuje zbyt wiele decyzji i powinna zostać podzielona.
2. Normalizuj dane raz, na granicy
JavaScript pozwala wartościom o tym samym znaczeniu występować w różnych formach. Flaga może mieć wartość true, "true", 1 lub "1". ID może być liczbą w bazie danych i łańcuchem w adreście URL. Data może być obiektem Date, łańcuchem ISO, łańcuchem lokalnym lub zapisem czasu. Jeśli każda funkcja interpretuje je samodzielnie, aplikacja stopniowo gromadzi kilka sprzecznych definicji tego, jak powinny wyglądać ważne dane.
Rozwiązaniem jest przekształcenie wartości tylko raz, w momencie, gdy przekraczają istotną granicę, zanim dotkną ich pozostała część kodu:
- Obsługa API przekształca łańcuchy na poziomie transportu w typy oczekiwane przez warstwę usług.
- Adapter danych mapuje zewnętrzną odpowiedź na wewnętrzną strukturę projektu.
- Obsługa wysyłania formularzy usuwa zbędne znaki z łańcuchów i obsługuje pola opcjonalne przed uruchomieniem logiki biznesowej.
Czas ma znaczenie. Gdy przekształcenie jest odkładane, kontrole obronne pojawiają się wszędzie: jedna funkcja sprawdza status === true, inna sprawdza status === „true”, a trzecia polega na sprawdzaniu wartości prawdziwej. Każda z nich działa dla swojego aktualnego wywołującego, ale znaczenie status nie jest już spójne.
Ciasny kontrakt wewnętrzny sprawia również, że testy są bardziej skoncentrowane. Gdy warstwa żądań już przekazuje prawdziwą wartość logiczną, usługa nie potrzebuje testów dla każdej możliwej pisowni tej wartości. Warstwa normalizacji nadal wymaga własnych testów, ale wszystko po jej stronie staje się prostsze.
Normalizacja nie oznacza akceptacji wszystkiego. Jest odpowiednia wtedy, gdy celowo wspierane są kilka reprezentacji; wartości niejednoznaczne lub nieważne powinny nadal być odrzucane. Przekształcanie "abc" w NaN i pozwalanie mu dalej płynąć to nie jest normalizacja, lecz walidacja odłożona na późniejszy, bardziej mylący moment.
3. Obiekty Options sprawiają, że wywołania są samoodnoszące się i łatwiejsze do rozszerzania
Parametry pozycyjne nadają się do jednego lub dwóch oczywistych wejść. Stają się kruche, gdy funkcja rośnie w rozmiarze, szczególnie gdy kilka parametrów ma ten sam typ. Wywołanie w stylu createUser(name, email, true, false, 3) zmusza czytelnika do przejrzenia definicji, aby dowiedzieć się, co oznacza każda pozycja, a typy w TypeScriptie w deklaracji w ogóle nie pomagają przy wywołaniu.
Gdy funkcja zaczyna przyjmować kilka wartości o charakterze konfiguracyjnym, zazwyczaj jaśniejszy jest pojedynczy obiekt opcji:
await createUser({
name,
email,
sendWelcomeEmail: true,
requireVerification: false,
retryLimit: 3,
});
W ten sposób sam wywołanie dokumentuje się samo, właściwości opcjonalne mogą mieć wartości domyślne (zwykle poprzez dekonstrukcję w sygnaturze), a dodanie nowej opcji nie zmusza każdego wywołującego do zmiany kolejności argumentów. Najbardziej czerpią na tym funkcje serwisowe, narzędzia do konfiguracji, ładowarki danych oraz rozwijające się utility’i.
Poza tym eliminuje charakterystyczny „zapach” parametru logicznego. Samo true w wywołaniu prawie nigdy nie wskazuje, jaką funkcję aktywuje; nazwana właściwość tak robi.
Nie stosuj tego wszędzie. add({ a, b }) nie jest jaśniejsze od add(a, b). Użyj obiektu opcji, gdy argumenty opisują konfigurację, gdy kilka z nich jest opcjonalnych, gdy wiele wartości ma ten sam typ lub gdy sygnatura może się stale zmieniać.
4. Mapy wyszukiwania przekształcają rozgałęzienia w wyraźne relacje
Łańcuchy instrukcji if lub długie bloki switch często pojawiają się wtedy, gdy jedna wartość wybiera obsługę, formatatora, walidatora lub strategię. Każdy nowy przypadek wydłuża strumień sterowania i dodaje kolejne miejsce, w którym trzeba uwzględnić zachowanie domyślne.
Tabela odniesień jasno pokazuje tę zależność: ten klucz odpowiada temu zachowaniu. Tutaj status zamówienia wybiera odpowiednią funkcję obsługi, a nieznany status powoduje wyraźny błąd zamiast cichego przejścia dalej.
const statusHandlers = {
pending: handlePendingOrder,
paid: handlePaidOrder,
cancelled: handleCancelledOrder,
};
function processOrder(order) {
const handler = statusHandlers[order.status];
if (!handler) {
throw new Error(`Unsupported order status: ${order.status}`);
}
return handler(order);
}
Ten wzorzec sprawdza się, gdy gałęzie są niezależne i spełniają ten sam kontrakt: serializatory i formatery, obsługujące wydarzenia i stan, walidatory, polityki dostępu. Dodanie nowego przypadku oznacza jedynie dodanie nowej pozycji, a nie rozszerzanie warunku.
Istnieja jedne subtelne pułapki związane z zwykłymi obiektami. Klucze odziedziczone od Object.prototype, takie jak "toString" lub "constructor", również reprezentują funkcje, więc nieoczekiwana wartość stanu może wybrać jedną z nich zamiast skorzystać z mechanizmu ochronnego. Jeśli klucze pochodzą z danych wejściowych użytkownika, należy użyć Map, obiektu utworzonego za pomocą Object.create(null) lub sprawdzenia za pomocą Object.hasOwn.
Należy unikać użycia map do wyszukiwania, gdy logika jest rzeczywiście sekwencyjna. Jeśli decyzja zależy od kombinacji stanów, wcześniejszych wyborów lub uporządkowanych przejść, przekształcenie jej w mapę zazwyczaj ukrywa tok działań zamiast go wyjaśnić. Mapa sprawdza się wtedy, gdy jeden klucz odpowiada jednemu kompletnemu, samodzielnie zrozumiałemu zachowaniu.
Zachowaj złożoną logikę poza samym mapem. Funkcje obsługujące o nazwie umożliwiają bardziej czytelne ślady stosu, są łatwiejsze do testowania osobno i pozwalają mapie skupić się wyłącznie na selekcji.
5. Obiekty rezultatów oddzielają nieoczekiwane błędy od awarii w wykonywaniu
Wyjątki są odpowiednim narzędziem na nieoczekiwane sytuacje, ale wiele nieudanych wyników jest zupełnie normalnych. Wyszukiwanie może nic nie znaleźć. Proces rejestracji może zostać odrzucony, ponieważ adres e-mail jest już zajęty. Przejście stanu może być zabronione przez obowiązujące zasady. Jeśli wszystko to trafia jako ogólne wyjątki, osoby je wywołujące muszą analizować komunikaty o błędach lub łapać szerokie typy błędów, aby dowiedzieć się, co się stało.
Gdy operacja może przynieść kilka możliwych wyników, zwrócenie małego obiektu z rezultatem sprawia, że stają się one częścią umowy. Poniższa funkcja zniżek zwraca jako dane „not found” i „expired”, każde z nich ma stabilny, czytelny przez maszynę code, a w przypadku sukcesu zwraca dostosowany zamówienie.
function applyDiscount(order, discount) {
if (!discount) {
return {
ok: false,
code: "DISCOUNT_NOT_FOUND",
message: "The requested discount does not exist.",
};
}
if (discount.expiresAt < new Date()) {
return {
ok: false,
code: "DISCOUNT_EXPIRED",
message: "The discount has expired.",
};
}
return {
ok: true,
value: {
...order,
total: order.total - discount.amount,
},
};
}
Wywołujący mogą teraz podjąć decyzję na podstawie ok i code bez konieczności czytania tekstu przeznaczonego dla ludzi. Dokładne nazwy pól są mniej istotne niż konsekwentne stosowanie jednej struktury: niektóre projekty modelują to jako złożoną unię w TypeScript na podstawie ok, inne używają success, data i error. Ważne jest, aby wynik był jasno określony. W rzeczywistej implementacji trzeba również zdecydować, co się stanie, gdy zniżka przekroczy łączną kwotę zamówienia, ponieważ tutaj pokazane odejmowanie może dać ujemną wartość.
Obiekty rezultatów nie zastępują wyjątków we wszystkich przypadkach. Rzuć wyjątek, gdy zależność zawiedzie niespodziewanie, gdy zostanie naruszona niezmienność lub gdy bieżąca warstwa nie może nic użytecznego zrobić wobec problemu. Zwróć wyniki, gdy niepowodzenie jest normalną częścią danego obszaru i oczekuje się, że wywołujący będzie na nie reagował.
Kolejną zaletą jest możliwość testowania: przypadki niepowodzeń są określone w typie zwracanym, więc łatwo je sprawdzić. Ponadto szerokie bloki try/catch nie łączą już odrzuconych reguł biznesowych z przerwanym połączeniem z bazą danych.
6. AbortController sprawia, że zarządzanie operacjami asynchronicznymi jest jawne
Wiele błędów w interfejsie użytkownika wynika z nakładania się operacji asynchronicznych. Użytkownik edytuje słowo kluczowe, rozpoczyna się druga prośba, a pierwsza kończy się jako ostatnia. Każda prośba zakończyła się pomyślnie, a mimo to na ekranie pokazują się przestarzałe wyniki.
Prośba nie jest problemem; problemem jest własność danego elementu. Aplikacja musi zdecydować, który wynik w trakcie przetwarzania nadal może służyć do aktualizacji stanu. AbortController odzwierciedla tę decyzję:
- W polu wyszukiwania należy anulować poprzednią prośbę za każdym razem, gdy zmienia się zapytanie.
- W komponencie należy anulować prace w toku, gdy ten jest usuwany.
- W procesie pracy składającym się z powiązanych kroków należy przekazać jeden wspólny
signalprzez wszystkie z nich, aby jedno anulowanie zatrzymało cały łańcuch.
Zamierzanie radzić sobie z anulowaniem. Anulowanie zazwyczaj oznacza, że aplikacja uznała, iż wynik nie ma już znaczenia, co różni się od awarii. W przypadku fetch anulowana próba wywołania kończy się błędem, którego name to "AbortError", dzięki czemu można go wyraźnie rozpoznać. Rejestrowanie każdego anulowania jako błędu zalewa system monitoringu niepotrzebnymi danymi, natomiast traktowanie prawdziwych awarii sieciowych jako anulowań powoduje utratę cennych informacji.
Im bardziej aplikacja polega na pobieraniu danych w tle, wyszukiwaniu w czasie rzeczywistym, przejściach między trasami oraz łańcuchowych żądaniach, tym ważniejsze staje się to zagadnienie. Rozpoczynanie pracy asynchronicznej w JavaScript jest proste; AbortController określa moment, w którym ta praca przestaje należeć do jej inicjatora. Aby lepiej zrozumieć kwestię wyszukiwania, sprawdź, jak naprawić warunki konkurencyjne, których nie da się rozwiązać za pomocą mechanizmu debouncing.
7. Promise.allSettled przechowuje częściowe wyniki niezależnych zadań
Promise.all nadaje się wtedy, gdy łączny wynik jest bezsensowny, jeśli nie wszystkie operacje zakończą się pomyślnie. Jest zbyt agresywny, gdy zadania są niezależne – jedno odrzucenie powoduje usunięcie wszystkich wartości, które udało się otrzymać.
Rozważmy panel sterowania składający się z danych sprzedaży, powiadomień, najnowszych rejestracji oraz informacji o stanie systemu, pochodzących z różnych źródeł. Jeśli usługa opcjonalna zawiedzie i spowoduje wyświetlenie pustej strony, jest to często gorsze doświadczenie niż pokazanie tego, co się załadowało, oraz oznaczenie tego, co nie zadziałało.
Promise.allSettled czeka na każdą obietnicę i raportuje wynik każdej z nich osobno, w postaci obiektu zawierającego status: „fulfilled” oraz value, albo status: „rejected” oraz reason. Użytkownik może wykorzystać udane wartości, wymienić błędy i zdecydować, czy cała operacja nadal ma sens.
Nie próbuj tego robić tylko po to, by zestaw wydawał się odporny na błędy. Niektóre grupy obietnic stanowią, z biznesowego punktu widzenia, jedną transakcję. Jeśli tworzenie zamówienia, rezerwowanie zapasów i pobieranie pieniędzy z karty muszą się powieść lub nie powieść jednocześnie, gromadzenie ustalonych wyników prowadzi do niespójnego stanu; potrzebne są działania korygujące lub silniejsza strategia orkiestracji.
Użyj allSettled, gdy zadania są rzeczywiście niezależne, a osoba je wywołująca ma określony plan na wypadek częściowego niepowodzenia. Prawdziwe pytanie brzmi nie o to, który pomocnik Promise jest wygodny, ale czy możliwe jest nadal uczciwe odpowiedzi po awarii jednej z części. Więcej opcji omówiono w dziewięciu wzorcach Promise dla niezawodnego asynchronicznego JavaScript w produkcji.
8. Funkcje czystej transformacji ułatwiają testowanie reguł danych
Bardzo wiele kodu aplikacji łączy przetwarzanie danych z ich pobieraniem, rejestrowaniem, renderowaniem, przechowywaniem oraz aktualizacją stanu. Testowanie jednej prostej zasady wymaga wtedy odbudowy całego środowiska wokół niej.
Wprowadzenie stabilnych transformacji do małych, czystych funkcji rozwiązuje ten problem. Funkcja czysta przyjmuje dane wejściowe, zwraca wynik i nie zmienia nic poza samą sobą. Typowymi przykładami są funkcje normalizujące rekordy API, obliczające wartości pochodne, grupujące elementy, tworzące obiekty zapytań lub mapujące dane domeny do modelu widoku.
Głębszą zaletą jest wyraźna granica pomiędzy pobieraniem danych a ich interpretacją. Funkcja grupująca rekordy pracowników nie musi wiedzieć, w jaki sposób zostały one załadowane. Funkcja tworząca treść żądania nie potrzebuje stanu komponentów, jeśli wartości można przekazać jako argumenty.
Debugowanie również staje się łatwiejsze. Gdy poprawne dane wejściowe dają błędny wynik, można samodzielnie przeprowadzić transformację. Jeśli transformacja jest poprawna, badania skupiają się na tym, co wygenerowało dane wejściowe lub przyjęło wynik.
Nie każda wyrażenie zasługuje na własną funkcję; mnóstwo jednoliniowych obudów utrudnia nawigację bez dodawania wartości. Wyodrębnij je wtedy, gdy transformacja koduje regułę biznesową, którą używasz wielokrotnie, ma kilka możliwych ścieżek rozwoju lub wymaga własnych testów.
9. Fabryki ujawniają zależności i wspólną konfigurację
Fabryka to jeden z najprostszych sposobów kontrolowania tego, od czego zależy moduł. Zamiast importować klienta bazy danych, loggera, cache’a lub konfigurację z globalnego miejsca, fabryka otrzymuje je jako argumenty i zwraca funkcje, które odnoszą się do nich.
To jest szczególnie przydatne w usługach backendowych i modułach współdzielonych. Zależności stają się widoczne, zmniejsza się ukryte powiązanie między komponentami, a testy mogą korzystać z fałszywego repozytorium lub loggera bez konieczności symulowania importów modułów, przy czym w środowisku produkcyjnym używane są te prawdziwe. Logika biznesowa skupia się nad zachowaniem, a nie na konstruowaniu elementów, z którymi współpracuje.
Fabryki nadają się również do grup funkcji, które dzielą stabilną konfigurację. Fabryka klienta HTTP może raz zapisać podstawową adres URL, token autoryzacji, czas timeout oraz logger i udostępnić metody, które wszystkie z nich wykorzystują, przy czym zamknięcia przechowują tę konfigurację, dzięki czemu nie musi być przekazywana przy każdej wywołaniu.
Ryzykiem jest tworzenie własnego frameworku do iniekcji zależności, gdy baza kodu już posiada taki narzędzie lub wystarczy zwykłe importowanie. Używaj fabryk wtedy, gdy zależności znacząco wpływają na zachowanie aplikacji, gdy testy wymagają kontroli nad nimi lub gdy muszą współistnieć kilka instancji skonfigurowanych inaczej. Celem jest ujawnienie zależności, a nie tworzenie kolejnego frameworku do konserwacji.
10. Cache’i typu Narrow Map i WeakMap pomagają uniknąć powtarzania pracy
Map i WeakMap stanowią doskonałe lokalne cache’y, gdy ta sama kosztowna obliczenia lub metadane specyficzne dla obiektu musiałyby być ponownie przeliczane. Parser może przechowywać wyniki dla poszczególnych ciągów znaków wejściowych. Formatator może ponownie wykorzystywać kosztowne dane pochodne. WeakMap, którego klucze muszą być obiektami, umożliwia przypisanie metadanych do tych obiektów bez utrzymywania ich w pamięci, gdy nic innego do nich nie odwołuje się.
Zapisywanie danych do pamięci cache to łatwa część. Trudniejsze jest ustalenie, czy wartość przechowywana w cache nadal jest ważna.
Pamięci cache zawodzą w przewidywalny sposób:
- Klucz nie uwzględnia pewnych danych wpływających na wynik, przez co cache, który wydaje się poprawny podczas testów, dostarcza przestarzałe wartości w środowisku produkcyjnym.
- Dane leżące u podstaw zmieniają się, a nic nie unieważnia wpisu w cache.
- Jego rozmiar rośnie nieograniczenie, podobnie jak w przypadku
Mapna poziomie modułu, który istnieje przez cały czas trwania procesu serwerowego.
Zanim dodasz pamięć cache, zidentyfikuj rzeczywisty, powtarzający się koszt i określ jego właściciela, strukturę klucza, czas trwania oraz zasady unieważniania. Często ponowne obliczanie jest bezpieczniejsze i wystarczająco szybkie. Pamięć cache ma sens, gdy praca jest kosztowna, dane wejściowe są stabilne, a zakres zastosowania może pozostać wąski.
To wzorzec dobrze ilustruje całą listę. Map ułatwia tworzenie mechanizmów cache, ale łatwość nie jest równoznaczna z poprawnością: przyspieszenie ma sens tylko wtedy, gdy można wyjaśnić, kiedy dana informacja przestaje być prawdziwa.
Wzorce powinny eliminować decyzje, a nie je ukrywać
Wszystkie te wzorce zasługują na swoje miejsce dzięki eliminacji niejednoznaczności, a nie poprzez podkreślanie cech języka. Mechanizmy ochronne szybko eliminują nieważne ścieżki; normalizacja na wczesnym etapie zapewnia kodowi wewnętrznemu przewidywalne wartości; obiekty typu options sprawiają, że wywołania są czytelne i otwarte na rozbudowę; tablice wyszukiwawcze ujawniają bezpośrednie zależności; obiekty rezultatów utrzymują wyniki w sensownej formie; AbortController przypisuje odpowiedzialność za zadania asynchroniczne; allSettled uczciwie informuje o częściowych niepowodzeniach; funkcje czyste izolują decyzje od efektów ubocznych; fabryki ujawniają zależności; a małe bufory pomijają powtarzane operacje, gdy ich ważność jest jasna.
Każdy z nich może również zostać błędnie zastosowany. Otaczanie wywołania z dwoma argumentami obiektem options to czysta formalność; zwracanie wyników z kodu, który powinien rzucić błąd, psuje sytuację; mapa może ukrywać uporządkowaną logikę; cache może stać się przestarzały, a fabryka – abstrakcją, której nikt nie potrzebuje. Wzorzec staje się szkodliwy, gdy wybiera się go ze względu na jego elegancki wygląd, a nie ze względu na rzeczywiste obciążenia, które łagodzi.
Główne wnioski
- Oceniaj wzorzec pod kątem osiąganego kompromisu, a nie pod kątem jego wyrafinowania.
- Niech preferowane będą struktury, których koszty są widoczne, a korzyści przetrwają zmiany w przyszłości.
- Dobre wzorce czynią jawnymi założenia funkcji, jej obowiązki, możliwe błędy oraz poprawne ścieżki działania.
- Najlepsze z nich znikają w tle: czytelnicy zauważają, że problem staje się prostszy, a nie sam wzorzec.
Pozycje pokrewne
- REST vs GraphQL: Prawdziwe kompromisy stojące za każdą architekturą — Wyjaśnia konkretne problemy, które rozwiązują REST i GraphQL, ich wewnętrzną mechanikę oraz ukryte kompromisy, które należy wziąć pod uwagę przed wyborem jednego z nich do swojej API.
- Sześć wzorów projektowych JavaScript do unikania kodu spaghetti — Opisuje sześć praktycznych wzorów JavaScript – Strategia, Fabryka, Obserwator, Adapter, Kompozycja i Rurociąg – które zastępują splątany kod strukturą łatwą w utrzymaniu.