Teleportację, w kontekście aplikacji Angularowej, można określić jako zmianę umiejscowienia fragmentu widoku (w szczególności przeniesienie go do innego komponentu), przy jednoczesnym zachowaniu wiązań z danymi i eventami oryginalnego komponentu. Jest to w pewien sposób analogiczne do mechanizmu content projection, jednak bez konieczności zachowania relacji rodzic-dziecko (fragment może być przeteleportowany np. z dziecka do rodzica).
Możliwość zmiany pozycji elementów w drzewie DOM, przy zachowaniu oryginalnych wiązań widoku z komponentem, jest naturalną cechą Angulara, wynikającą z istnienia (również w runtime) logicznego drzewa komponentów i ich powiązań z szablonami widoków w zupełnym oderwaniu od drzewa DOM (zmiany w drzewie DOM nie wpływają na zmiany w logicznym drzewie komponentów).
src: https://i.redd.it/kko442mrgim71.jpg
Kiedy przydaje się teleportacja?
Jak wspomniano wyżej – gdy samo content projection nie wystarczy i zmuszeni jesteśmy do miejscowego odwrócenia zależności w drzewie komponentów (fragment widoku przodka zależeć będzie od jego potomka). Przykładem może być widget osadzony w nagłówku naszej strony, zmieniający się w zależności od podstrony, na której znajduje się użytkownik.
Gotowe rozwiązania
@angular/cdk/portal
https://material.angular.io/cdk/portal/overview
Portal będący częścią The Component Dev Kit (w skrócie cdk) to niskopoziomowy mechanizm wykorzystany do stworzenia innych elementów biblioteki angular material (m.in. overlays, z którego korzystają np. dialogi). Składa się z 2 elementów:
- portal outlet, czyli “slot” na fragment widoku, który chcemy dynamicznie wyrenderować,
- fragment widoku, który chcemy dynamicznie wyrenderować (w postaci referencji na element DOM, templateRef lub na komponent).
Aby teleportacja zadziała się na odległość sami musimy stworzyć mechanizm, który przekaże nam fragment widoku z odpowiedniego miejsca do portal outletu. Łatwo osiągnąć to tworząc serwis (singleton), który będzie jednocześnie dokonywać rejestracji (zapamiętania) outletu oraz rozpoczynania i kończenia teleportacji.
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 |
import {ApplicationRef, ComponentFactoryResolver, Injectable, Injector} from '@angular/core'; import {DomPortalOutlet, Portal} from '@angular/cdk/portal'; @Injectable({ providedIn: 'root' }) export class AngularCdkTeleportService { private portalOutlet: DomPortalOutlet | null = null; constructor(private cfr: ComponentFactoryResolver, private appRef: ApplicationRef, private injector: Injector) { } registerPortalOutlet(outletElement: HTMLElement): void { this.portalOutlet = new DomPortalOutlet( outletElement, this.cfr, this.appRef, this.injector, document ) } unregisterPortalOutlet(): void { this.portalOutlet?.dispose(); this.portalOutlet = null; } teleport(portal: Portal<any>): void { this.portalOutlet?.attach(portal); } finishTeleportation(): void { this.portalOutlet?.detach(); } } |
Metoda registerPortalOutlet przyjmuje w naszym przypadku referencję na element DOM i tworzy w nim nasz “slot” na fragment widoku. Natomiastmetoda unregisterPortalOutlet niszczy taki slot. Metody teleport i finishTeleportation służą odpowiednio do rozpoczęcia i zakończenia renderowania przekazanego (jako “portal”) fragmentu widoku.
Przykładowa implementacja po stronie contentu do przeteleportowania:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
@Component({ selector: 'app-example', changeDetection: ChangeDetectionStrategy.OnPush, template: ` <div #content> I have been teleported by cdk portal! </div>` }) export class ExampleComponent implements OnDestroy { @ViewChild('content') set content(elemRef: ElementRef<HTMLElement>) { this.angularCdkTeleportService.teleport(new DomPortal(elemRef)); } constructor(private readonly angularCdkTeleportService: AngularCdkTeleportService) { } ngOnDestroy(): void { this.angularCdkTeleportService.finishTeleportation(); } } |
Za pomocą @ViewChild tworzymy referencję na element DOM, tworzymy z niego instancję klasy DomPortal i przekazujemy do naszego serwisu. Pamiętamy o tym, by w ngOnDestroy zakończyć teleportację.
Przykładowa implementacja po stronie portal outletu:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
@Component({ selector: 'app-root', changeDetection: ChangeDetectionStrategy.OnPush, template: ` <div #angularCdkPortalOutlet></div>` }) export class AppComponent implements OnDestroy { @ViewChild('angularCdkPortalOutlet') set angularCdkPortalOutletElement(elementRef: ElementRef<HTMLElement>) { this.angularCdkTeleportService.registerPortalOutlet( elementRef.nativeElement ); } constructor(private readonly angularCdkTeleportService: AngularCdkTeleportService) { } ngOnDestroy(): void { this.angularCdkTeleportService.unregisterPortalOutlet(); } } |
Analogicznie do poprzedniego fragmentu tworzymy referencję do elementu DOM, który będzie naszym “slotem” na dynamiczny content i przekazujemy ją do serwisu. W ngOnDestroy pamiętamy o zniszczeniu slotu.
Powyższa implementacja przedstawia wersję uproszczoną. Nic nie stoi na przeszkodzie, by wprowadzić możliwość występowania wielu slotów i identyfikowania ich za pomocą kluczy oraz obsługi przypadku, w którym najpierw dostarczamy fragment UI do teleportacji, a potem dopiero dokonujemy rejestracji outletu. Zamiast ręcznego przekazywania referencji do serwisu można stworzyć do tego specjalne dyrektywy.
@ngneat/overview > teleporting
https://github.com/ngneat/overview#Teleporting
Biblioteka @ngneat/overview dostarcza nam pakiet dwóch dyrektyw:
- teleportOutlet, która tworzy w danym miejscu “slot”,
- *teleportTo, która oznacza content do teleportacji.
Fragment kodu z teleportOutlet z dokumentacji:
1 2 3 4 5 6 7 8 |
@Component({ template: ` <div class="flex"> <ng-container teleportOutlet="someId"></ng-container> </div> ` }) export class FooComponent {} |
Fragment kodu z *teleportTo z dokumentacji:
1 2 3 4 5 6 7 8 9 10 |
@Component({ template: ` <section *teleportTo="'someId'"> {{ value }} </section> ` }) export class BarComponent { value = '...' } |
Własna implementacja
Jeśli nie chcemy lub nie możemy skorzystać z gotowych rozwiązań to w dość prosty sposób jesteśmy w stanie stworzyć cały mechanizm od zera.
W roli “slotu” na dynamiczny content działać będzie ViewContainerRef.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
import {Injectable, TemplateRef, ViewContainerRef} from '@angular/core'; @Injectable({ providedIn: 'root' }) export class CustomTeleporterService { private portalOutlet: ViewContainerRef | null = null; registerPortalOutlet(viewContainerRef: ViewContainerRef): void { this.portalOutlet = viewContainerRef; } unregisterPortalOutlet(): void { this.portalOutlet = null; } startTeleportation(templateRef: TemplateRef<unknown>): void { this.portalOutlet?.createEmbeddedView(templateRef); } finishTeleportation(): void { this.portalOutlet?.clear(); } } |
Czerpiąc głęboką inspirację z rozwiązania od @ngneat tworzymy dwie dyrektywy:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
@Directive({ selector: '[customTeleportOutlet]', }) export class CustomPortalOutletDirective implements OnDestroy { constructor( private readonly viewContainerRef: ViewContainerRef, private readonly teleportService: CustomTeleporterService ) { this.teleportService.registerPortalOutlet(this.viewContainerRef); } ngOnDestroy(): void { this.teleportService.unregisterPortalOutlet(); } } |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
@Directive({ selector: '[customTeleportTo]', }) export class CustomTeleportToDirective implements OnDestroy { constructor( private readonly templateRef: TemplateRef<unknown>, private readonly teleportService: CustomTeleporterService ) { this.teleportService.startTeleportation(this.templateRef); } ngOnDestroy(): void { this.teleportService.finishTeleportation(); } } |
Przykład użycia wygląda następująco:
1 2 3 4 5 6 7 8 9 |
@Component({ selector: 'app-example', changeDetection: ChangeDetectionStrategy.OnPush, template: ` <div *customTeleportTo> I've been teleported! </div>` }) export class ExampleComponent {} |
1 2 3 4 5 6 7 |
@Component({ selector: 'app-root', changeDetection: ChangeDetectionStrategy.OnPush, template: ` <ng-container customTeleportOutlet></ng-container>` }) export class AppComponent {} |
Tak jak w przypadku przykładu z @angular/cdk docelowe rozwiązanie warto rozbudować o dodatkowe możliwości i zabezpieczenia.
Zakończenie
Repozytorium, zawierające wszystkie powyższe implementacje, znajduje się pod poniższym linkiem:
https://github.com/mateusz-dobrowolski-va/angular-teleportation
Dodaj komentarz