Skocz do treści

Już wkrótce odpalamy zapisy na drugą edycję next13masters.pl. Zapisz się na listę oczekujących!

Komunikacja pomiędzy kontrolerami w AngularJS

Jednym z aspektów sprawiających trudność w AngularJS, który wciąż i wciąż powraca w moich rozmowach z różnymi programistami jest prawidłowa implementacja komunikacji pomiędzy modułami. Dokumentacja i poradniki najczęściej opisują tylko najbardziej podstawowe przykłady porozumiewania się np. pomiędzy komponentem-rodzicem a komponentem-dzieckiem, co w zasadzie nie sprawia problemu. Co jednak w przypadku, gdy komunikacja musi zachodzić w obu kierunkach albo pomiędzy komponentami, które w strukturze aplikacji są od siebie odległe?

Zdjęcie Michał Miszczyszyn
Dobry Kod5 komentarzy

Aby problem był mniej abstrakcyjny, postaram się nakreślić bardziej konkretny przykład. Weźmy koncept aplikacji:

mockup aplikacji

Układ nie ma znaczenia, te same problemy występują niezależnie od położenia elementów na stronie. Aplikacja posiada dwa główne komponenty: Ustawienia oraz wykres. Zmieniając ustawienia użytkownik powoduje zmiany w wyglądzie wykresu. Najprostszym rozwiązaniem jest tutaj wrzucenie wszystkiego do jednego komponentu i cały problem komunikacji znika. Jednak w trakcie dalszego rozwoju aplikacji okazuje się, oba elementy stały się niezwykle skomplikowane, a sam wykres ma się dodatkowo wyświetlać na innej podstronie, ale tym razem już bez konfiguracji. Z tych powodów decydujemy o wyodrębnieniu ustawień oraz samego wykresu do osobnych komponentów, modułów, plików… Według mnie brzmi to jak całkiem typowy scenariusz rozwoju aplikacji. Tylko jak sprawić, aby te dwa elementy komunikowały się pomiędzy sobą bez żadnego problemu?

$rootScope.$broadcast

Odpowiedzią powszechnie postowaną na forach jest sugestia wykorzystania funkcji $rootScope.$broadcast. Co do zasady działa to w ten sposób, że jeden komponent wysyła zdarzenie, a drugi je odbiera i odpowiednio reaguje. Przykładowa implementacja może wyglądać tak:

// settings.controller.js
function dataChanged(newData) {  
    $rootScope.$broadcast('dataChanged', newData);
}

// chart.controller.js
$rootScope.$on('dateChanged', (event, newData) => {
    this.data = newData;
    this.redrawChart();
});

Jest to chyba najprostsze możliwe rozwiązanie. Problemy? W początkowej fazie rozwoju aplikacji - rozwiązanie to bez wątpienia działa. Jednak słabo się skaluje i wraz z rozrostem aplikacji łatwo się pogubić w tych wszystkich zdarzeniach fruwających przez globalny $rootScope. Dodatkowo problemem może być tutaj konflikt nazw zdarzeń – dwa niezależnie komponenty mogą przecież wysyłać zdarzenie o nazwie dataChanged i nic tego nie kontroluje (oprócz dobrej komunikacji programistów w zespole ;) ). Można co prawda rozwiązać ten problem poprzez ustalenie odpowiedniego nazewnictwa zdarzeń (np. prefix:nazwa), jednak nadal pozostaje fakt, że $broadcast nie jest przewidziany do takiego zastosowania. Jak czytamy w Najlepszych praktykach AngularJS:

Only use .$broadcast(), .$emit() and .$on() for atomic events Events that are relevant globally across the entire app (such as a user authenticating or the app closing). If you want events specific to modules, services or widgets you should consider Services, Directive Controllers, or 3rd Party Libs

$broadcast przewidziany jest do informowania o zdarzeniach, które mają jakieś znaczenie w kontekście całej aplikacji, a nie tylko jednego komponentu. Dobrym przykładem jego zastsowania może być zalogowanie się użytkownika, zmiana podstrony albo próba zamknięcia aplikacji. Złym – zmiana wewnątrz komponentu odpowiedzialnego za ustawienia wykresu. Fakt zmiany ustawień rysowania nie jest istotny dla całej aplikacji. Krótko mówiąc: Złą praktyką jest stosowanie $broadcast do komunikacji pomiędzy komponentami.

$rootScope

Nieco innym rozwiązaniem jest zapisywanie ustawień komponentu jako właściwość obiektu $rootScope. Jednak już od samego początku ten pomysł powinien zapalić symboliczną czerwoną lampkę w głowie każdego programisty. $rootScope to w pewnym sensie Angularowy obiekt globalny, a więc de facto sprowadza się to do zapisywania danych jako globalne. Samemu zdarzyło mi się z tego skorzystać niejednokrotnie, szczególnie na początku pracy z Angularem i bez wątpienia możliwość zapisania czegoś w $rootScope ma swoje dobre strony. Jednak w przypadku komunikacji dwóch komponentów to zły pomysł z kilku powodów.

Po pierwsze, cierpi na tym testowalność takiego komponentu. Bez szczegółowego zaglądania w kod źródłowy nie ma możliwości przewidzenia co i dokąd ten komponent będzie próbował zapisać ani skąd chciałby pobierać informacje. Dlatego mockowanie będzie tutaj znacznie trudniejsze niż gdyby dane pochodziły z dependency injection jak to zazwyczaj ma miejsce.

Dodatkowo to rozwiązanie niesie ze sobą wszystkie te same problem, co używanie zmiennych globalnych w ogóle. Utrudnione rozumienie przepływu danych, konflikty nazw pomiędzy komponentami czy przypadkowe zmiany wartości – temu rozwiązaniu mówimy zdecydowane „nie”. Kiedy zapisywanie danych w $rootScope jest akceptowalne? Myślę, że uzasadnione jest użycie go do przechowywania takich informacji jak np. tytuł strony, słowa kluczowe lub innych metadanych dotyczących całej aplikacji.

$scope.$watch

Częstym rozwiązaniem wszelkich problemów, w szczególności na początku fali popularnośc Angulara, było wykorzystywanie funkcji $scope.$watch na dowolnych danych, które mogły ulec zmianie. W opisywanym tutaj przypadku również udałoby się to podejście zastosować. Koncepcyjnie wygląda to tak, że tworzony jest serwis pośredniczący, który wstrzykiwany jest do obu kontrolerów, które chce się skomunikować, a zmiany w tych serwisach są obserwowane:

// data.service.js
this.data = null; // początkowa wartość

// settings.controller.js
function dataChanged(newData) {  
    DataService.data = newData;
}

// chart.controller.js
$scope.$watch(() => DataService.data, newData => {
    this.data = newData;
    this.redrawChart();
});

Wady? Przede wszystkim gorsza wydajność. Każdy dodany $watch zwiększa poziom skomplikowania pętli $digest w Angularze. Jeśli wydajność nie jest w aplikacji kluczowa to jest to akceptowalne rozwiązanie, ale na pewno nie najlepsze. Drugim aspektem, który warto wziąć pod uwagę jest fakt, że znowu cierpi tutaj testowalność aplikacji. O ile tym razem nie ma problemu z mockowaniem (DataService.data można zamockować), jednak przepływ danych nie jest do końca jasny. Spójrzmy na test jednostkowy:

DataServiceMock.data = 999;  
$scope.$digest(); // magia!
expect($scope.data).toBe(999); // skąd to się wzięło??  

Nie jest do końca jasne dlaczego wartość $scope.data się zmieniła. Dodatkowo konieczne jest tutaj ręczne wywołanie $scope.$digest() co sugeruje pewien potencjalny problem – co jeśli w przyszłości serwis zmieni swoją implementację i zmiany w nim będą zachodziły poza pętlą $digest? Ten abstrakcyjny przykład w rzeczywistości zdarza się dość często, np. jeśli zmianę danych w serwisie powoduje dyrektywa w odpowiedzi na zdarzenie DOM albo jeśli dane w serwisie pochodzą z API gdzie nadejście nowych informacji nie powoduje wywołania $digest (np. ze względu na wydajność ma to sens w niektórych wypadkach). Test prawdopodobnie będzie nadal bez problemu przechodził, natomiast aplikacja nie wyświetli nowych danych lub wyświetli je z opóźnieniem, bo w aplikacji nie zostanie automatycznie wywołany $digest.

Dodatkowo środowisko AngularJS raczej zgodnie rezygnuje z korzystania z funkcji $watch czy zmiennej $scope w kontrolerach w ogóle na rzecz controllerAs, .component() i metody $onChanges. Używanie $watch nie jest już ani potrzebne ani polecane.

Stare dobre wzorce projektowe…

Jak więc powinno się rozwiązać komunikację pomiędzy kontrolerami w aplikacji AngularJS? Wystarczy sięgnąć do starych, dobrych wzorców projektowych i skorzystać np. ze wzorca obserwatora.

Obserwator jest wzorcem projektowym, w którym jeden obiekt (subject) przechowuje listę obiektów (observer) zależących od niego i automatycznie informuje je o zmianach swojego stanu.

Wykorzystanie tego wzorca jest proste:

// settings.controller.js
function dataChanged(newData) {  
    DataService.setNewData(newData);
}

// chart.controller.js
DataService.addObserver(newData => {  
    this.data = newData;
    this.redrawChart();
});

Opcjonalnie na końcu funkcji przekazanej do addObserver kontroler może wywołać jeszcze $scope.$applyAsync() – jeśli jest taka potrzeba, bo w przeciwnym wypadku nie ma sensu tego robić i zyskuje na tym wydajność aplikacji. Koniecznie należy też pamiętać o usunięciu obserwatora w momencie gdy niszczony jest dany scope.

Warto tutaj jeszcze wspomnieć o innym wzorcu, który mógłby się tutaj sprawdzić – mianowicie o publish-subscribe. Różnice pomiędzy nim a obserwatorem są w zasadzie niewielkie, a wybór jednego z tych dwóch wzorców zależy od konkretnego problemu do rozwiązania. Więcej na temat różnic pomiędzy nimi można przeczytać w linkowanym już podręczniku wzorców projektowych w JavaScript.

Niezależnie czy implementujemy obserwator czy publish-subscribe, to rozwiązanie jest zdecydowanie najbardziej elastyczne i najbardziej wydajne ze wszystkich wcześniej wymienionych. Istotny jest tutaj również fakt, że zapoznanie się ze wzorcem projektowym obserwatora jest wiedzą uniwersalną, a nie tylko specyficzną dla danego frameworka. Dzięki temu jest to wiedza użyteczna niezależnie od środowiska, w którym się pracuje i przez to bardzo cenna. Przykładową implementację tego wzorca wraz z prostymi testami jednostkowymi udostępniam poniżej. Kod jest również na gist.github.com/mmiszy/cea958c4c644b3fffe8537e21a419d4d.

Zobacz Codepen.

👉  Znalazłeś/aś błąd?  👈Edytuj ten wpis na GitHubie!

Autor