Struktura aplikacji AngularJS (część 1 ‑ trochę historii)

W trakcie kilku ostatnich lat pracy z AngularJS obserwuję całkowitą zmianę podejścia do architektury aplikacji internetowych. Była to powolna ewolucja, po drodze mocno inspirowana dobrymi wzorcami projektowymi, szkicem Web Components oraz biblioteką React. Sublimacją tego wysiłku jest powstanie angular2, ale wszystkie wypracowane praktyki wdrożono również do AngularJS 1. Od bałaganu, porozrzucanych zależnych od siebie kontrolerów i pomieszanych scope’ów doszliśmy w końcu do czegoś co może wydawać się dziś oczywiste: Czystych, prostych, krótkich komponentów, odpowiedzialnych tylko za jedną konkretną funkcję aplikacji.

Trochę historii

Kiedy zacząłem pracować nad pierwszym komercyjnym projektem z użyciem AngularJS, wyszła akurat wersja 1.0.5 tego frameworka. Grunt był w wersji release candidate, a Karma nazywała się jeszcze Testacular. To było na początku roku 2013, ale z perspektywy rozwoju webdevelopmentu to wieki temu. W jaki sposób wyglądała początkowo aplikacja, którą tworzyliśmy? Nie było wtedy jeszcze chyba żadnego podręcznika Angulara, który by opisywał tak podstawowe rzeczy jak struktura folderów czy architektura aplikacji (pierwszy taki wpis na blogu znaleźliśmy dopiero kilka tygodni później). Użyliśmy modnego wtedy narzędzia Yeoman i zaczęliśmy projekt przy pomocy yo angular. Otrzymaliśmy całkowicie nieskalowalną, ale używaną chyba przez wszystkich, strukturę katalogów podobną do tej poniżej:

---
aplikacja  
    app.js
    index.html
    controllers/
        app.controller.js
    directives/
        myElement.directive.js
    services/
        appState.service.js
    tests/
        controllers/
            app.controller.test.js
        directives/
            myElement.directive.test.js
        services/
            appState.service.test.js

Ta prosta struktura nie sprawdza się nawet przy małych aplikacjach jeśli tylko mamy więcej niż kilka kontrolerów. Trudno zarządzać tak ułożonymi plikami, a wdrożenie nowych funkcji sprawia wiele kłopotów. Ten układ folderów pokazywał również inny, poważniejszy problem: W zasadzie nikt nie wiedział jak zaprojektować architekturę aplikacji opartej o AngularJS. Na fali mody wszyscy zdawali się zapomnieć o podziale odpowiedzialności i MVC na rzecz MVW1 oraz o dobrych praktykach i wzorcach na rzecz szybkiego tworzenia imponujących aplikacji internetowych. Polecane było używanie ng‑include do zagnieżdżania podwidoków oraz ng‑controller do dodawania do nich logiki biznesowej, a komunikacja pomiędzy kontrolerami była często rozwiązywana np. poprzez odwoływanie się do $parent – czyli bezpośredniego rodzica zależnego od ułożenia html. W skrócie oznaczało to, że widok musiał być świadomy dokładnie gdzie w hierarchi się znajduje i praktycznie niemożliwe było jego ponowne wykorzystanie w innym miejscu. Kontrolery były pozornie niezwiązane z widokiem, ale w rzeczywistości nie dało się użyć jednego bez drugiego.

Dawna architektura aplikacji w AngularJS

U nas kontrolerów na początek miało być kilkadziesiąt, do tego co najmniej tuzin różnych własnych dyrektyw i przynajmniej drugie tyle serwisów, dlatego niedługo później zdecydowaliśmy się na generalne porządki… Autentyczny fragment kodu z tamtego okresu zamieszczam poniżej. Trudno się w tym połapać, ale gdy już się dokładnie przyjrzeć to widać, że np. ConferenceListCtrl niepotrzebnie używany jest dwukrotnie w jednym widoku, a przepływ danych jest całkowicie niejasny. Patrząc na ten fragment HTML nie wiem nawet teraz gdzie miałbym zacząć, aby móc np. details.html wykorzystać ponownie w innym miejscu aplikacji.

<!-- history.html -->  
<ng-include src="'list.html'"></ng-include>  
<ng-include src="'conference.html'"></ng-include>

<!-- list.html -->  
<div ng-show="showList" ng-controller="ConferenceListCtrl">  
    …
</div>

<!-- conference.html -->  
<div ng-controller="ConferenceListCtrl">  
    <ng-include src="'details.html'"></ng-include>
</div>

<!-- details.html -->  
<div ng-controller="ConferenceDetailsCtrl">  
    …
</div>  

Ewolucja

Początkowo autorzy AngularJS nie rekomendowali żadnej struktury projektu. Tłumaczyli to dowolnością i elastycznością frameworka. Dzięki temu na wielu blogach pojawiały się kolejne propozycje struktur i nazewnictwa plików tworzone przez użytkowników. W lutym 2014 na oficjalnym blogu AngularJS ukazał się wpis mówiący w końcu o najlepszych praktykach i strukturze. Twórcy Angulara opublikowali dokument, który opisuje w jaki sposób ich zdaniem powinna wyglądać aplikacja w Angularze.

Taki podział folderów od razu sugeruje też inne podejście do planowania aplikacji – podejście komponentowe. Dodatkową pomocą jest, dostępna od wersji 1.5, nowa funkcja angular.component(…), która umożliwia łatwiejsze tworzenie komponentów. Widać tutaj zresztą silną inspirację biblioteką React oraz ideą Web Componentów, a sam koncept jest moim zdaniem świetny i przede wszystkim zgodny z dobrze znanymi wzorcami projektowymi.

W skrócie komponent to zenkapsulowany samowystarczalny element aplikacji, odpowiedzialny za dokładnie jedną funkcję. Zawiera widok i logikę biznesową.

Rezygnujemy z ng-controllerng-include na rzecz zagnieżdżonych komponentów. Jest to rozwiązanie lepsze bo tworzone są małe fragmenty kodu, które łatwo jest testować, a przepływ danych pomiędzy nimi jest jasny i przejrzysty. Do tego automatycznie każdy taki komponent może być użyty w dowolnym miejscu gdyż nie zależy od otaczających go elementów – tylko od swoich atrybutów. Przykładowa implementacja jednego komponentu <app‑conference‑details> znajduje się nieco dalej. Więcej można przeczytaj na blogu Todd Motto, serdecznie polecam cały jego wpis.

Architektura komponentowa w AngularJS

Poprzedni kod HTML mógłby zostać przepisany na przykład na coś podobnego do tego:

<!-- app-history-component -->  
<app-conference-list conferences=“$ctrl.conferences”>  
</app-conference-list>  
<app-conference-details conference=“$ctrl.selectedConference”>  
</app-conference-details>  

Teraz dokładnie widoczne jest skąd pochodzą dane, a szczegóły implementacyjne komponentów są właściwie nieistotne. Komponent <app‑conference‑details> można by bez problemu ponownie wykorzystać w innym miejscu na stronie, a jedynym warunkiem jest podanie mu conference jako atrybutu. Kod JS tego jednego komponentu może wyglądać tak:

angular  
    .module('app')
    .component('appConferenceDetails', {
        controller: 'ConferenceDetails',
        bindings: {
            'conference': '<'
        }
    });

Warto zwrócić również uwagę na specjalny rodzaj bindingu widoczny tutaj: <. Jest to binding jednokierunkowy – oznacza to, że zmiany w conference będą przekazywane do tego komponentu, ale jeśli conference zostanie nadpisane wewnątrz niego to rodzic nie zostanie o tym poinformowany. Zmiany w atrybucie płyną w dół, ale nie w górę – czyli dokładnie coś czego tutaj potrzebujemy.

Na zakończenie dodam, że opisana wyżej hipotetyczna refaktoryzacja na komponenty jednak nigdy nie doszła do skutku, gdyż rzadko kiedy jest czas aby kompletnie przebudować coś co działa w zasadzie prawidłowo, szczególnie że projekt dzisiaj jest już legacy. Zdobytą wiedzę na temat komponentów na szczęście stosuję z powodzeniem w innych projektach i chętnie tym doświadczeniem się podzielę. Więcej szczegółów na temat komponentów w AngularJS napiszę w kolejnej części.

Angular2

Nawiasem mówiąc, w ten sposób naturalną drogą rozwoju dochodzimy do koncepcji, które są w zasadzie fundamentami budowy aplikacji w Angular2, albo prawdopodobnie jakimkolwiek nowoczesnym frameworku Single Page Applications. Niezależnie komponenty i dane przekazywane od rodzica do dziecka. Więcej na temat tworzenia aplikacji w samym Angularze2 napiszę już wkrótce, gdyż właśnie teraz staram się uporządkować wiedzę na ten temat.

  1. Model-View-Whatever