03 sie 2022
5 min

Teleportacja w Angularze

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.

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:

@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:

@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:

@Component({
 template: `
   <div class="flex">
     <ng-container teleportOutlet="someId"></ng-container>
   </div>
 `
})
export class FooComponent {}

Fragment kodu z *teleportTo z dokumentacji:

@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.

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:

@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();
 }
}
@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:

@Component({
 selector: 'app-example',
 changeDetection: ChangeDetectionStrategy.OnPush,
 template: `
   <div *customTeleportTo>
     I've been teleported!
   </div>`
})
export class ExampleComponent {}
@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

Najczęściej zadawane pytania

Czym jest teleportacja w Angularze i kiedy warto ją stosować?

Teleportacja w Angularze to zmiana miejsca renderowania fragmentu widoku, zwykle do innego komponentu, przy zachowaniu wiązań z danymi i eventów oryginalnego komponentu. Przydaje się, gdy samo content projection nie wystarcza, np. gdy widget w nagłówku strony ma zmieniać się zależnie od podstrony, a więc przodek zależy od swojego potomka. Zmiany w drzewie DOM nie wpływają na logiczne drzewo komponentów.

Jak zrobić teleportację z użyciem @angular/cdk/portal?

Najprościej zbudować serwis singleton, który rejestruje DomPortalOutlet na elemencie DOM w komponencie będącym slotem, a komponent źródłowy przekazuje DomPortal z referencją na element pobraną przez @ViewChild. Metoda teleport() wywołuje attach() na outlecie, a finishTeleportation() wywołuje detach(). Slot należy zniszczyć przez dispose() w ngOnDestroy.

Czym różni się teleportacja od content projection w Angularze?

Content projection, realizowane przez <ng-content>, wymaga relacji rodzic-dziecko. Teleportacja pozwala przenieść fragment widoku także z dziecka do rodzica, czyli lokalnie odwrócić zależność w drzewie komponentów. Stosuje się ją wtedy, gdy samo content projection nie wystarcza.

Jak zaimplementować teleportację od zera za pomocą ViewContainerRef i dyrektyw?

Slotem jest ViewContainerRef zarejestrowany w serwisie przez dyrektywę customTeleportOutlet, a treść oznaczona dyrektywą *customTeleportTo jest renderowana przez createEmbeddedView() z TemplateRef. Po zniszczeniu dyrektywy customTeleportTo wywoływane jest clear(), które usuwa widok z outletu. Ten wariant nie wymaga @angular/cdk, ale wymaga własnych zabezpieczeń, np. obsługi wielu slotów.

Czy teleportowany fragment widoku w Angularze zachowuje bindingi i obsługę zdarzeń?

Teleportowany fragment widoku zachowuje wiązania i eventy oryginalnego komponentu, ponieważ Angular utrzymuje logiczne drzewo komponentów oderwane od drzewa DOM. Zmiana położenia elementu w DOM nie zmienia tego, do którego komponentu należy szablon, więc {{ value }} i (click) nadal działają w kontekście komponentu źródłowego.

Podziel się artykułem

Zapisz się na nasz newsletter

Dołącz do community Angular.love i bądź na bieżąco z trendami.