Komponenty dynamiczne ciąg dalszy: Angular 9 i drzewo dynamicznych komponentów
Ponad cztery lata temu opublikowaliśmy artykuł o komponentach dynamicznych w Angularze 2. Dziś jest już wersja Angulara 9 i chcielibyśmy naszą wiedzę „zaktualizować”. Komponenty dynamiczne – czym one są? Jakie opcje zapewnia Angular w przypadku tworzenia komponentów dynamicznie? Co to jest i jak zbudować drzewo komponentów dynamicznych? W tym artykule poszukamy odpowiedzi na te pytania.
Komponenty dynamiczne
Jest już mnóstwo świetnych artykułów na temat komponentów dynamicznych. Krótko mówiąc, komponent dynamiczny to komponent, którego selektor nie jest użyty w żadnym template innego komponentu. Zamiast tego, jest ładowany imperatywnie poprzez swoją klasę i musimy podjąć dodatkowy wysiłek, który dla zwykłych komponentów wykonywany jest za kulisami przez sam framework.
O jakim wysiłku mowa?
Załóżmy, że już napisaliśmy nasz komponent. Teraz jednym ze sposobów załadowania go dynamicznie będzie:
1. Wstrzyknięcie ComponentFactoryResolver do komponentu, który będzie dany komponent dynamiczny ładował (nazwijmy go „loaderem”)
2. Stare podejście z Angulara 8 (zostanie “może” usunięte wraz z Angularem 11):
Dodanie komponentu dynamicznego do tablic declarations i entryComponents modułu, który go używa. Następnie, stworzenie fabryki komponentu dynamicznego, przekazując typ komponentu do metody resolComponentFactory instancji ComponentFactoryResolver.
Nowe podejście z Angulara 9:
Właściwie nie musisz nigdzie deklarować komponentu, jego kod nie znajdzie się w inicjalnym bundle’u, ale przez brak modułu nie będziemy mogli skorzystać z przydatnych rzeczy np. z CommonModule (istnieje jednak sprytne obejście, więcej na ten temat w dalszej części).Aby uzyskać fabrykę komponentu, użyj składni dynamicznego importu określającej ścieżkę do modułu / pliku typescriptowego, w którym eksportowany jest komponent dynamiczny. Ten import zwróci Promise z modułem, w którym pod kluczem nazwy komponentu jest jego świeżo załadowana klasa. Następnie możemy przekazać go do metody resolveComponentFactory.
1 2 3 4 |
import ("src/app/my-dynamic/my-dynamic.component"). ({MyDynamicComponent} => { const factory = this.componentFactoryResolver(MyDynamicComponent); }). |
3. Kolejną rzeczą będzie znalezienie miejsca w widoku dla naszego dynamicznego komponentu. Do tego potrzebujemy referencji do ViewContainera. Taką referencję możemy uzyskać najpierw dodając do template’u naszego „loadera”. A później, odczytując wskazany “template reference variable” za pomocą ViewChild’a z określeniem, że chcemy ją “czytać” jako ViewContainerRef:
1 2 |
@ViewChild ("viewContainer", {read: ViewContainerRef, static: false}) viewContainerRef |
4. Wreszcie, gdy użyjemy metody create na ViewContainerze przekazując naszą fabrykę komponentu, nasz komponent dynamiczny zostanie stworzony. Metoda create zwraca również referencję do naszego nowo utworzonego komponentu, którą warto przechowywać jako zmienną – poprzez nią mamy dostęp do propsów komponentu lub możemy jej użyć do jego zniszczenia.
Cóż… wszystkie te kroki są dość uciążliwe. Czy Angular zapewnia prostszy, lepszy sposób?
Prostszy, tak, ale też bardziej ograniczony, co czyni go tylko sytuacyjnie lepszym
NgComponentOutlet
Jest to dyrektywa strukturalna, która ukrywa złożoność wyżej wymienionych kroków. Jest używana w następujący sposób:
1 2 3 4 |
<ng-container *ngComponentOutlet = "componentTypeExpression; injector: injectorExpression; content: contentNodesExpression;" </ng-container> |
Określamy, który komponent ma zostać załadowany według jego klasy – w Angular 9 wciąż musimy wykonać dynamiczny import, aby załadować chunk z tą klasą. Dodatkowo możemy przekazać niestandardowy injector (domyślnie jest brany z viewContainer’a na którym jest nałożona dyrektywa), a także transkludować jakiś kontent do ng-contentu komponentu dynamicznego.
Więc… jakie są wady tego rozwiązania?
Głównie dwie:
- Nie mamy dostępu do referencji komponentu.
- Nie mamy możliwości bindowania inputów / outputów.
Aby przekazać jakieś dane, musielibyśmy wstrzyknąć serwis do komponentu dynamicznego, albo, bardziej bezpośrednio, przekazać injector z injectionToken’em zawierającym te dane
Drzewo dynamicznych komponentów
Aby pokazać bardziej złożony przypadek użycia komponentów dynamicznych,
zastanówmy się nad pewnym problemem:
Wyobraź sobie, że nasza aplikacja jest builderem, w którym użytkownik tworzy jakąś strukturę ze wstępnie zdefiniowanych elementów. Niektóre z tych elementów mogą zawierać kolejne elementy, a niektóre z kolejnych kolejne (itd.). Ponadto, tę strukturę można zapisać po stronie serwera, a następnie pobierać z API i wyświetlać z powrotem użytkownikowi.
Przejdźmy przez rozwiązanie trochę uproszczonej wersji tego problemu:
- mamy „zdefiniowane elementy”, jakąś pulę dynamicznych komponentów
- istnieje obiekt przechowujący konfigurację dynamicznego komponentu, konfiguracja przechowuje typ komponentu dynamicznego i ew. konfiguracje zagnieżdżonych komponentów (taką postać potencjalnie miałby JSON ładowany z backendu)
- na podstawie tego obiektu musimy załadować i wyświetlić drzewo
Angularowych dynamicznych komponentów
Najpierw stwórzmy interface na ten obiekt konfiguracji:
1 2 3 4 |
export interface DynamicComponentConfig { content: DynamicComponentConfig[]; type: DynamicComponentType; } |
To rekurencyjna struktura danych, która zawiera dwa klucze:
- content, tablica konfiguracji, jeśli nasz komponent dynamiczny ma dzieci
- type, który jest enumem wszystkich możliwych stringów opisujących typ komponentu
1 2 3 4 5 6 |
export enum DynamicComponentType { cmp1 = 'cmp1', cmp2 = 'cmp2', cmp3 = 'cmp3', cmp4 = 'cmp4' } |
Mamy cztery typy komponentów dynamicznych, które później stworzymy: cmp1, cmp2 … itd.
Teraz stworzymy sobie utila z metodą zwracającą zamockowany obiekt powyższego interfejsu
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 |
export class MockedDataUtil { static getDynamicComponentConfig(): DynamicComponentConfig { return { type: DynamicComponentType.cmp1, content: [{ type: DynamicComponentType.cmp2, content: [{ type: DynamicComponentType.cmp4, content: [] }] }, { type: DynamicComponentType.cmp2, content: [{ type: DynamicComponentType.cmp3, content: [] }, { type: DynamicComponentType.cmp3, content: [] }, { type: DynamicComponentType.cmp1, content: [{ type: DynamicComponentType.cmp2, content: [{ type: DynamicComponentType.cmp4, content: [] }, { type: DynamicComponentType.cmp1, content: [] }] }] }] } ] }; } } |
Z tego obiektu będziemy musieli stworzyć drzewo dynamicznych komponentów i wyświetlić je użytkownikowi. Widzimy na przykład, że root component jest typu „cmp1” i ma dwoje dzieci: oba typu „cmp2”.
Boilerplate
Wygenerujmy boilerplate dla komponentów od cmp1 do cmp4. W tym samym pliku co komponent, dodajmy jeszcze @NgModule, w którym zadeklarujemy komponent i zaimportujemy moduły, komponenty, dyrektywy itp., których ten komponent używa. Moduł nie musi (i nie powinien) być eksportowany. Zostanie on odnaleziony i skompilowany, bo istnieje dynamiczny import wskazujący na plik, w którym ten moduł (i komponent) jest zawarty. Z każdego pliku z dynamicznym komponentem i modułem powstanie nam osobny chunk, ważnym jest dla nas to, że nie został oznaczony jako initial:
Co więcej, Angular sam zajmie się dociągnięciem zależności, nie dublując importów istniejących w już załadowanych chunkach. W praktyce oznacza to, że powinniśmy mieć w naszej aplikacji nawet tysiąc dynamicznych komponentów, ale użytkownik załaduje tylko te, które są obecnie wykorzystywane na widoku.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
@Component({ selector: 'app-cmp1', templateUrl: './cmp1.component.html', styleUrls: ['./cmp1.component.scss'], }) export class Cmp1Component extends DynamicComponentBaseComponent { } @NgModule({ declarations: [Cmp1Component], imports: [SharedModule] }) class Cmp1Module { } |
SharedModule
W SharedModule przetrzymujemy wszystkie wspólne rzeczy używane przez nasze dynamiczne komponenty:
1 2 3 4 5 6 7 8 |
@NgModule({ exports: [ CommonModule ], imports: [CommonModule] }) export class SharedModule { } |
Następnie tworzymy obiekt, który będzie mapował stringi z konfiguracji na funkcje zwracające odpowiadające ścieżki do komponentów:
1 2 3 4 5 6 |
const dynamicComponentImportsMap = { [DynamicComponentType.cmp1]: () => import('src/app/dynamic-components/cmp1/cmp1.component'), [DynamicComponentType.cmp2]: () => import('src/app/dynamic-components/cmp2/cmp2.component'), [DynamicComponentType.cmp3]: () => import('src/app/dynamic-components/cmp3/cmp3.component'), [DynamicComponentType.cmp4]: () => import('src/app/dynamic-components/cmp4/cmp4.component') } |
Ładowanie dynamicznych komponentów
Po zakończeniu naszych deklaracji przechodzimy do następnej części – ładowania naszych dynamicznych komponentów.
Użyjmy do tego dyrektywy strukturalnej, podobnej do ngComponentOutlet, ale bardziej dostosowanej do naszych potrzeb. Powinna być zadeklarowana i wyeksportowana w SharedModule:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
@Directive({ selector: '[appDynamicComponentLoader]' }) export class DynamicComponentLoaderDirective { constructor(private componentFactoryResolver: ComponentFactoryResolver, private viewContainerRef: ViewContainerRef) { } @Input() set appDynamicComponentLoader(dynamicComponentConfig: DynamicComponentConfig) { this.loadComponent(dynamicComponentConfig); } private loadComponent(dynamicComponentConfig: DynamicComponentConfig) { this.resolveCmpClass(dynamicImportsMap[dynamicComponentConfig.type]).then(cmpClass => { const cmpFactory = this.componentFactoryResolver.resolveComponentFactory(cmpClass); const cmpRef = this.viewContainerRef.createComponent(cmpFactory); (cmpRef.instance as DynamicComponentBaseComponent).dynamicComponentConfigs = dynamicComponentConfig.content; }); } private resolveCmpClass(importFn: () => any): Promise<Type> { return importFn().then(module => { const cmpClass = Object.values(module).find(val => val.hasOwnProperty('ɵcmp')); if (!cmpClass) { throw new Error('No exported component found!'); } return cmpClass; }); } } |
Zasadniczo jest to procedura podobna do opisanej na początku tego tekstu, z tym że jest sparametryzowana za pomocą inputu z konfiguracją. W oparciu o type z konfiguracji uruchamiamy odpowiednią funkcję z importem w dynamicComponentImportsMap. Następnie, aby znaleźć klasę komponentu, szukamy pierwszej wartości importowanego modułu, która ma property „ɵcmp” (w Angular 9, do tej postaci jest transformowany dekorator komponentu). Jedyne co pozostało niewyjaśnione to DynamicComponentBase. Jest to klasa abstrakcyjna, która będzie nam służyć jako wspólne API naszych dynamicznych komponentów. Na razie zaimplementujmy tam jeden input na tablicę konfiguracji.
1 2 3 4 |
@Component({template: ''}) export abstract class DynamicComponentBaseComponent { @Input() dynamicComponentConfigs: DynamicComponentConfig[]; } |
Nasze komponenty będą po niej dziedziczyć, aby mieć dostęp do konfiguracji:
1 2 |
export class Cmp1Component extends DynamicComponentBase { } |
Pozostaje jeszcze ostatni krok, aby faktycznie użyć naszej dyrektywy DynamicComponentLoader. W głównym komponencie musimy dodać:
app.component.ts
1 2 3 |
export class AppComponent { dynamicComponentConfig = MockedDataUtil.getDynamicComponentConfig(); } |
app.component.html
1 |
<ng-template [appDynamicComponentLoader]="dynamicComponentConfig"></ng-template> |
I w naszych dynamicznych komponentach, w celu rekursywnego ładowania potencjalnych dzieci:
cmp1.component.html
1 2 3 4 5 6 7 |
<h1 class="dynamic-component__label"> C1 </h1> <ng-container *ngFor="let dynamicComponentConfig of dynamicComponentConfigs"> <ng-template [appDynamicComponentLoader]="dynamicComponentConfig"> </ng-template> </ng-container> |
Struktura ostateczna
Po dodaniu stylów, nasza struktura prezentuje się tak:
Cały kod dostępny tutaj.
W następnym artykule zajmiemy się bardziej zaawansowanymi tematami – zarządzaniem inputami / outputami, skalowalnością i naprawimy pewien problem UX-owy / optymalizacyjny obecnego rozwiązań.
Pingback: ANGULAR 2 Komponenty dynamiczne cz I. - Angular.love
Bardzo fajny artykuł. Przykład tego, że można robić w angularze jakieś buildery na podstawie dynamicznych komponentów był super. Nie mogę się doczekać 3 części 🙂