Wróć do strony głównej
Angular

Angular – nieco inne podejście do personalizowania szablonu komponentów

Personalizowanie komponentów to temat znany każdemu kto choć raz użył zewnętrznej biblioteki UI takiej jak primeng. Możliwość zdefiniowania całości lub fragmentu szablonu bez modyfikowania kodu oryginalnego komponentu jest przydatna dla programisty i zapewnia lepszą reużywalność. Skoro więc zalet jest wiele dlaczego nie zastosować takiego podejścia we własnych aplikacjach lub bibliotekach? 🙂

Istnieje kilka różnych implementacji takiej funkcjonalności, z którymi wielokrotnie eksperymentowałem w trakcie codziennej pracy. W tym artykule dokonam krótkiego przeglądu popularnych rozwiązań, po którym przedstawię implementację tego, które w mojej ocenie jest najlepsze. Inspiracją były źródła z githuba biblioteki primeng, więc metoda nie jest autorska i bazuje na pracy innych programistów. Spróbuję również uzasadnić swój wybór. A więc zaczynamy.

Case study

Wyobraźmy sobie, że potrzebujemy widgetu z informacjami o użytkowniku. Powinien zawierać kilka typowych danych – a więc imię, nazwisko, e-mail oraz avatar. Szablon komponentu mógłby wyglądać na przykład tak:

Wyobraźmy sobie też, że potrzebujemy kilku różnych sposobów prezentacji naszego kafelka. Rodzaj wyświetlanych danych nie zmieni się, ale układ szablonu już tak i samo użycie styli CSS nie będzie tu wystarczające. Załóżmy też, że chcemy mieć możliwość osobnego zdefiniowania szablonu dla avatara i dla pozostałych danych użytkownika.

Zatem opisaliśmy problem – przyjrzyjmy się możliwym rozwiązaniom.

Krok 1: flagi

Prawdopodobnie najłatwiej podejść do implementacji używając flag. Wyobraźmy sobie, że komponent widgetu zawiera jedną lub więcej właściwości z dekoratorem @Input(), służących do sterowania wyglądem szablonu. I tyle?

Implementacja jest tu bardzo prosta. Podejście posiada jednak dużą wadę – komponent musi “znać” wszystkie możliwe warianty użycia, które muszą być z góry określone i zawierać się wewnątrz szablonu. Wyklucza to niestety tworzenie komponentów ogólnego przeznaczenia 🙁 

A my szukamy dalej.

Krok 2: content projection

Angular posiada dyrektywę ng-content, służącą do czegoś co w języku angielskim ładnie nazywa się “Content Projection”. Szczegółowy opis znajdziemy w dokumentacji, zaś mówiąc w skrócie umożliwia nam to przekazanie fragmentu szablonu do wnętrza komponentu bezpośrednio pomiędzy jego tagami. Możemy użyć wielu takich dyrektyw oraz określić dla każdej z osobna selektor elementów jakie będą “wyłapywane”.

Spróbujmy więc zaimplementować nasz komponent stosując content projection:

Wówczas jego użycie wyglądać będzie mniej więcej tak:

Zaletą jest ponownie prostota implementacji i użycia. Wady? Niestety jest ich parę. Trudno zdefiniować domyślny szablon, a przekazywanie go przy każdym użyciu może prowadzić do dużych ilości powtórzonego kodu. Co więcej, komponent który tworzymy nie jest w stanie w żaden sposób wpłynąć na dane wyświetlane wewnątrz. Jest jedynie opakowaniem dla tego co przekazujemy. W konsekwencji w miejscu użycia komponentu musimy mieć dostęp do wszystkich używanych w nim danych. W omawianym przypadku nie jest to jeszcze tak dotkliwe, ale wyobraźmy sobie definiowanie szablonów różnych elementów tabeli – wierszy, nagłówków i stopki. Używając komponentu nie chcemy zagłębiać się w dane czy tym bardziej przetwarzać ich w jakikolwiek sposób – ta funkcjonalność powinna być częścią jego wewnętrznej implementacji. Oto więc krok bliżej celu, lecz wciąż jeszcze nie “to”.

Krok 3: ng-template

Dyrektywa ng-template jak sama nazwa wskazuje opakowuje szablon, który zostanie wstawiony w jedno lub więcej miejsc podczas renderowania finalnego drzewa dokumentu. Nie jest on jako taki prezentowany w miejscu wystąpienia – do tego potrzebujemy dodatkowych elementów, takich jak dyrektywa ngTemplateOutlet. Szczegóły bez problemu znaleźć można w dokumentacji frameworka.

Nas interesuje tu jedna ciekawa właściwość – konfigurowalny kontekst, czyli określenie tego co “widzi” szablon w momencie wstawiania do drzewa dokumentu. W konsekwencji mogę definiować szablon dla innego komponentu, odwołujący się do zmiennych zaszytych w jego wnętrzu i nieosiągalnych poza nim.

Brzmi obiecująco? Wróćmy więc do pomysłu z kroku 1 i spróbujmy użyć właściwości z dekoratorem @Input(), która zamiast flagi przyjmie referencję do zdefiniowanego szablonu:

Nie będę tu rozwijał tematu dyrektywy ngTemplateOutlet czy użytej właściwości kontekstu $implicit, dokumentacja Angulara zawiera wszystkie potrzebne informacje.

Naszego komponentu możemy używać z domyślnym szablonem:

Możemy też skorzystać z zalet personalizacji i samemu zdefiniować jeden lub oba szablony:

To właśnie takie rozwiązanie najczęściej znajdziemy na blogach i w kursach poświęconych Angularowi. Rozwiązanie zasadniczo dobre – umożliwia określenie domyślnego szablonu, daje dużą elastyczność w personalizowaniu i umożliwia zamknięcie logiki przetwarzania danych wewnątrz komponentu. 

Skoro zatem jest dobrze to czemu jest źle?

Chodzi o samo użycie. Wyobraźmy sobie widok agregujący kilka(naście) tak napisanych komponentów. Jeżeli co któryś z nich używać będzie personalizacji otrzymamy mnóstwo dodatkowych elementów ng-template z różnymi identyfikatorami, które dodatkowo nie mogą się dublować. Trzeba również pamiętać o przekazaniu referencji do wszystkich szablonów.

W idealnym świecie chciałbym, by wszystko co związane z komponentem zawierało się wewnątrz jego tagów i nie wymagało żadnego dodatkowego działania. Komponent powinien sam wykrywać czy i jakie szablony zostały zdefiniowane i nie wymagać uzupełniania dodatkowych parametrów. Chciałbym składni z kroku drugiego ale ze wszystkimi zaletami opisanymi w kroku bieżącym.

Krok 4: “samo słodkie”, czyli połączone zalety poprzednich rozwiązań

Aby dokonać fuzji poprzednich kroków zastanówmy się jakie problemy powinien rozwiązać kod który implementujemy. Po pierwsze chcemy wyłapać dyrektywy ng-template przekazane do naszego komponentu bezpośrednio pomiędzy jego tagami. Po drugie musimy umieć odróżnić te elementy, będą więc potrzebowały jakiegoś rodzaju identyfikatorów. Co więcej – potrzebujemy sposobu na wyrenderowanie finalnego szablonu gdzie połączymy dane przetworzone wewnątrz komponentu z szablonem lub szablonami przekazanymi z zewnątrz.

Na początek zdefiniujmy własną dyrektywę która ułatwi nam odczyt identyfikatora szablonu:

Następnie użyjemy dekoratora @ContentChildren(), dzięki któremu otrzymamy listę wszystkich elementów drzewa DOM zagnieżdżonych wewnątrz naszego komponentu. Iterując po tej liście będziemy w stanie kolejno sprawdzać identyfikatory przekazanych szablonów – dzięki temu możemy właściwie zinterpretować ich przeznaczenie:

W listingu powyżej użyliśmy metody ngAfterContentInit. Jak wiemy, komponenty w Angularze mają swój cykl życia – począwszy od stworzenia a skończywszy na zniszczeniu – co jest szczegółowo opisane (a jakże ;-)) w dokumentacji. Dzięki tzw. hook methods – z których jedna użyta została w kodzie powyżej – jesteśmy w stanie wpiąć się w dowolny z etapów tego cyklu, co da nam kontrolę nad tym kiedy nasz kod zostanie wykonany. 

Tu i teraz zależy nam, aby iterowanie po tablicy this.templates nie zostało wywołane zanim zostanie ona zainicjowana. Właściwość ta opatrzona została dekoratorem ContentChildren, skutkiem czego jej wartość przekazana zostanie z zewnątrz komponentu (będą to elementy przekazane pomiędzy jego tagami). Dlatego też żaden etap cyklu życia komponentu poprzedzający AfterContentInit nie będzie właściwy i najpewniej zakończy się błędem przy próbie jego wykonania.

Wróćmy jednak do implementacji. Ostatni krok to użycie dyrektywy ngTemplateOutlet, dzięki której połączymy dane przetworzone w komponencie z przekazanymi elementami ng-template i wygenerujemy finalny szablon naszego komponentu. Kompletny kod wyglądać będzie następująco:

Przykładowe użycie w domyślnej formie:

Przykładowe użycie z personalizacją szablonu:

Oczywiście nic nie stoi na przeszkodzie aby nasz komponent przyjmował tylko jeden element ng-template. Wówczas identyfikatory nie będą potrzebne a sam kod byłby nieco prostszy.

Efekt jaki osiągnęliśmy zawiera wszystkie zalety poprzedniego kroku, będąc jednocześnie wygodniejszym w użyciu. Zatem – mamy to! 🙂

Na deser kod źródłowy do kompletnego rozwiązania: https://stackblitz.com/edit/personalize-your-components

O autorze

Łukasz Joorewicz

Z Angularem od lat, choć w pisaniu artykułów dopiero debiutuję. Po godzinach bębniarz w rockowym zespole i coraz częściej zapalony rowerzysta. Czasami nie zaszkodzi wstać od laptopa 😉

Zapisz się do naszego newslettera. Bądź na bieżąco z najnowszymi trendami, poradami, meetupami i stań się częścią społeczności Angulara w Polsce. Rynek pracy docenia członków społeczności.

3 komentarzy

  1. Szymon

    Super artykuł! Podoba mi się rozwiązanie 4. Możliwe że jutro uda mi się je zastosować :). A czy masz pomysł jak można by było przenieść zdefiniowane 'ng-template’ do zewnętrznych plików i ładować je w tych miejscach dynamicznie? Wtedy można by je trzymać w jakimś współdzielonym katalogu i udostępniać wszystkim komponentom.

Dodaj komentarz

Twój adres e-mail nie zostanie opublikowany. Wymagane pola są oznaczone *