Blog

07 maja 2016 Wojciech Zawistowski

Aplikacja w Angularze 1.5 jako drzewo komponentów [“NG 1.5 z placu boju” 2/7]

Aplikacja w Angularze 1.5 jako drzewo komponentów [“NG 1.5 z placu boju” 2/7]

Ten post to 2-ga część serii “Angular 1.5 z placu boju”, prezentującej architekturę opartej na komponentach, “gotowej na NG 2” aplikacji w Angularze 1.5, nad którą pracujemy.

Wprowadzona w Angularze 1.5 metoda module.component() może wydawać się kosmetycznym dodatkiem, jednak podejście, które ta metoda promuje (a które wykorzystujemy “do oporu”) owocuje zupełnie innym typem architektury niż opisywana w większości “klasycznych” tutoriali do Angulara, mam więc nadzieję, że uda Ci się na bazie naszych doświadczeń paru rzeczy dowiedzieć.

Spis Treści:

  • Czy opłaca się startować z nową aplikacją w Angularze 1.5?
  • Aplikacja w Angularze 1.5 jako drzewo komponentów [TEN POST]
  • Komunikacja pomiędzy komponentami w Angularze 1.5 [WKRÓTCE]
  • Elastyczna struktura projektu w Angularze 1.5 (architektura “fraktalna”) [WKRÓTCE]
  • Pisanie projektu w Angularze 1.5 w ES6/ES2015 [WKRÓTCE]
  • Testowanie jednostkowe komponentów Angulara 1.5 – szczegółowy przewodnik [WKRÓTCE]
  • Testy E2E opartej na komponentach aplikacji w Angularze 1.5 [WKRÓTCE]

Nowy sposób pisania aplikacji w Angularze 1.

Wprowadzony w NG 1.5 pozornie kosmetyczny dodatek, metoda module.component(), może mieć duży wpływ na Twoją aplikację. Umożliwia ona zaprojektowanie Twojej aplikacji jako jednego wielkiego drzewa komponentów, i całkowitego pozbycia się kontrolerów (pamiętasz [nie]sławną prezentację “RIP”, przedstawiającą pierwsze spojrzenie na architekturę Angulara 2?). Wprawdzie NG 1.5 nie wymusza na Tobie takiej architektury tak jak NG 2, ale jeżeli chcesz, otwiera Ci taką możliwość.

Osiągnięcie podobnego efektu było możliwe także w NG 1.4, poprzez odpowiednią kombinację dyrektyw, isolated scope oraz opcji controllerAs. Było to jednak skomplikowane i stawiało duży opór materii, przez co ludzie zazwyczaj korzystali z tej możliwości na znacznie mniejszą skalę, jednynie dla paru wybranych komponentów. NG 1.5 zmienia reguły gry. Metoda module.component() tak bardzo to ułatwia, że tworzenie komponentów nawet dla najmniejszych, najbanalniejszych fragmentów interfejsu staje się naturalne.

My poszliśmy z naszą aplikacją tak daleko w tym kierunku jak to tylko możliwe i jesteśmy bardzo zadowoleni z takiej opartej na komponentach architektury. W tym poście pokażę jak ona wygląda.

Anatomia komponentu.

Kompletna definicja komponentu składa się z 3 części: binding-ów, kontrolera i szablonu (template). Taki przykładowy komponent może wyglądać jak poniżej:

(wszystkie przykłady w tym poście używają składni ES6/ES2015 – więcej na ten temat w jednym z kolejnych postów tej serii)

angular.module('contacts', []).component('person', {
    bindings: {
        person: '<',
        onRemoveContact: '&'
    },
    controller: class {
        onClick() {
            this.onRemoveContact({ id: this.person.id });
        }

        get fullName() {
            return `${this.person.name} ${this.person.surname}`;
        }
    },
    template: `
        <div>
            <span>{{ $ctrl.fullName }}</span>
            <button ng-click="$ctrl.onClick()">Remove</button>
        </div>
    `
});

Nie licząc dwóch drobnych szczegółów (brakującej metody constructor w klasie kontrolera, w której możesz wstrzykiwać zależności komponentu, oraz brakującej opcji transclude – oba elementy nieistotne dla tego przykładu), to wszystko, co możesz zawrzeć w definicji komponentu NG 1.5. Prawda, że łatwe i proste?

Omówmy pokrótce trzy główne składowe komponentu:

binding-i

Binding-i to parametry przekazywane z komponentu-rodzica do komponentu-dziecka. Są one przekazywane w szablonie, jako atrybuty tagu. Na przykład, komponent z naszego powyższego przykładu, mógłby zostać użyty w szablonie innego komponentu w taki sposób:

<person person="someObject" on-remove-contact="someFunction(id)"></person>

Komponent ma domyślnie isolated scope, i nie powinien mieć nigdy bezpośredniego dostępu do scope-u swojego rodzica (ani do
root scope-u), tak więc binding-i to jedyny sposób, w jaki można przekazać komponentowi dane.

W Angularze 1.5 wprowadzono nowy typ binding-u – binding jednokierunkowy ('<'). Powinien on być zawsze używany przy przekazywaniu danych do komponentu. Stary, dwukierunkowy binding ('=') nie powinien być nigdy używany – przepływ danych w drzewie komponentów powinien być zawsze jednokierunkowy, z góry na dół (więcej o przepływie danych i komunikacji pomiędzy komponentami w jednym z kolejnych postów tej serii).

Oprócz bindingu jednokierunkowego ('<'), istnieją jeszcze dwa rodzaje bindingów, których możesz użyć w komponencie: '&', wykorzystywany do przekazywania do komponentu callback-ów (używany bardzo często, w celu poinformowania rodzica komponentu o tym że wydarzyła się jakaś akcja lub o zmianie stanu komponentu – więcej na ten temat w najbliższym poście), oraz '@', wykorzystywany do przekazywania do komponentu statycznych opcji konfiguracyjnych (używany niezbyt często).

Binding-i mogą być także aliasowane (np. zamiast person: '<' mógłbyś napisać person: '<contact' i dzięki temu widzieć atrybut z poziomu rodzica komponentu jako person="...", natomiast wewnątrz komponentu jako this.contact), uważamy jednak, że obniża to czytelność kodu i nie korzystamy w naszej aplikacji z tej funkcjonalności.

kontroler

Odpowiedzialność kontrolera jest dwojaka:

  • Orkiestracja fragmentów drzewa komponentów (komunikacja z backend-owymi API, przesył danych w dół, do komponentów-dzieci, warunkowe ukrywanie i wyświetlanie komponentów-dzieci itp.). To dotyczy jedynie niewielkiej liczby wybranych komponentów położonych wysoko w hierarchii drzewa komponentów (więcej na temat różnych typów komponentów oraz komunikacji między komponentami w kolejnym poście).
  • Upraszczanie szablonów poprzez wydzielenie z nich logiki związanej z formatowaniem i prezentacją danych (szablony powinny być tak “głupie” i proste, jak to tylko możliwe; dobrą zasadą jest nie używanie w szablonach niczego ponad proste zmienne).

Poniżej przykład takiego prezentacyjnego komponentu, z całą logiką wydzieloną z szablonu do kontrolera:

angular.module('streaming', []).component('welcomeScreen', {
    bindings: {
        user: '<',
        supportedCountries: '<'
    },
    controller: class {
        get userFullName() {
            return `${this.user.name} ${this.user.surname}`;
        }

        get userCountry() {
            return this.user.location.country;
        }

        get serviceAvailable() {
            return this.supportedCountries.indexOf(this.userCountry) !== -1;
        }

        get serviceStatus() {
            return this.serviceAvailable ? 'available' : 'unavailable';
        }
    },
    template: `
        <div>
            <span>Hello {{ $ctrl.userFullName }}!</span>
            <span>Our service is {{ $ctrl.serviceStatus }} in {{ $ctrl.userCountry }}.
            <button ng-if="$ctrl.serviceAvailable">Watch the video</button>
        </div>
    `
});

Wyobraź sobie teraz, że miałbyś inline-ować całą tą (pozornie prostą) prezentacyjną logikę bezpośrednio w szablonie:

<div>
    <span>Hello {{ $ctrl.user.name }} {{ $ctrl.user.surname }}!</span>
    <span>Our service is {{ $ctrl.supportedCountries.indexOf($ctrl.user.location.country) !== -1 ? 'available' : 'unavailable' }} in {{ $ctrl.user.location.country }}.
    <button ng-if="$ctrl.supportedCountries.indexOf($ctrl.user.location.country) !== -1">Watch the video</button>
</div>

Co za bałagan! Całkowicie nieczytelne i nie do utrzymania!

Użyteczną techniką, o której warto pamiętać, jest wykorzystanie w kontrolerach ES5-kowych getterów. Daje nam to dwie korzyści:

  • Sprawia, że szablony są jeszcze czystsze (<span>{{ $ctrl.serviceStatus }}</span> zamiast <span>{{ $ctrl.serviceStatus() }}</span>).
  • Sprawia, że metody te są nieodróżnialne od zmiennych, staje się więc możliwe, by w pierwszej kolejności użyć zwykłej zmiennej, a po jakimś czasie “zupgrade-ować” ją do metody, gdy pojawi się potrzeba dodania do tej zmiennej bardziej złożonej logiki – bez konieczności refaktoryzowania szablonu, by dodać do wszystkich odwołań do tej zmiennej nawiasy.

szablon

Szablon jest główną częścią komponentu i jego jedynym niezbędnym elementem. O szablonie nie za wiele jest do pisania. Jedyną nową informacją dotyczącą szablonów, związaną z komponentami NG 1.5, jest to, że wszyskie binding-i komponentu, jak również wszystkie pola i metody kontrolera, są domyślnie widoczne w szablonie jako pola obiektu $ctrl. Stosując opcję controllerAs można aliasować tą zmienną dowolną inną nazwą, jednak naszym zdaniem nie jest to specjalnie przydatne i nigdy tego w naszej aplikacji nie stosujemy.

Również, podobnie jak w poprzednich wersjach Angulara, szablon może znajdować się w osobnym pliku (powinieneś w takim wypadku użyć opcji templateUrl zamiast template). Zdania na temat czytelności inline-owych i zewnętrznych szablonów były w naszym zespole podzielone. Z jednej strony wygodnie widzieć wszystko na raz na ekranie, z drugiej strony, w przypadku bardziej złożonych, większych komponentów inline-owanie wszystkiego powoduje zbytnie wydłużenie pliku, co obniża czytelność zamiast ją poprawiać. Na chwilę obecną, pozostaliśmy dla spójności przy używaniu wszędzie szablonów inline – ale Ty wybierz ten wariant, który najlepiej Ci odpowiada.

komponenty bez kontrolera, bindingów lub z samym szablonem

Jak wspomniałem powyżej, szablon jest jedynym wymaganym elementem definicji komponentu. Czy ma jednak sens tworzenie komponentów bez kontrolera albo binding-ów? Czy nie będą one zbyt proste, by tworzenie ich było zasadne?

Nasze doświadczenie pokazuje, że definiowanie komponentów w NG 1.5 jest tak łatwe, że tworzenie ich nawet dla najprostszych elementów interfejsu jest uzasadnione. Wszystkie 3 przypadki (bez binding-ów, bez kontrolera a nawet komponenty z samym szablonem) mają swoje zastosowania.

Przykładowo, częste jest, że komponenty najwyższego poziomu nie mają binding-ów (ponieważ ładują one dane “własnoręcznie” z API):

angular.module('contacts', []).component('contactList', {
    controller: class {
        constructor(contactService) {
            this.contacts = contactService.loadContacts();
        }
    },
    template: `
        <div>
            <person ng-repeat="person in $ctrl.contacts" person="person"></person>
        </div>
    `
});

Również częste są komponenty z binding-ami, ale bez kontrolera – kiedy chcesz wydzielić reużywalny, sparametryzowany fragment interfejsu ale nie wiąże się z nim żadna logika, jedynie proste formatowanie.

Może wydawać się lekką przesadą tworzenie komponentów z samym szablonem, jednak nawet takie przypadki mają swoje miejsce. Przykład z naszej aplikacji:

angular.module('shared', []).component('progressBar', {
    template: `
        <div layout="row" layout-align="center">
          <md-progress-linear flex="90" md-mode="indeterminate" flex></md-progress-linear>
        </div>
    `
});

Mimo, że powyższy komponent nie jest sparametryzowany i nie zawiera żadnej logiki, enkapsulacja konfiguracji Angular Material-owego progress bar-a w komponencie na potrzeby reużywalności i łatwiejszego utrzymania kodu ma sens. A dzięki nowej metodzie module.component(), tworzenie takich prostych komponentów praktycznie nie wiąże się z żadnym kosztem.

To prowadzi nas ku kolejnemu tematowi: jak taka niska bariera tworzenia komponentów, nawet dla najmniejszych, najprostszych fragmentów interfejsu, wpływa na architekturę aplikacji?

Aplikacji Angulara 1.5 jako drzewo komponentów.

Ideą stojąca za aplikacją w pełni opartą na komponentach jest zrobienie z KAŻDEJ oddzielnej części interfejsu komponentu, rekurencyjnie renderującego inne komponenty niższego poziomu. W rezultacie nie ma w takiej aplikacji głównego szablonu, takiego jak w tradycyjnej aplikacji Angulara 1. Zamiast tego mamy pojedyncze, wielkie drzewo komponentów, zaczynające się od pojedynczego komponentu-korzenia (lub, w przypadku aplikacji wykorzystującej routing, kilka takich drzew – po jednym dla każdego widoku).

aplikacja bez routingu

Główny szablon takiej aplikacji wygląda jak poniżej:

<html>
    <head> ... </head>
    <body>
        <our-app-root-component></our-app-root-component>
    </body>
</html>

Jak możesz zauważyć, jest on wyjątkowo prosty w porównaniu ze starym podejściem “cała aplikacja w jednym szablonie”.

aplikacja z routingiem

W przypadku aplikacji wykorzystującej routing, zastępujemy komponent-korzeń “slotem“, w którym zostanie wyrenderowana bieżąca routa, a następnie podajemy w konfiguracji routera osobne drzewo komponentów (z osobnym komponentem-korzeniem) dla każdego widoku. Główny szablon takiej aplijacji wygląda jak poniżej:

<html>
    <head> ... </head>
    <body>
        <div ng-view></div>
    </body>
</html>

a konfiguracja routera wygląda tak:

(przykład wykorzystuje ngRoute, ale ogólna idea jest taka sama dla dowolnego rodzaju routera)

$routeProvider
    .when('/some-route', {
      template: `<some-route-root-component></some-route-root-component>`
    })
    .when('/other-route', {
      template: `<other-route-root-component></other-route-root-component>`
    });

Jak widać, konfiguracja routera również staje się niezwykle prosta, ponieważ routy nie wymagają kotrolerów itd. – wszystko jest schowane w pojedynczym, bezparametrowym komponencie-korzeniu.

struktura drzewa komponentów

Fragment drzewa komponentów bardzo prostej aplikacji mógłby wyglądać jak poniżej:

(kontrolery, bindingi i atrybuty tagów zostały dla uproszczenia przykładu pominięte)

<!-- szablon komponentu <root> -->
<side-nav></side-nav>
<contact-list></contact-list>

<!-- szablon komponentu <contact-list> -->
<search-bar></search-bar>
<add-contact-form></add-contact-form>
<div>
    <person ng-repeat="person in $ctrl.contacts"></person>
</div>

<!-- szablon komponentu <person> -->
<div>
    <div>
        <span>{{ $ctrl.fullName }}</span>
        <span>{{ $ctrl.mail }}</span>
        <address></address>
    </div>
    <div>
        <button>Edit</button>
        <button>Remove</button>
    </div>    
</div>

Przyglądając się powyższym szablonom możesz dostrzec kilka interesujących cech aplikacji opartej na komponentach:

  • Wszystkie komponenty są bardzo proste, zawierają jedynie parę linijek HTML-a. (W prawdziwej, bardziej rozbudowanej aplikacji możesz zazwyczaj znaleźć kilka większych komponentów, z bardziej złożonymi szablonami, jednak nawet w bardzo dużej aplikacji przeważająca większość komponentów jest tak prosta, jak te w powyższym przykładzie).
  • Większość komponentów zawiera prawie wyłącznie custom-owe tagi (tzn. inne komponenty niższego poziomu). Jedynie komponenty najniższego poziomu (takie jak komponent person w naszym przykładzie) zawierają głównie zwykły HTML, jednak nawet takie komponenty-liście mogą enkapsulować fragmenty swoich szablonów w custom-owych komponentach, dla osiągnięcia większej reużywalności (zwróć uwagę jak w naszym przykładzie komponent person korzysta z komponentu address by enkapsulować prosty, ale reużywalny fragment swojego szablonu).

Warto także podkreślić, że mimo prostoty powyższego przykładu, jest on jak najbardziej realistyczny. Jakakolwiek nietrywialna aplikacja będzie z pewnością znacznie bardziej złożona, jednak najlepszym sposobem tworzenia takiej aplikacji jest wystartowanie od prostej stuktury, takiej jak ta powyżej, i stopniowe wprowadzania nowych komponentów w razie potrzeby. Na przykład, jeśli ilość akcji w naszym komponencie person zwiększyłaby się w przyszłości, moglibyśmy wydzielić je do osobnego komponentu actions, lub gdybyśmy chcieli wyświetlać nie tylko mail, ale także telefon i Twitter ID, moglibyśmy wprowadzić komponent contact-data, podobny do komponentu address).

Które elementy Angulara wykorzystujemy w aplikacji opartej na komponentach?

Jak mogłeś czytając ten post zauważyć, architektura oparta na komponentach wpływa na to, z jakich elementów Angulara korzystamy i w jakim stopniu (mogłeś na przykład zauważyć, że szablon aplikacji stał się jedynie pojedynczą linią HTML, zawierającą komponent-korzeń aplikacji). Które więc, konkretnie, elementy Angulara wykorzystujemy w naszej aplikacji?

elementy, których nie używamy (w ogóle)

  • kontrolery
  • wspóldzielony scope (nie używanie współdzielonego scope i tak było dobrą praktyką, nawet bez komponentów)
  • partial-i
  • dyrektyw (istnieją uzasadnione przypadki ich użycia, ponieważ API komponentów NG 1.5 jest ograniczone – np. nie możesz match-ować komponentów na podstawie atrybutu, komponenty nie mają funkcji linkującej itp.; jednakże, w trakcie ponad 3 miesięcy rozwijania naszej aplikacji, nie natknęliśmy się jak na razie na ani jeden przypadek, który zmusiłby nas do wykorzystania dyrektywy)

elementy, których używamy

  • komponenty (co za niespodzianka! ;))
  • serwisy (tutaj bez rewolucji, spełniają one dokładnie tą samą rolę w przypadku aplikacji opartej na komponentach co w przypadku “tradycyjnej” aplikacji)
  • filtry (ale jedynie w niewielkim stopniu; w wielu przypadkach filtr może być zastąpiony metodą kontrolera komponentu, albo niewielkim, niezależnym komponentem)
  • constant i value (do tworzenia prostych klas pomocniczych, enum-ów, obiektów konfiguracyjnych itp.)

Wnioski

Tego typu architektura sprawdza się jak dla nas wyśmienicie. Sprawia, że kod jest łatwy w utrzymaniu (rozbicie interfejsu na wiele małych komponentów sprawia, że większość zmian jest lokalna i dobrze odizolowana), zachęca do reużywania kodu i jest łatwa do testowania (więcej o testowaniu komponentów Angulara 1.5 w jednym z kolejnych postów tej serii). Wparwdzie NG 1.5 pozwala Ci wykorzystywać komponenty jedynie w niewielkim stopniu, jeśli chcesz, jednak zdecydowanie namawiamy byś robił to na pełną skalę, tak jak my.

Co Ty o tym sądzisz?

Czy też korzystałeś już z metody module.component() Angulara 1.5? Jak często jej używasz? Co sądzisz o architekturze, którą ta metoda umożliwia? Podziel się swoim zdaniem w komentarzach poniżej!

Zobacz na blogu

09.09.2022
Marcin Jahn
It’s Not Just HTTP It’s Not Just HTTP

In today’s world of cloud-based solutions, distributed systems, and microservices-based architectures, network communication is a...

23.08.2022
Adam Mrowiec
Konferencja IPC 2022 Berlin Konferencja IPC 2022 Berlin

Pandemia wreszcie się kończy, dlatego w tym roku postanowiliśmy wrócić do naszych wyjazdów na konferencje....