Kompleksowy przewodnik po wzorcach projektowych JavaScript
Opublikowany: 2022-03-11Jako dobry programista JavaScript starasz się pisać czysty, zdrowy i łatwy w utrzymaniu kod. Rozwiązujesz ciekawe wyzwania, które choć unikalne, niekoniecznie wymagają unikalnych rozwiązań. Prawdopodobnie pisałeś kod, który wygląda podobnie do rozwiązania zupełnie innego problemu, z którym miałeś do czynienia wcześniej. Możesz tego nie wiedzieć, ale użyłeś wzorca projektowego JavaScript . Wzorce projektowe to wielokrotnego użytku rozwiązania często występujących problemów w projektowaniu oprogramowania.
W trakcie życia dowolnego języka wiele takich rozwiązań wielokrotnego użytku jest tworzonych i testowanych przez dużą liczbę programistów ze społeczności danego języka. To właśnie dzięki temu połączonemu doświadczeniu wielu programistów takie rozwiązania są tak przydatne, ponieważ pomagają nam pisać kod w zoptymalizowany sposób, jednocześnie rozwiązując problem.
Główne korzyści, jakie czerpiemy z wzorców projektowych, to:
- To sprawdzone rozwiązania: Ponieważ wzorce projektowe są często wykorzystywane przez wielu programistów, możesz mieć pewność, że się sprawdzają. I nie tylko to, możesz być pewien, że były one wielokrotnie poprawiane i prawdopodobnie wprowadzono optymalizacje.
- Można je łatwo ponownie wykorzystać: wzorce projektowe dokumentują rozwiązanie wielokrotnego użytku, które można modyfikować w celu rozwiązania wielu konkretnych problemów, ponieważ nie są one związane z konkretnym problemem.
- Są wyraziste: wzorce projektowe mogą dość elegancko wyjaśnić duże rozwiązanie.
- Ułatwiają komunikację: gdy programiści znają wzorce projektowe, mogą łatwiej komunikować się ze sobą na temat potencjalnych rozwiązań danego problemu.
- Zapobiegają konieczności refaktoryzacji kodu: Jeśli aplikacja jest napisana z myślą o wzorcach projektowych, często nie będzie konieczne ponowne dokonywanie refaktoryzacji kodu, ponieważ zastosowanie prawidłowego wzorca projektowego do danego problemu jest już optymalne rozwiązanie.
- Zmniejszają rozmiar bazy kodu: ponieważ wzorce projektowe są zwykle eleganckimi i optymalnymi rozwiązaniami, zwykle wymagają mniej kodu niż inne rozwiązania.
Wiem, że jesteś gotowy, aby w tym momencie wskoczyć, ale zanim nauczysz się wszystkiego o wzorcach projektowych, przyjrzyjmy się podstawom JavaScript.
Krótka historia JavaScript
JavaScript jest obecnie jednym z najpopularniejszych języków programowania do tworzenia stron internetowych. Początkowo powstał jako rodzaj „kleju” do różnych wyświetlanych elementów HTML, znany jako język skryptowy po stronie klienta, dla jednej z pierwszych przeglądarek internetowych. Nazywany Netscape Navigator, mógł wyświetlać tylko statyczny kod HTML w tym czasie. Jak można przypuszczać, pomysł takiego języka skryptowego doprowadził wówczas do wojen przeglądarek między wielkimi graczami w branży programistycznej przeglądarek, takimi jak Netscape Communications (dziś Mozilla), Microsoft i inne.
Każdy z dużych graczy chciał przeforsować własną implementację tego języka skryptowego, więc Netscape stworzył JavaScript (właściwie zrobił to Brendan Eich), Microsoft stworzył JScript i tak dalej. Jak możesz sobie wyobrazić, różnice między tymi implementacjami były ogromne, więc programowanie dla przeglądarek internetowych odbywało się dla każdej przeglądarki, z naklejkami najlepiej oglądanymi, które były dostarczane ze stroną internetową. Szybko okazało się, że potrzebujemy standardu, rozwiązania dla wielu przeglądarek, które ujednolici proces tworzenia i uprości tworzenie stron internetowych. To, co wymyślili, nazywa się ECMAScript.
ECMAScript to ustandaryzowana specyfikacja języka skryptowego, którą wszystkie nowoczesne przeglądarki starają się obsługiwać. Istnieje wiele implementacji (można powiedzieć dialektów) ECMAScript. Najpopularniejszym z nich jest temat tego artykułu, JavaScript. Od czasu pierwszego wydania ECMAScript ustandaryzował wiele ważnych rzeczy, a dla bardziej zainteresowanych szczegółami istnieje szczegółowa lista standardowych elementów dla każdej wersji ECMAScript dostępnej na Wikipedii. Obsługa przeglądarek dla ECMAScript w wersji 6 (ES6) i nowszych jest nadal niekompletna i musi zostać przetranspilowana do ES5, aby była w pełni obsługiwana.
Co to jest JavaScript?
Aby w pełni zrozumieć treść tego artykułu, zróbmy wprowadzenie do kilku bardzo ważnych cech języka, o których musimy wiedzieć przed zagłębieniem się w wzorce projektowe JavaScript. Gdyby ktoś zapytał Cię „Co to jest JavaScript?” możesz odpowiedzieć gdzieś w linii:
JavaScript jest lekkim, interpretowanym, zorientowanym obiektowo językiem programowania z pierwszorzędnymi funkcjami, najczęściej znanymi jako język skryptowy dla stron internetowych.
Wspomniana definicja oznacza, że kod JavaScript zajmuje mało pamięci, jest łatwy do wdrożenia i łatwy do nauczenia, ze składnią podobną do popularnych języków, takich jak C++ i Java. Jest to język skryptowy, co oznacza, że jego kod jest interpretowany, a nie kompilowany. Posiada wsparcie dla proceduralnych, obiektowych i funkcjonalnych stylów programowania, co czyni go bardzo elastycznym dla programistów.
Do tej pory przyjrzeliśmy się wszystkim cechom, które brzmią jak wiele innych języków, więc przyjrzyjmy się, co jest specyficzne w JavaScript w odniesieniu do innych języków. Wymienię kilka cech i spróbuję wyjaśnić, dlaczego zasługują na szczególną uwagę.
JavaScript obsługuje funkcje pierwszej klasy
Ta cecha była dla mnie kłopotliwa, kiedy dopiero zaczynałem z JavaScriptem, ponieważ wywodziłem się z C/C++. JavaScript traktuje funkcje jako obywateli pierwszej klasy, co oznacza, że możesz przekazywać funkcje jako parametry do innych funkcji, tak jak każdą inną zmienną.
// we send in the function as an argument to be // executed from inside the calling function function performOperation(a, b, cb) { var c = a + b; cb(c); } performOperation(2, 3, function(result) { // prints out 5 console.log("The result of the operation is " + result); })
JavaScript jest oparty na prototypach
Podobnie jak w przypadku wielu innych języków obiektowych, JavaScript obsługuje obiekty, a jednym z pierwszych terminów, które przychodzą na myśl, kiedy myślimy o obiektach, są klasy i dziedziczenie. W tym miejscu staje się to trochę trudne, ponieważ język nie obsługuje klas w postaci zwykłego języka, ale raczej używa czegoś, co nazywa się dziedziczeniem opartym na prototypach lub na instancjach.
Dopiero teraz, w ES6, wprowadzono formalną klasę termiczną, co oznacza, że przeglądarki nadal tego nie obsługują (jeśli pamiętasz, ostatnią w pełni obsługiwaną wersją ECMAScript jest 5.1). Należy jednak zauważyć, że chociaż termin „klasa” został wprowadzony do JavaScript, nadal wykorzystuje pod maską dziedziczenie oparte na prototypach.
Programowanie oparte na prototypach to styl programowania obiektowego, w którym ponowne wykorzystanie zachowania (znane jako dziedziczenie) odbywa się w procesie ponownego wykorzystywania istniejących obiektów za pośrednictwem delegacji, które służą jako prototypy. Zagłębimy się w to bardziej szczegółowo, gdy przejdziemy do sekcji wzorców projektowych artykułu, ponieważ ta cecha jest używana w wielu wzorcach projektowych JavaScript.
Pętle zdarzeń JavaScript
Jeśli masz doświadczenie w pracy z JavaScriptem, z pewnością znasz termin callback function . Dla tych, którzy nie znają tego terminu, funkcja zwrotna to funkcja wysyłana jako parametr (pamiętaj, że JavaScript traktuje funkcje jak obywatele pierwszej klasy) do innej funkcji i jest wykonywana po wyzwoleniu zdarzenia. Jest to zwykle używane do subskrybowania zdarzeń, takich jak kliknięcie myszą lub naciśnięcie przycisku klawiatury.
Za każdym razem, gdy zostanie wywołane zdarzenie, do którego jest podłączony listener (w przeciwnym razie zdarzenie zostanie utracone), komunikat jest wysyłany do kolejki komunikatów, które są przetwarzane synchronicznie, w sposób FIFO (pierwsze weszło-pierwsze wyszło). ). Nazywa się to pętlą zdarzeń .
Każda wiadomość w kolejce ma powiązaną z nią funkcję. Po usunięciu komunikatu z kolejki środowisko wykonawcze całkowicie wykonuje funkcję przed przetworzeniem jakiegokolwiek innego komunikatu. Oznacza to, że jeśli funkcja zawiera inne wywołania funkcji, wszystkie są wykonywane przed przetworzeniem nowej wiadomości z kolejki. Nazywa się to biegiem do ukończenia.
while (queue.waitForMessage()) { queue.processNextMessage(); }
queue.waitForMessage()
synchronicznie czeka na nowe komunikaty. Każdy z przetwarzanych komunikatów ma swój własny stos i jest przetwarzany, dopóki stos nie będzie pusty. Po zakończeniu nowa wiadomość jest przetwarzana z kolejki, jeśli taka istnieje.
Być może słyszałeś również, że JavaScript nie blokuje, co oznacza, że gdy wykonywana jest operacja asynchroniczna, program może przetwarzać inne rzeczy, takie jak odbieranie danych wejściowych od użytkownika, czekając na zakończenie operacji asynchronicznej, nie blokując głównego wątek wykonania. Jest to bardzo użyteczna właściwość JavaScript i na ten temat można by napisać cały artykuł; jednak wykracza to poza zakres tego artykułu.
Czym są wzorce projektowe?
Jak powiedziałem wcześniej, wzorce projektowe to wielokrotnego użytku rozwiązania często występujących problemów w projektowaniu oprogramowania. Przyjrzyjmy się niektórym kategoriom wzorców projektowych.
Proto-wzorce
Jak stworzyć wzór? Załóżmy, że rozpoznałeś powszechnie występujący problem i masz własne unikalne rozwiązanie tego problemu, które nie jest globalnie rozpoznawane i dokumentowane. Korzystasz z tego rozwiązania za każdym razem, gdy napotykasz ten problem i myślisz, że nadaje się do wielokrotnego użytku i że społeczność programistów może na tym skorzystać.
Czy od razu staje się wzorem? Na szczęście nie. Często można mieć dobre praktyki pisania kodu i po prostu pomylić coś, co wygląda jak wzorzec, z czymś, co w rzeczywistości nie jest wzorcem.
Skąd możesz wiedzieć, kiedy to, co Twoim zdaniem rozpoznajesz, jest w rzeczywistości wzorcem projektowym?
Pozyskując opinie innych programistów na ten temat, znając sam proces tworzenia wzorca oraz dobrze zapoznając się z istniejącymi wzorcami. Istnieje faza, przez którą wzorzec musi przejść, zanim stanie się pełnoprawnym wzorcem, i nazywa się to proto-wzorcem.
Proto-wzorzec jest wzorcem, który ma powstać , jeśli przejdzie przez pewien okres testowania przez różnych programistów i scenariusze, w których wzorzec okazuje się przydatny i daje prawidłowe wyniki. Jest dość dużo pracy i dokumentacji – z których większość wykracza poza zakres tego artykułu – do wykonania, aby stworzyć pełnoprawny wzorzec rozpoznawany przez społeczność.
Antywzorce
Ponieważ wzorzec projektowy reprezentuje dobrą praktykę, antywzorzec reprezentuje złą praktykę.
Przykładem antywzorca może być modyfikacja prototypu klasy Object
. Prawie wszystkie obiekty w JavaScript dziedziczą z Object
(pamiętaj, że JavaScript używa dziedziczenia opartego na prototypach), więc wyobraź sobie scenariusz, w którym zmieniłeś ten prototyp. Zmiany w prototypie Object
byłyby widoczne we wszystkich obiektach, które dziedziczą po tym prototypie — czyli w większości obiektów JavaScript . To katastrofa, która czeka, aby się wydarzyć.
Innym przykładem, podobnym do wspomnianego powyżej, jest modyfikowanie obiektów, których nie jesteś właścicielem. Przykładem może być zastąpienie funkcji obiektu używanego w wielu scenariuszach w całej aplikacji. Jeśli pracujesz z dużym zespołem, wyobraź sobie zamieszanie, jakie mogłoby to spowodować; szybko napotkasz kolizje nazw, niezgodne implementacje i koszmary związane z konserwacją.
Podobnie do tego, jak dobrze jest wiedzieć o wszystkich dobrych praktykach i rozwiązaniach, bardzo ważne jest również, aby wiedzieć o tych złych. W ten sposób możesz je rozpoznać i uniknąć popełnienia błędu z góry.
Kategoryzacja wzorców projektowych
Wzorce projektowe można kategoryzować na wiele sposobów, ale najpopularniejszy z nich to:
- Kreatywne wzorce projektowe
- Strukturalne wzorce projektowe
- Behawioralne wzorce projektowe
- Wzorce projektowe współbieżności
- Architektoniczne wzorce projektowe
Kreatywne wzorce projektowe
Wzorce te dotyczą mechanizmów tworzenia obiektów, które optymalizują tworzenie obiektów w porównaniu z podejściem podstawowym. Podstawowa forma tworzenia obiektów może powodować problemy projektowe lub dodatkową złożoność projektu. Kreacyjne wzorce projektowe rozwiązują ten problem, w jakiś sposób kontrolując tworzenie obiektów. Niektóre z popularnych wzorców projektowych w tej kategorii to:
- Metoda fabryczna
- Fabryka abstrakcyjna
- Budowniczy
- Prototyp
- Singel
Strukturalne wzorce projektowe
Te wzorce dotyczą relacji między obiektami. Zapewniają, że jeśli jedna część systemu się zmieni, cały system nie musi się zmieniać razem z nią. Najpopularniejsze wzory w tej kategorii to:
- Adapter
- Most
- Złożony
- Dekorator
- Fasada
- Waga lotna
- Pełnomocnik
Behawioralne wzorce projektowe
Tego typu wzorce rozpoznają, implementują i poprawiają komunikację między różnymi obiektami w systemie. Pomagają zapewnić, że różne części systemu mają zsynchronizowane informacje. Popularne przykłady tych wzorów to:
- Łańcuch odpowiedzialności
- Komenda
- Iterator
- Mediator
- Memento
- Obserwator
- Stan
- Strategia
- Gość
Wzorce projektowe współbieżności
Tego typu wzorce projektowe radzą sobie z wielowątkowymi paradygmatami programowania. Niektóre z popularnych to:
- Obiekt aktywny
- Reakcja nuklearna
- Planista
Architektoniczne wzorce projektowe
Wzorce projektowe wykorzystywane do celów architektonicznych. Niektóre z najbardziej znanych to:
- MVC (kontroler widoku modelu)
- MVP (Model-Widok-Prezent)
- MVVM (model-widok-widokmodel)
W dalszej części przyjrzymy się bliżej niektórym z wyżej wymienionych wzorców projektowych z przykładami podanymi w celu lepszego zrozumienia.
Przykłady wzorów projektowych
Każdy z wzorców projektowych reprezentuje określony rodzaj rozwiązania określonego typu problemu. Nie ma uniwersalnego zestawu wzorów, który zawsze jest najlepiej dopasowany. Musimy dowiedzieć się, kiedy dany wzór okaże się przydatny i czy przyniesie rzeczywistą wartość. Po zapoznaniu się z wzorcami i scenariuszami, do których najlepiej nadają się, możemy łatwo określić, czy określony wzorzec dobrze pasuje do danego problemu.
Pamiętaj, że zastosowanie niewłaściwego wzorca do danego problemu może prowadzić do niepożądanych efektów, takich jak niepotrzebna złożoność kodu, niepotrzebne obciążenie wydajnością, a nawet pojawienie się nowego antywzorca.
To wszystko są ważne rzeczy, które należy wziąć pod uwagę, myśląc o zastosowaniu wzorca projektowego do naszego kodu. Przyjrzymy się niektórym wzorcom projektowym, które osobiście uznałem za przydatne i uważam, że każdy starszy programista JavaScript powinien być zaznajomiony.
Konstruktor wzór
Kiedy myślimy o klasycznych językach obiektowych, konstruktor to specjalna funkcja w klasie, która inicjuje obiekt z pewnym zestawem wartości domyślnych i/lub przesłanych.
Typowymi sposobami tworzenia obiektów w JavaScript są trzy następujące sposoby:
// either of the following ways can be used to create a new object var instance = {}; // or var instance = Object.create(Object.prototype); // or var instance = new Object();
Po utworzeniu obiektu istnieją cztery sposoby (od ES3) dodawania właściwości do tych obiektów. Są to:
// supported since ES3 // the dot notation instance.key = "A key's value"; // the square brackets notation instance["key"] = "A key's value"; // supported since ES5 // setting a single property using Object.defineProperty Object.defineProperty(instance, "key", { value: "A key's value", writable: true, enumerable: true, configurable: true }); // setting multiple properties using Object.defineProperties Object.defineProperties(instance, { "firstKey": { value: "First key's value", writable: true }, "secondKey": { value: "Second key's value", writable: false } });
Najpopularniejszym sposobem tworzenia obiektów są nawiasy klamrowe, a do dodawania właściwości notacja kropkowa lub nawiasy kwadratowe. Używał ich każdy, kto miał jakiekolwiek doświadczenie z JavaScriptem.
Wspomnieliśmy wcześniej, że JavaScript nie obsługuje klas natywnych, ale obsługuje konstruktory poprzez użycie „nowego” słowa kluczowego poprzedzonego przed wywołaniem funkcji. W ten sposób możemy użyć funkcji jako konstruktora i zainicjować jej właściwości w taki sam sposób, jak w przypadku klasycznego konstruktora języka.
// we define a constructor for Person objects function Person(name, age, isDeveloper) { this.name = name; this.age = age; this.isDeveloper = isDeveloper || false; this.writesCode = function() { console.log(this.isDeveloper? "This person does write code" : "This person does not write code"); } } // creates a Person instance with properties name: Bob, age: 38, isDeveloper: true and a method writesCode var person1 = new Person("Bob", 38, true); // creates a Person instance with properties name: Alice, age: 32, isDeveloper: false and a method writesCode var person2 = new Person("Alice", 32); // prints out: This person does write code person1.writesCode(); // prints out: this person does not write code person2.writesCode();
Jednak wciąż jest tu miejsce na poprawę. Jeśli pamiętasz, wspomniałem wcześniej, że JavaScript używa dziedziczenia opartego na prototypach. Problem z poprzednim podejściem polega na tym, że metoda writesCode
zostaje przedefiniowana dla każdego wystąpienia konstruktora Person
. Możemy tego uniknąć, ustawiając metodę w prototypie funkcji:
// we define a constructor for Person objects function Person(name, age, isDeveloper) { this.name = name; this.age = age; this.isDeveloper = isDeveloper || false; } // we extend the function's prototype Person.prototype.writesCode = function() { console.log(this.isDeveloper? "This person does write code" : "This person does not write code"); } // creates a Person instance with properties name: Bob, age: 38, isDeveloper: true and a method writesCode var person1 = new Person("Bob", 38, true); // creates a Person instance with properties name: Alice, age: 32, isDeveloper: false and a method writesCode var person2 = new Person("Alice", 32); // prints out: This person does write code person1.writesCode(); // prints out: this person does not write code person2.writesCode();
Teraz oba wystąpienia konstruktora Person
mogą uzyskać dostęp do udostępnionego wystąpienia metody writesCode()
.

Wzór modułu
Jeśli chodzi o osobliwości, JavaScript nigdy nie przestaje zadziwiać. Inną osobliwą rzeczą dla JavaScriptu (przynajmniej jeśli chodzi o języki obiektowe) jest to, że JavaScript nie obsługuje modyfikatorów dostępu. W klasycznym języku OOP użytkownik definiuje klasę i określa prawa dostępu dla jej członków. Ponieważ JavaScript w swojej zwykłej formie nie obsługuje klas ani modyfikatorów dostępu, programiści JavaScript wymyślili sposób na naśladowanie tego zachowania w razie potrzeby.
Zanim przejdziemy do szczegółów wzorca modułu, porozmawiajmy o koncepcji domknięcia. Zamknięcie to funkcja z dostępem do zakresu nadrzędnego, nawet po zamknięciu funkcji nadrzędnej. Pomagają nam naśladować zachowanie modyfikatorów dostępu poprzez określanie zakresu. Pokażmy to na przykładzie:
// we used an immediately invoked function expression // to create a private variable, counter var counterIncrementer = (function() { var counter = 0; return function() { return ++counter; }; })(); // prints out 1 console.log(counterIncrementer()); // prints out 2 console.log(counterIncrementer()); // prints out 3 console.log(counterIncrementer());
Jak widać, używając IIFE, powiązaliśmy zmienną licznika z funkcją, która została wywołana i zamknięta, ale nadal jest dostępna dla funkcji potomnej, która ją zwiększa. Ponieważ nie możemy uzyskać dostępu do zmiennej licznika spoza wyrażenia funkcji, uczyniliśmy ją prywatną poprzez manipulację zakresem.
Za pomocą zamknięć możemy tworzyć obiekty z częściami prywatnymi i publicznymi. Są to tak zwane moduły i są bardzo przydatne, gdy chcemy ukryć pewne części obiektu i udostępnić tylko interfejs użytkownikowi modułu. Pokażmy to na przykładzie:
// through the use of a closure we expose an object // as a public API which manages the private objects array var collection = (function() { // private members var objects = []; // public members return { addObject: function(object) { objects.push(object); }, removeObject: function(object) { var index = objects.indexOf(object); if (index >= 0) { objects.splice(index, 1); } }, getObjects: function() { return JSON.parse(JSON.stringify(objects)); } }; })(); collection.addObject("Bob"); collection.addObject("Alice"); collection.addObject("Franck"); // prints ["Bob", "Alice", "Franck"] console.log(collection.getObjects()); collection.removeObject("Alice"); // prints ["Bob", "Franck"] console.log(collection.getObjects());
Najbardziej użyteczną rzeczą, jaką wprowadza ten wzorzec, jest wyraźne oddzielenie prywatnych i publicznych części obiektu, co jest koncepcją bardzo podobną do koncepcji deweloperów wywodzących się z klasycznego tła obiektowego.
Jednak nie wszystko jest takie idealne. Jeśli chcesz zmienić widoczność członka, musisz zmodyfikować kod wszędzie tam, gdzie użyłeś tego członka, ze względu na inny charakter dostępu do części publicznych i prywatnych. Ponadto metody dodane do obiektu po ich utworzeniu nie mogą uzyskać dostępu do prywatnych członków obiektu.
Ujawnianie wzorca modułu
Ten wzorzec jest ulepszeniem wprowadzonym do wzorca modułu, jak pokazano powyżej. Główna różnica polega na tym, że piszemy całą logikę obiektu w prywatnym zakresie modułu, a następnie po prostu ujawniamy części, które chcemy, aby były publiczne, zwracając anonimowy obiekt. Możemy również zmienić nazewnictwo członków prywatnych podczas mapowania członków prywatnych na odpowiadających im członków publicznych.
// we write the entire object logic as private members and // expose an anonymous object which maps members we wish to reveal // to their corresponding public members var namesCollection = (function() { // private members var objects = []; function addObject(object) { objects.push(object); } function removeObject(object) { var index = objects.indexOf(object); if (index >= 0) { objects.splice(index, 1); } } function getObjects() { return JSON.parse(JSON.stringify(objects)); } // public members return { addName: addObject, removeName: removeObject, getNames: getObjects }; })(); namesCollection.addName("Bob"); namesCollection.addName("Alice"); namesCollection.addName("Franck"); // prints ["Bob", "Alice", "Franck"] console.log(namesCollection.getNames()); namesCollection.removeName("Alice"); // prints ["Bob", "Franck"] console.log(namesCollection.getNames());
Odkrywający wzorzec modułu to jeden z co najmniej trzech sposobów, w jakie możemy zaimplementować wzorzec modułu. Różnice między ujawniającym wzorcem modułu a innymi wariantami wzorca modułu dotyczą przede wszystkim sposobu odwoływania się do publicznych członków. W rezultacie odsłaniający wzór modułu jest znacznie łatwiejszy w użyciu i modyfikacji; jednak może okazać się kruche w niektórych scenariuszach, na przykład przy użyciu obiektów RMP jako prototypów w łańcuchu dziedziczenia. Sytuacje problematyczne to:
- Jeśli mamy funkcję prywatną, która odnosi się do funkcji publicznej, nie możemy nadpisać funkcji publicznej, ponieważ funkcja prywatna będzie nadal odnosić się do prywatnej realizacji funkcji, wprowadzając w ten sposób błąd do naszego systemu.
- Jeśli mamy element publiczny wskazujący na zmienną prywatną i spróbujemy przesłonić element publiczny spoza modułu, inne funkcje nadal odwołują się do prywatnej wartości zmiennej, wprowadzając błąd do naszego systemu.
Jednotonowy wzór
Wzorzec singleton jest używany w scenariuszach, w których potrzebujemy dokładnie jednej instancji klasy. Na przykład, musimy mieć obiekt, który zawiera jakąś konfigurację do czegoś. W takich przypadkach nie jest konieczne tworzenie nowego obiektu, gdy obiekt konfiguracyjny jest wymagany gdzieś w systemie.
var singleton = (function() { // private singleton value which gets initialized only once var config; function initializeConfiguration(values){ this.randomNumber = Math.random(); values = values || {}; this.number = values.number || 5; this.size = values.size || 10; } // we export the centralized method for retrieving the singleton value return { getConfig: function(values) { // we initialize the singleton value only once if (config === undefined) { config = new initializeConfiguration(values); } // and return the same config value wherever it is asked for return config; } }; })(); var configObject = singleton.getConfig({ "size": 8 }); // prints number: 5, size: 8, randomNumber: someRandomDecimalValue console.log(configObject); var configObject1 = singleton.getConfig({ "number": 8 }); // prints number: 5, size: 8, randomNumber: same randomDecimalValue as in first config console.log(configObject1);
Jak widać na przykładzie, wygenerowana liczba losowa jest zawsze taka sama, podobnie jak przesłane wartości konfiguracyjne.
Należy zauważyć, że punkt dostępu do pobierania wartości singleton musi być tylko jeden i bardzo dobrze znany. Wadą korzystania z tego wzorca jest to, że jest raczej trudny do przetestowania.
Wzór obserwatora
Wzorzec obserwatora jest bardzo przydatnym narzędziem, gdy mamy scenariusz, w którym musimy poprawić komunikację między różnymi częściami naszego systemu w zoptymalizowany sposób. Sprzyja luźnemu sprzężeniu między przedmiotami.
Istnieją różne wersje tego wzoru, ale w najbardziej podstawowej formie mamy dwie główne części wzoru. Pierwszy to podmiot, a drugi to obserwatorzy.
Temat obsługuje wszystkie operacje dotyczące określonego tematu, który subskrybują obserwatorzy. Operacje te przypisują obserwatora do określonego tematu, wypisują obserwatora z określonego tematu i powiadamiają obserwatorów o określonym temacie po opublikowaniu wydarzenia.
Istnieje jednak odmiana tego wzorca zwana wzorcem wydawcy/subskrybenta, której użyję jako przykładu w tej sekcji. Główną różnicą między klasycznym wzorcem obserwatora a wzorcem wydawcy/subskrybenta jest to, że wydawca/subskrybent promuje jeszcze bardziej luźne sprzężenie niż wzorzec obserwatora.
We wzorcu obserwator podmiot przechowuje odniesienia do subskrybowanych obserwatorów i wywołuje metody bezpośrednio z samych obiektów, podczas gdy we wzorcu wydawca/subskrybent mamy kanały, które służą jako pomost komunikacyjny między subskrybentem a wydawcą. Wydawca uruchamia zdarzenie i po prostu wykonuje funkcję wywołania zwrotnego wysłaną dla tego zdarzenia.
Zamierzam wyświetlić krótki przykład wzorca wydawca/subskrybent, ale dla zainteresowanych, klasyczny przykład wzorca obserwatora można łatwo znaleźć w Internecie.
var publisherSubscriber = {}; // we send in a container object which will handle the subscriptions and publishings (function(container) { // the id represents a unique subscription id to a topic var id = 0; // we subscribe to a specific topic by sending in // a callback function to be executed on event firing container.subscribe = function(topic, f) { if (!(topic in container)) { container[topic] = []; } container[topic].push({ "id": ++id, "callback": f }); return id; } // each subscription has its own unique ID, which we use // to remove a subscriber from a certain topic container.unsubscribe = function(topic, id) { var subscribers = []; for (var subscriber of container[topic]) { if (subscriber.id !== id) { subscribers.push(subscriber); } } container[topic] = subscribers; } container.publish = function(topic, data) { for (var subscriber of container[topic]) { // when executing a callback, it is usually helpful to read // the documentation to know which arguments will be // passed to our callbacks by the object firing the event subscriber.callback(data); } } })(publisherSubscriber); var subscriptionID1 = publisherSubscriber.subscribe("mouseClicked", function(data) { console.log("I am Bob's callback function for a mouse clicked event and this is my event data: " + JSON.stringify(data)); }); var subscriptionID2 = publisherSubscriber.subscribe("mouseHovered", function(data) { console.log("I am Bob's callback function for a hovered mouse event and this is my event data: " + JSON.stringify(data)); }); var subscriptionID3 = publisherSubscriber.subscribe("mouseClicked", function(data) { console.log("I am Alice's callback function for a mouse clicked event and this is my event data: " + JSON.stringify(data)); }); // NOTE: after publishing an event with its data, all of the // subscribed callbacks will execute and will receive // a data object from the object firing the event // there are 3 console.logs executed publisherSubscriber.publish("mouseClicked", {"data": "data1"}); publisherSubscriber.publish("mouseHovered", {"data": "data2"}); // we unsubscribe from an event by removing the subscription ID publisherSubscriber.unsubscribe("mouseClicked", subscriptionID3); // there are 2 console.logs executed publisherSubscriber.publish("mouseClicked", {"data": "data1"}); publisherSubscriber.publish("mouseHovered", {"data": "data2"});
Ten wzorzec projektowy jest przydatny w sytuacjach, gdy musimy wykonać wiele operacji na jednym wywoływanym zdarzeniu. Wyobraź sobie, że masz scenariusz, w którym musimy wykonać wiele wywołań AJAX do usługi zaplecza, a następnie wykonać inne wywołania AJAX w zależności od wyniku. Musiałbyś zagnieździć wywołania AJAX jedno w drugim, prawdopodobnie wchodząc w sytuację zwaną piekłem wywołań zwrotnych. Użycie wzorca wydawca/subskrybent jest znacznie bardziej eleganckim rozwiązaniem.
Minusem korzystania z tego wzorca jest trudne testowanie różnych części naszego systemu. Nie ma eleganckiego sposobu, abyśmy mogli dowiedzieć się, czy subskrybowane części systemu zachowują się zgodnie z oczekiwaniami.
Wzorzec mediatora
Pokrótce omówimy wzór, który jest również bardzo przydatny, gdy mówimy o systemach odsprzęganych. Kiedy mamy scenariusz, w którym wiele części systemu musi się komunikować i koordynować, być może dobrym rozwiązaniem byłoby wprowadzenie mediatora.
Mediator to obiekt, który służy jako centralny punkt komunikacji między różnymi częściami systemu i obsługuje przepływ pracy między nimi. Teraz należy podkreślić, że obsługuje przepływ pracy. Dlaczego to jest ważne?
Ponieważ istnieje duże podobieństwo do wzorca wydawca/subskrybent. Możesz zadać sobie pytanie, OK, więc oba te wzorce pomagają wdrożyć lepszą komunikację między obiektami… Jaka jest różnica?
Różnica polega na tym, że mediator obsługuje przepływ pracy, podczas gdy wydawca/subskrybent używa czegoś, co nazywa się rodzajem komunikacji „uruchom i zapomnij”. Wydawca/subskrybent jest po prostu agregatorem zdarzeń, co oznacza, że po prostu zajmuje się uruchamianiem zdarzeń i informowaniem właściwych subskrybentów, które zdarzenia zostały uruchomione. Agregator zdarzeń nie dba o to, co dzieje się po wyzwoleniu zdarzenia, co nie ma miejsca w przypadku mediatora.
Dobrym przykładem mediatora jest interfejs typu kreator. Załóżmy, że masz duży proces rejestracji systemu, nad którym pracowałeś. Często, gdy użytkownik potrzebuje dużej ilości informacji, dobrą praktyką jest rozbicie tego na wiele etapów.
W ten sposób kod będzie dużo czystszy (łatwiejszy w utrzymaniu), a użytkownik nie będzie przytłoczony ilością informacji wymaganych tylko w celu zakończenia rejestracji. Mediator to obiekt, który obsługiwałby etapy rejestracji, biorąc pod uwagę różne możliwe przepływy pracy, które mogą mieć miejsce ze względu na to, że każdy użytkownik może potencjalnie mieć unikalny proces rejestracji.
Oczywistą korzyścią wynikającą z tego wzorca projektowego jest lepsza komunikacja między różnymi częściami systemu, które teraz komunikują się za pośrednictwem mediatora i czystszej bazy kodu.
Minusem byłoby to, że teraz wprowadziliśmy do naszego systemu pojedynczy punkt awarii, co oznacza, że jeśli nasz mediator zawiedzie, cały system może przestać działać.
Wzór prototypu
Jak już wspomnieliśmy w całym artykule, JavaScript nie obsługuje klas w swojej natywnej formie. Dziedziczenie między obiektami jest realizowane za pomocą programowania opartego na prototypach.
Umożliwia nam tworzenie obiektów, które mogą służyć jako prototyp dla innych tworzonych obiektów. Obiekt prototypowy jest używany jako plan dla każdego obiektu tworzonego przez konstruktora.
Jak już mówiliśmy o tym w poprzednich sekcjach, pokażmy prosty przykład, jak można użyć tego wzorca.
var personPrototype = { sayHi: function() { console.log("Hello, my name is " + this.name + ", and I am " + this.age); }, sayBye: function() { console.log("Bye Bye!"); } }; function Person(name, age) { name = name || "John Doe"; age = age || 26; function constructorFunction(name, age) { this.name = name; this.age = age; }; constructorFunction.prototype = personPrototype; var instance = new constructorFunction(name, age); return instance; } var person1 = Person(); var person2 = Person("Bob", 38); // prints out Hello, my name is John Doe, and I am 26 person1.sayHi(); // prints out Hello, my name is Bob, and I am 38 person2.sayHi();
Take notice how prototype inheritance makes a performance boost as well because both objects contain a reference to the functions which are implemented in the prototype itself, instead of in each of the objects.
Command Pattern
The command pattern is useful in cases when we want to decouple objects executing the commands from objects issuing the commands. For example, imagine a scenario where our application is using a large number of API service calls. Then, let's say that the API services change. We would have to modify the code wherever the APIs that changed are called.
This would be a great place to implement an abstraction layer, which would separate the objects calling an API service from the objects which are telling them when to call the API service. This way, we avoid modification in all of the places where we have a need to call the service, but rather have to change only the objects which are making the call itself, which is only one place.
As with any other pattern, we have to know when exactly is there a real need for such a pattern. We need to be aware of the tradeoff we are making, as we are adding an additional abstraction layer over the API calls, which will reduce performance but potentially save a lot of time when we need to modify objects executing the commands.
// the object which knows how to execute the command var invoker = { add: function(x, y) { return x + y; }, subtract: function(x, y) { return x - y; } } // the object which is used as an abstraction layer when // executing commands; it represents an interface // toward the invoker object var manager = { execute: function(name, args) { if (name in invoker) { return invoker[name].apply(invoker, [].slice.call(arguments, 1)); } return false; } } // prints 8 console.log(manager.execute("add", 3, 5)); // prints 2 console.log(manager.execute("subtract", 5, 3));
Facade Pattern
The facade pattern is used when we want to create an abstraction layer between what is shown publicly and what is implemented behind the curtain. It is used when an easier or simpler interface to an underlying object is desired.
A great example of this pattern would be selectors from DOM manipulation libraries such as jQuery, Dojo, or D3. You might have noticed using these libraries that they have very powerful selector features; you can write in complex queries such as:
jQuery(".parent .child div.span")
It simplifies the selection features a lot, and even though it seems simple on the surface, there is an entire complex logic implemented under the hood in order for this to work.
We also need to be aware of the performance-simplicity tradeoff. It is desirable to avoid extra complexity if it isn't beneficial enough. In the case of the aforementioned libraries, the tradeoff was worth it, as they are all very successful libraries.
Następne kroki
Design patterns are a very useful tool which any senior JavaScript developer should be aware of. Knowing the specifics regarding design patterns could prove incredibly useful and save you a lot of time in any project's lifecycle, especially the maintenance part. Modifying and maintaining systems written with the help of design patterns which are a good fit for the system's needs could prove invaluable.
In order to keep the article relatively brief, we will not be displaying any more examples. For those interested, a great inspiration for this article came from the Gang of Four book Design Patterns: Elements of Reusable Object-Oriented Software and Addy Osmani's Learning JavaScript Design Patterns . I highly recommend both books.