Einführung
Angular bietet eine bequeme deklarative Möglichkeit, Ereignisse in einer Vorlage mit der Syntax (eventName)="onEventName($event)"
zu abonnieren. Zusammen mit der ChangeDetectionStrategy.OnPush
Änderungsprüfungsrichtlinie startet dieser Ansatz den Änderungsprüfungszyklus automatisch nur für Benutzereingaben, die uns interessieren. Mit anderen Worten, wenn wir das (input)
Ereignis im <input>
-Element abhören, wird die Änderungsprüfung nicht ausgelöst, wenn der Benutzer einfach auf das Eingabefeld klickt. Es verbessert sich stark
Leistung im Vergleich zur Standardrichtlinie ( ChangeDetectionStrategy.Default
). In Direktiven können wir Ereignisse auf dem Host-Element auch über den @HostListener('eventName')
.
In meiner Praxis gibt es häufig Fälle, in denen die Verarbeitung eines bestimmten Ereignisses nur erforderlich ist, wenn eine Bedingung erfüllt ist. d.h. Der Handler sieht ungefähr so aus:
class ComponentWithEventHandler { // ... onEvent(event: Event) { if (!this.condition) { return; } // Handling event ... } }
Selbst wenn die Bedingung nicht erfüllt ist und tatsächlich keine Aktionen ausgeführt wurden, wird der Änderungsüberprüfungszyklus trotzdem gestartet. Bei häufigen Ereignissen wie mousemove
oder mousemove
kann dies die Anwendungsleistung beeinträchtigen.
In der Komponenten-UI-Bibliothek, an der ich arbeite, löste das Abonnieren von mousemove
in den Dropdown-Menüs eine Nachzählung von Änderungen im gesamten Komponentenbaum für jede Mausbewegung aus. Es war notwendig, die Maus zu überwachen, um das richtige Menüverhalten zu implementieren, aber es war eindeutig eine Optimierung wert. Mehr dazu weiter unten.
Solche Momente sind besonders wichtig für universelle UI-Elemente. Möglicherweise befinden sich viele davon auf der Seite, und Anwendungen können sehr komplex sein und hohe Anforderungen an die Leistung stellen.
Sie können die Situation korrigieren, indem Sie Ereignisse unter Umgehung von ngZone
, z. B. mit Observable.fromEvent
und manuell nach Änderungen ngZone
, indem Sie changeDetectorRef.markForCheck()
aufrufen. Dies bringt jedoch eine Menge zusätzlicher Arbeit mit sich und macht es unmöglich, die praktischen integrierten Winkelwerkzeuge zu verwenden.
Es ist kein Geheimnis, dass Angular es Ihnen ermöglicht, die sogenannten Pseudo-Ereignisse zu abonnieren und genau anzugeben, an welchen Ereignissen wir interessiert sind. Wir können schreiben (keydown.enter)="onEnter($event)"
und der Handler (und damit der Änderungsprüfzyklus) wird nur aufgerufen, wenn die Enter
gedrückt wird. Die verbleibenden Drücke werden ignoriert. In diesem Artikel wird erläutert, wie Sie denselben Ansatz wie Angular verwenden können, um die Ereignisbehandlung zu optimieren. .prevent
als .stop
.prevent
und .stop
, die das Standardverhalten aufheben und verhindern, dass das Ereignis automatisch angezeigt wird.
EventManagerPlugin
Angular verwendet die EventManager
Klasse, um Ereignisse zu verarbeiten. Es verfügt über eine Reihe sogenannter Plugins, die das abstrakte EventManagerPlugin
und die Verarbeitung von Ereignisabonnements an das Plugin delegieren, das dieses Ereignis unterstützt (nach Namen). In Angular gibt es mehrere Plugins, darunter die HammerJS-Ereignisbehandlung und ein Plugin, das für zusammengesetzte Ereignisse wie keydown.enter
. Dies ist eine interne Implementierung von Angular, und dieser Ansatz kann sich ändern. Seit der Erstellung des Problems zur Verarbeitung dieser Lösung sind jedoch drei Jahre vergangen, und in dieser Richtung wurden keine Fortschritte erzielt:
https://github.com/angular/angular/issues/3929
Was ist daran für uns interessant? Trotz der Tatsache, dass diese Klassen intern sind und nicht von ihnen geerbt werden können, ist das Token, das für die Implementierung von Abhängigkeiten für Plugins verantwortlich ist, öffentlich. Dies bedeutet, dass wir unsere eigenen Plugins schreiben und den integrierten Ereignisbehandlungsmechanismus damit erweitern können.
Wenn Sie sich den Quellcode von EventManagerPlugin
, werden Sie feststellen, dass wir nicht davon erben können. Er ist größtenteils abstrakt und es ist einfach, eine eigene Klasse zu implementieren, die den Anforderungen entspricht:
https://github.com/angular/angular/blob/master/packages/platform-browser/src/dom/events/event_manager.ts#L92
Grob gesagt sollte das Plugin in der Lage sein, festzustellen, ob es mit diesem Ereignis arbeitet, und sollte in der Lage sein, einen Ereignishandler und globale Handler (für body
, window
und document
) hinzuzufügen. Wir werden uns für die Modifikatoren .filter
, .prevent
und .stop
. Um sie an unser Plugin zu binden, implementieren wir die erforderlichen Methodenunterstützungen:
const FILTER = '.filter'; const PREVENT = '.prevent'; const STOP = '.stop'; class FilteredEventPlugin { supports(event: string): boolean { return ( event.includes(FILTER) || event.includes(PREVENT) || event.includes(STOP) ); } }
EventManager
wird also verstehen, dass Ereignisse, in deren Namen bestimmte Modifikatoren vorhanden sind, zur Verarbeitung an unser Plugin übergeben werden müssen. Dann müssen wir das Hinzufügen von Ereignishandlern implementieren. Wir sind nicht an globalen Handlern interessiert, in ihrem Fall ist der Bedarf an solchen Tools viel seltener und die Implementierung wäre komplizierter. Daher entfernen wir einfach unsere Modifikatoren aus dem Ereignisnamen und geben sie an den EventManager
damit er EventManager
richtige integrierte Plug-In für die Verarbeitung EventManager
:
class FilteredEventPlugin { supports(event: string): boolean { // ... } addGlobalEventListener( element: string, eventName: string, handler: Function, ): Function { const event = eventName .replace(FILTER, '') .replace(PREVENT, '') .replace(STOP, ''); return this.manager.addGlobalEventListener(element, event, handler); } }
Im Falle eines Ereignisses für ein reguläres Element müssen wir unsere eigene Logik schreiben. Zu diesem EventManager
wir den Handler in einen Abschluss ein und übergeben das Ereignis ohne unsere Modifikatoren an den EventManager
, der außerhalb von ngZone
, um zu vermeiden, dass der Änderungsprüfzyklus ngZone
wird:
class FilteredEventPlugin { supports(event: string): boolean { // ... } addEventListener( element: HTMLElement, eventName: string, handler: Function, ): Function { const event = eventName .replace(FILTER, '') .replace(PREVENT, '') .replace(STOP, ''); // const filtered = (event: Event) => { // ... }; const wrapper = () => this.manager.addEventListener(element, event, filtered); return this.manager.getZone().runOutsideAngular(wrapper); } /* addGlobalEventListener(...): Function { ... } */ }
Zu diesem Zeitpunkt haben wir: den Namen des Ereignisses, das Ereignis selbst und das Element, auf das es gehört wird. Der Handler, der hierher kommt, ist nicht der diesem Ereignis zugewiesene Quellhandler, sondern das Ende der Schließungskette, die Angular für seine eigenen Zwecke erstellt hat.
Eine Lösung wäre, dem Element ein Attribut hinzuzufügen, das für den Aufruf des Handlers verantwortlich ist oder nicht. Manchmal ist es zur Entscheidungsfindung erforderlich, das Ereignis selbst zu analysieren: ob die Standardaktion abgebrochen wurde, welches Element die Quelle des Ereignisses ist usw. Ein Attribut reicht dafür nicht aus. Wir müssen einen Weg finden, eine Filterfunktion festzulegen, die ein Ereignis empfängt und true
oder false
zurückgibt. Dann könnten wir unseren Handler wie folgt beschreiben:
const filtered = (event: Event) => { const filter = getOurHandler(some_arguments); if ( !eventName.includes(FILTER) || !filter || filter(event) ) { if (eventName.includes(PREVENT)) { event.preventDefault(); } if (eventName.includes(STOP)) { event.stopPropagation(); } this.manager.getZone().run(() => handler(event)); } };
Lösung
Die Lösung kann ein Singleton-Dienst sein, der die Entsprechung von Elementen zu Ereignis / Filter-Paaren und Hilfsentitäten zum Festlegen dieser Entsprechungen speichert. Natürlich können auf einem Element mehrere Handler für dasselbe Ereignis vorhanden sein, aber in der Regel können sowohl @HostListener
als auch ein Handler auf dieser Komponente in der Vorlage eine Ebene höher installiert sein. Wir werden diese Situation vorhersehen, während andere Fälle aufgrund ihrer Spezifität für uns von geringem Interesse sind.
Der Hauptdienst ist recht einfach und besteht aus einer Karte und einigen Methoden zum Einstellen, Empfangen und Reinigen von Filtern:
export type Filter = (event: Event) => boolean; export type Filters = {[key: string]: Filter}; class FilteredEventMainService { private elements: Map<Element, Filters> = new Map(); register(element: Element, filters: Filters) { this.elements.set(element, filters); } unregister(element: Element) { this.elements.delete(element); } getFilter(element: Element, event: string): Filter | null { const map = this.elements.get(element); return map ? map[event] || null : null; } }
Somit können wir diesen Service im Plugin implementieren und einen Filter erhalten, indem wir das Element und den Namen des Ereignisses übergeben. Für die Verwendung in Verbindung mit @HostListener
fügen wir einen weiteren kleinen Dienst hinzu, der mit der Komponente lebt und die entsprechenden Filter @HostListener
wenn sie entfernt wird:
export class EventFiltersService { constructor( @Inject(ElementRef) private readonly elementRef: ElementRef, @Inject(FilteredEventMainService) private readonly mainService: FilteredEventMainService, ) {} ngOnDestroy() { this.mainService.unregister(this.elementRef.nativeElement); } register(filters: Filters) { this.mainService.register(this.elementRef.nativeElement, filters); } }
Um Elemente zu Filtern hinzuzufügen, können Sie eine ähnliche Anweisung erstellen:
class EventFiltersDirective { @Input() set eventFilters(filters: Filters) { this.mainService.register(this.elementRef.nativeElement, filters); } constructor( @Inject(ElementRef) private readonly elementRef: ElementRef, @Inject(FilteredEventMainService) private readonly mainService: FilteredEventMainService, ) {} ngOnDestroy() { this.mainService.unregister(this.elementRef.nativeElement); } }
Wenn es einen Dienst zum Filtern von Ereignissen innerhalb der Komponente gibt, können Filter nicht über die Direktive daran gehängt werden. Am Ende kann dies fast immer durch einfaches Umschließen der Komponente mit dem Element erfolgen, dem unsere Direktive zugewiesen wird. Um zu verstehen, dass für dieses Element bereits ein Dienst vorhanden ist, werden wir ihn optional in der Richtlinie implementieren:
class EventFiltersDirective { // ... constructor( @Optional() @Self() @Inject(FiltersService) private readonly filtersService: FiltersService | null, ) {} // ... }
Wenn dieser Dienst vorhanden ist, wird eine Meldung angezeigt, die besagt, dass die Richtlinie nicht auf ihn anwendbar ist:
class EventFiltersDirective { @Input() set eventFilters(filters: Filters) { if (this.eventFiltersService === null) { console.warn(ALREADY_APPLIED_MESSAGE); return; } this.mainService.register(this.elementRef.nativeElement, filters); }
Praktische Anwendung
Alle beschriebenen Codes finden Sie auf Stackblitz:
https://stackblitz.com/edit/angular-event-filter
Als Anwendungsbeispiele wird dort eine imaginäre select
angezeigt - eine Komponente in einem modalen Fenster - und ein Kontextmenü in der Rolle des Dropdowns. Wenn Sie im Kontextmenü eine Implementierung überprüfen, werden Sie feststellen, dass das Verhalten immer das folgende ist: Wenn Sie mit der Maus über ein Element fahren, wird es fokussiert. Wenn Sie die Pfeile auf der Tastatur drücken, bewegt sich der Fokus durch die Elemente. Wenn Sie jedoch die Maus bewegen, kehrt der Fokus zum Element zurück befindet sich unter dem Mauszeiger. Es scheint, dass dieses Verhalten einfach zu implementieren ist. Unnötige Reaktionen auf das mousemove
können jedoch Dutzende nutzloser Änderungsprüfzyklen auslösen. Indem Sie als Filter eine Prüfung für den Fokus des Zielelements des Ereignisses festlegen, können Sie diese unnötigen Auslösungen abschneiden und nur diejenigen belassen, die wirklich den Fokus tragen.

Diese select
@HostListener
nach @HostListener
Abonnements. Wenn Sie die Esc
im Popup drücken, sollte es geschlossen werden. Dies sollte nur geschehen, wenn dieser Klick in einer verschachtelten Komponente nicht erforderlich war und in dieser nicht verarbeitet wurde. Bei select
Drücken von Esc
die Dropdown-Liste geschlossen und der Fokus auf das Feld selbst zurückgesetzt. Wenn es jedoch bereits geschlossen ist, sollte es nicht verhindern, dass das Ereignis auftaucht und das modale Fenster später geschlossen wird. Somit kann die Verarbeitung durch einen Dekorateur beschrieben werden:
@HostListener('keydown.esc.filtered.stop')
beim @HostListener('keydown.esc.filtered.stop')
: () => this.opened
.
Da select
eine Komponente mit mehreren fokussierbaren Elementen ist, ist die Verfolgung des allgemeinen Fokus über focusout
Popup-Ereignisse möglich. Sie treten bei allen Änderungen im Fokus auf, einschließlich derer, die die Grenzen der Komponente nicht verlassen. Dieses Ereignis verfügt über ein verwandtes relatedTarget
, das relatedTarget
wohin sich der Fokus bewegt. Nachdem wir es analysiert haben, können wir verstehen, ob wir ein Analogon des blur
für unsere Komponente aufrufen sollen:
class SelectComponent {
Der Filter sieht gleichzeitig so aus:
const focusOutFilter = ({relatedTarget}: FocusEvent) => !this.elementRef.nativeElement.contains(relatedTarget);
Fazit
Leider wird die integrierte Verarbeitung von zusammengesetzten Tastenanschlägen in Angular weiterhin in NgZone
, was bedeutet, dass nach Änderungen NgZone
wird. Wenn wir wollten, hätten wir nicht auf die integrierte Verarbeitung zurückgreifen können, aber der Leistungsgewinn wäre gering und die Aussparungen in der internen „Küche“ von Angular könnten während des Upgrades beschädigt werden. Daher müssen wir entweder das zusammengesetzte Ereignis abbrechen oder einen Filter verwenden, der dem Grenzoperator ähnlich ist, und den Handler einfach nicht aufrufen, wenn er nicht relevant ist.
Der Einstieg in die interne Ereignisverarbeitung von Angular ist ein abenteuerliches Unterfangen, da sich die interne Implementierung in Zukunft ändern kann. Dies verpflichtet uns, die Aktualisierungen zu verfolgen, insbesondere die Aufgabe auf GitHub, die im zweiten Abschnitt des Artikels angegeben ist. Jetzt können wir die Ausführung von preventDefault
bequem filtern und nach Änderungen preventDefault
Jetzt haben wir die Möglichkeit, die für die preventDefault
stopPropagation
Methoden preventDefault
und stopPropagation
bequem anzuwenden, wenn Sie ein Abonnement deklarieren. Ab Zukunft wäre es bequemer, Filter für @HostListener
direkt neben ihnen mithilfe von Dekoratoren zu deklarieren. Im nächsten Artikel möchte ich über mehrere Dekorateure sprechen, die wir zu Hause erstellt haben, und versuchen, diese Lösung zu implementieren.