Optimierung der Ereignisbehandlung in Angular

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 { // ... @HostListener('focusout.filtered') onBlur() { this.opened = false; } // ... } 

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.

Source: https://habr.com/ru/post/de429692/


All Articles