Optimisation de la gestion des événements dans Angular

Présentation


Angular fournit un moyen déclaratif pratique de s'abonner aux événements dans un modèle en utilisant la syntaxe (eventName)="onEventName($event)" . Avec la ChangeDetectionStrategy.OnPush vérification des modifications ChangeDetectionStrategy.OnPush , cette approche démarre automatiquement le cycle de vérification des modifications uniquement pour les entrées utilisateur qui nous intéressent. En d'autres termes, si nous écoutons l'événement (input) sur l'élément <input> , la vérification des modifications ne sera pas déclenchée si l'utilisateur clique simplement sur le champ de saisie. Il améliore considérablement
performances par rapport à la stratégie par défaut ( ChangeDetectionStrategy.Default ). Dans les directives, nous pouvons également souscrire aux événements sur l'élément hôte via le @HostListener('eventName') .


Dans ma pratique, il y a souvent des cas où le traitement d'un événement spécifique n'est requis que si une condition est remplie. c'est-à-dire le gestionnaire ressemble à ceci:


 class ComponentWithEventHandler { // ... onEvent(event: Event) { if (!this.condition) { return; } // Handling event ... } } 

Même si la condition n'est pas remplie et qu'aucune action n'a en fait eu lieu, le cycle de vérification des modifications sera toujours lancé. Dans le cas d'événements fréquents, comme le scroll ou le mousemove , cela peut nuire aux performances de l'application.


Dans la bibliothèque d'interface utilisateur de composant sur laquelle je travaille, l'abonnement à mousemove à l'intérieur des menus déroulants a déclenché un recomptage des modifications dans l'arborescence complète des composants pour chaque mouvement de souris. Il était nécessaire de surveiller la souris pour implémenter le bon comportement du menu, mais cela valait clairement la peine d'être optimisé. Plus d'informations ci-dessous.


Ces moments sont particulièrement importants pour les éléments d'interface utilisateur universels. Il peut y en avoir beaucoup sur la page, et les applications peuvent être très complexes et exigeantes en termes de performances.


Vous pouvez corriger la situation en vous abonnant à des événements contournant ngZone , par exemple, en utilisant Observable.fromEvent et en commençant à rechercher manuellement les modifications, en appelant changeDetectorRef.markForCheck() . Cependant, cela ajoute une tonne de travail supplémentaire et rend impossible l'utilisation des outils angulaires intégrés pratiques.


Ce n'est pas un secret que Angular vous permet de vous abonner aux soi-disant pseudo-événements, en spécifiant exactement les événements qui nous intéressent. Nous pouvons écrire (keydown.enter)="onEnter($event)" et le gestionnaire (et avec lui le cycle de vérification des modifications) sera appelé uniquement lorsque la touche Enter est enfoncée. Les autres pressions seront ignorées. Dans cet article, nous verrons comment vous pouvez utiliser la même approche que Angular pour optimiser la gestion des événements. Et en bonus, ajoutez des .stop .prevent et .stop , qui annuleront le comportement par défaut et empêcheront l'événement de .stop automatiquement.


EventManagerPlugin



Angular utilise la classe EventManager pour gérer les événements. Il dispose d'un ensemble de plug-ins qui étendent le résumé EventManagerPlugin et délègue le traitement de l'abonnement aux événements au plug-in qui prend en charge cet événement (par son nom). Il y a plusieurs plugins dans Angular, y compris la gestion des événements HammerJS et un plugin responsable des événements composites comme keydown.enter . Il s'agit d'une implémentation interne d'Angular, et cette approche est sujette à changement. Cependant, 3 ans se sont écoulés depuis la création du problème sur le traitement de cette solution, et aucun progrès n'a été fait dans ce sens:


https://github.com/angular/angular/issues/3929


Qu'est-ce qui est intéressant pour nous? Malgré le fait que ces classes sont internes et ne peuvent pas en être héritées, le jeton responsable de l'implémentation des dépendances pour les plugins est public. Cela signifie que nous pouvons écrire nos propres plugins et étendre le mécanisme de gestion des événements intégré avec eux.


Si vous regardez le code source d' EventManagerPlugin , vous remarquerez que nous ne pourrons pas en hériter, pour la plupart, il est abstrait et il est facile d'implémenter notre propre classe qui répond à ses exigences:


https://github.com/angular/angular/blob/master/packages/platform-browser/src/dom/events/event_manager.ts#L92


En gros, le plugin devrait être en mesure de déterminer s'il fonctionne avec cet événement et devrait pouvoir ajouter un gestionnaire d'événements et des gestionnaires globaux (sur le body , la window et le document ). Nous serons intéressés par les modificateurs .filter , .prevent et .stop . Pour les lier à notre plugin, nous implémentons la méthode requise supports :


 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) ); } } 

Ainsi, EventManager comprendra que les événements au nom desquels il existe certains modificateurs doivent être transmis à notre plugin pour traitement. Ensuite, nous devons implémenter l'ajout de gestionnaires d'événements. Nous ne sommes pas intéressés par les gestionnaires globaux, dans leur cas, le besoin de tels outils est beaucoup moins courant, et la mise en œuvre serait plus compliquée. Par conséquent, nous EventManager simplement nos modificateurs du nom de l'événement et le EventManager à EventManager afin qu'il EventManager plug-in intégré correct pour le traitement:


 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); } } 

Dans le cas d'un événement sur un élément régulier, nous devons écrire notre propre logique. Pour ce faire, nous enveloppons le gestionnaire dans une fermeture et transmettons l'événement sans nos modificateurs à EventManager , en l'appelant en dehors de ngZone , pour éviter de démarrer le cycle de vérification des modifications:


 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 { ... } */ } 

A ce stade, nous avons: le nom de l'événement, l'événement lui-même et l'élément sur lequel il est écouté. Le gestionnaire qui arrive ici n'est pas le gestionnaire source affecté à cet événement, mais la fin de la chaîne de fermeture créée par Angular à ses propres fins.


Une solution serait d'ajouter un attribut à l'élément, qui est responsable d'appeler le gestionnaire ou non. Parfois, pour prendre une décision, il est nécessaire d'analyser l'événement lui-même: si l'action par défaut a été annulée, quel élément est la source de l'événement, etc. Un attribut ne suffit pas pour cela, nous devons trouver un moyen de définir une fonction de filtre qui reçoit un événement et renvoie true ou false . Ensuite, nous pourrions décrire notre gestionnaire comme suit:


 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)); } }; 

Solution


La solution peut être un service singleton qui stocke la correspondance des éléments avec les paires d'événement / filtre et les entités auxiliaires pour établir ces correspondances. Bien sûr, sur un élément, il peut y avoir plusieurs gestionnaires pour le même événement, mais, en règle générale, il peut s'agir à la fois de @HostListener et d'un gestionnaire installé sur ce composant dans le modèle un niveau plus haut. Nous prévoyons cette situation, tandis que d'autres cas nous intéressent peu en raison de sa spécificité.


Le service principal est assez simple et se compose d'une carte et de quelques méthodes pour définir, recevoir et nettoyer les filtres:


 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; } } 

Ainsi, nous pouvons implémenter ce service dans le plugin et recevoir un filtre en passant l'élément et le nom de l'événement. Pour une utilisation en conjonction avec @HostListener nous ajoutons un autre petit service qui vivra avec le composant et effacera les filtres correspondants lorsqu'il sera supprimé:


 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); } } 

Pour ajouter des filtres aux éléments, vous pouvez créer une directive similaire:


 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); } } 

S'il existe un service de filtrage des événements à l'intérieur du composant, nous ne permettrons pas d'y accrocher des filtres via la directive. En fin de compte, cela peut presque toujours être fait en enveloppant simplement le composant avec l'élément auquel notre directive sera affectée. Pour comprendre qu'un service est déjà présent sur cet élément, nous allons éventuellement l'implémenter dans la directive:


 class EventFiltersDirective { // ... constructor( @Optional() @Self() @Inject(FiltersService) private readonly filtersService: FiltersService | null, ) {} // ... } 

Si ce service est présent, nous afficherons un message indiquant que la directive ne lui est pas applicable:


 class EventFiltersDirective { @Input() set eventFilters(filters: Filters) { if (this.eventFiltersService === null) { console.warn(ALREADY_APPLIED_MESSAGE); return; } this.mainService.register(this.elementRef.nativeElement, filters); } // ... } 


Application pratique


Tout le code décrit peut être trouvé sur Stackblitz:


https://stackblitz.com/edit/angular-event-filter


À titre d'exemples d'utilisation, une select imaginaire y est représentée - un composant à l'intérieur d'une fenêtre modale - et un menu contextuel dans le rôle de sa liste déroulante. Dans le cas du menu contextuel, si vous cochez une implémentation, vous verrez que le comportement est toujours le suivant: lorsque vous survolez un élément, il se concentre, lorsque vous appuyez sur les flèches du clavier, le focus se déplace à travers les éléments, mais si vous déplacez la souris, le focus revient à l'élément situé sous le pointeur de la souris. Il semblerait que ce comportement soit facile à implémenter, cependant, des réactions inutiles à l'événement mousemove peuvent déclencher des dizaines de cycles de vérification des modifications inutiles. En définissant comme filtre une vérification de la focalisation de l'élément target de l'événement, nous pouvons couper ces déclenchements inutiles, ne laissant que ceux qui portent réellement la focalisation.



En outre, ce composant select a un filtrage sur les abonnements @HostListener . Lorsque vous appuyez sur la Esc à l'intérieur de la fenêtre contextuelle, elle devrait se fermer. Cela ne devrait se produire que si ce clic n'était pas nécessaire dans un composant imbriqué et n'y avait pas été traité. En select appuyer sur Esc provoque la fermeture de la liste déroulante et le focus reviendra au champ lui-même, mais s'il est déjà fermé, cela ne devrait pas empêcher l'événement de surgir et la fenêtre modale de se fermer plus tard. Ainsi, le traitement peut être décrit par un décorateur:


@HostListener('keydown.esc.filtered.stop') , lors du @HostListener('keydown.esc.filtered.stop') : () => this.opened .


Étant donné que select est un composant avec plusieurs éléments focalisables, le suivi de sa focalisation générale est possible via des événements contextuels de focusout . Ils se produisent avec tous les changements de focus, y compris ceux qui ne quittent pas les limites du composant. Cet événement a un champ relatedTarget qui relatedTarget où le focus se déplace. Après l'avoir analysé, nous pouvons comprendre s'il faut appeler un analogue de l'événement blur pour notre composant:


 class SelectComponent { // ... @HostListener('focusout.filtered') onBlur() { this.opened = false; } // ... } 

Le filtre, en même temps, ressemble à ceci:


 const focusOutFilter = ({relatedTarget}: FocusEvent) => !this.elementRef.nativeElement.contains(relatedTarget); 

Conclusion


Malheureusement, le traitement intégré des frappes composées dans Angular commencera toujours dans NgZone , ce qui signifie qu'il vérifiera les changements. Si nous le voulions, nous n'aurions pas pu recourir au traitement intégré, mais le gain de performances serait faible et les évidements dans la «cuisine» interne d'Angular pourraient être endommagés lors de la mise à niveau. Par conséquent, nous devons soit abandonner l'événement composite, soit utiliser un filtre similaire à l'opérateur de frontière et simplement ne pas appeler le gestionnaire lorsqu'il n'est pas pertinent.


S'engager dans le traitement des événements internes d'Angular est une entreprise aventureuse, car la mise en œuvre interne peut changer à l'avenir. Cela nous oblige à suivre les mises à jour, en particulier la tâche sur GitHub, donnée dans la deuxième section de l'article. Mais maintenant, nous pouvons facilement filtrer l'exécution des gestionnaires et commencer à vérifier les modifications; nous avons maintenant la possibilité d'appliquer facilement les méthodes preventDefault et stopPropagation typiques du traitement d'événements lors de la déclaration d'un abonnement. À l'avenir, il serait plus pratique de déclarer des filtres pour @HostListener juste à côté d'eux à l'aide de décorateurs. Dans le prochain article, j'ai l'intention de parler de plusieurs décorateurs que nous avons créés à la maison et d'essayer de mettre en œuvre cette solution.

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


All Articles