Introduccion
Angular proporciona una forma declarativa conveniente de suscribirse a eventos en una plantilla utilizando la sintaxis (eventName)="onEventName($event)"
. Junto con la política de verificación de cambios ChangeDetectionStrategy.OnPush
, este enfoque inicia automáticamente el ciclo de verificación de cambios solo para la entrada del usuario que nos interesa. En otras palabras, si escuchamos el evento (input)
en el elemento <input>
, la verificación de cambio no se activará si el usuario simplemente hace clic en el campo de entrada. Mejora mucho
rendimiento en comparación con la política predeterminada ( ChangeDetectionStrategy.Default
). En las directivas, también podemos suscribirnos a eventos en el elemento host a través del @HostListener('eventName')
.
En mi práctica, a menudo hay casos en los que se requiere el procesamiento de un evento específico solo si se cumple una condición. es decir el controlador se parece a esto:
class ComponentWithEventHandler { // ... onEvent(event: Event) { if (!this.condition) { return; } // Handling event ... } }
Incluso si la condición no se cumple y no se han producido acciones, el ciclo de verificación de cambio aún se iniciará. En el caso de eventos frecuentes, como scroll
o mousemove
, esto puede afectar negativamente el rendimiento de la aplicación.
En la biblioteca de IU de componentes en la que estoy trabajando, suscribirme a mousemove
dentro de los menús desplegables desencadenó un recuento de cambios en todo el árbol de componentes para cada movimiento del mouse. Era necesario monitorear el mouse para implementar el comportamiento correcto del menú, pero claramente valía la pena optimizarlo. Más sobre esto a continuación.
Tales momentos son especialmente importantes para los elementos universales de la interfaz de usuario. Puede haber muchos de ellos en la página, y las aplicaciones pueden ser muy complejas y exigentes en cuanto al rendimiento.
Puede corregir la situación suscribiéndose a eventos sin pasar por ngZone
, por ejemplo, usando Observable.fromEvent
y comenzar a buscar cambios a mano, llamando a changeDetectorRef.markForCheck()
. Sin embargo, esto agrega un montón de trabajo extra y hace que sea imposible usar las prácticas herramientas angulares incorporadas.
No es ningún secreto que Angular le permite suscribirse a los llamados pseudoeventos, especificando exactamente en qué eventos estamos interesados. Podemos escribir (keydown.enter)="onEnter($event)"
y el controlador (y con él el ciclo de verificación de cambio) se llamará solo cuando se presione la tecla Enter
. Las presiones restantes se ignorarán. En este artículo, veremos cómo puede usar el mismo enfoque que Angular para optimizar el manejo de eventos. Y como .stop
adicional, agregue los .stop
y .stop
, que cancelarán el comportamiento predeterminado y .stop
que el evento .stop
automáticamente.
EventManagerPlugin
Angular usa la clase EventManager
para manejar eventos. Tiene un conjunto de los llamados complementos que amplían el EventManagerPlugin
abstracto y delega el procesamiento de suscripción de eventos al complemento que admite este evento (por nombre). Hay varios complementos dentro de Angular, incluido el manejo de eventos HammerJS y un complemento responsable de eventos compuestos como keydown.enter
. Esta es una implementación interna de Angular, y este enfoque está sujeto a cambios. Sin embargo, han pasado 3 años desde la creación del problema sobre el procesamiento de esta solución, y no se ha avanzado en esta dirección:
https://github.com/angular/angular/issues/3929
¿Qué es interesante en esto para nosotros? A pesar de que estas clases son internas y no se pueden heredar de ellas, el token responsable de implementar las dependencias de los complementos es público. Esto significa que podemos escribir nuestros propios complementos y ampliar el mecanismo de gestión de eventos incorporado con ellos.
Si observa el código fuente de EventManagerPlugin
, notará que no podremos heredarlo, en su mayor parte es abstracto y es fácil implementar nuestra propia clase que cumpla con sus requisitos:
https://github.com/angular/angular/blob/master/packages/platform-browser/src/dom/events/event_manager.ts#L92
En términos generales, el complemento debe poder determinar si está funcionando con este evento y debe poder agregar un controlador de eventos y controladores globales (en el body
, la window
y el document
). Nos interesarán los modificadores .filter
, .stop
y .stop
. Para vincularlos a nuestro complemento, implementamos los métodos necesarios:
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) ); } }
Por EventManager
tanto, EventManager
comprenderá que los eventos en cuyo nombre hay ciertos modificadores deben pasarse a nuestro complemento para su procesamiento. Entonces necesitamos implementar la adición de controladores de eventos. No estamos interesados en los controladores globales, en su caso, la necesidad de tales herramientas es mucho menos común, y la implementación sería más complicada. Por lo tanto, simplemente eliminamos nuestros modificadores del nombre del evento y lo devolvemos al EventManager
para que EventManager
complemento incorporado correcto para el procesamiento:
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); } }
En el caso de un evento en un elemento regular, necesitamos escribir nuestra propia lógica. Para hacer esto, envolvemos el controlador en un bucle y pasamos el evento sin nuestros modificadores al EventManager
, llamándolo fuera de ngZone
, para evitar comenzar el ciclo de comprobación de cambios:
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 { ... } */ }
En esta etapa, tenemos: el nombre del evento, el evento en sí y el elemento en el que se está escuchando. El controlador que llega aquí no es el controlador fuente asignado a este evento, sino el final de la cadena de cierre creada por Angular para sus propios fines.
Una solución sería agregar un atributo al elemento, que es responsable de llamar al controlador o no. A veces, para tomar una decisión, es necesario analizar el evento en sí: si se canceló la acción predeterminada, qué elemento es el origen del evento, etc. Un atributo no es suficiente para esto, necesitamos encontrar una manera de establecer una función de filtro que reciba un evento y devuelva true
o false
. Entonces podríamos describir nuestro controlador de la siguiente manera:
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)); } };
Solución
La solución puede ser un servicio singleton que almacene la correspondencia de elementos con pares de eventos / filtros y entidades auxiliares para establecer estas correspondencias. Por supuesto, en un elemento puede haber varios controladores para el mismo evento, pero, por regla general, puede ser tanto @HostListener
como un controlador instalado en este componente en la plantilla un nivel superior. Vamos a prever esta situación, mientras que otros casos nos interesan poco debido a su especificidad.
El servicio principal es bastante simple y consiste en un mapa y un par de métodos para configurar, recibir y limpiar los filtros:
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; } }
Por lo tanto, podemos implementar este servicio en el complemento y recibir un filtro pasando el elemento y el nombre del evento. Para usar junto con @HostListener
agregamos otro pequeño servicio que vivirá con el componente y borrará los filtros correspondientes cuando se elimine:
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); } }
Para agregar filtros a los elementos, puede hacer una directiva similar:
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); } }
Si hay un servicio para filtrar eventos dentro del componente, no permitiremos que se cuelguen filtros a través de la directiva. Al final, esto casi siempre se puede hacer simplemente envolviendo el componente con el elemento al que se asignará nuestra directiva. Para comprender que un servicio ya está presente en este elemento, opcionalmente lo implementaremos en la directiva:
class EventFiltersDirective { // ... constructor( @Optional() @Self() @Inject(FiltersService) private readonly filtersService: FiltersService | null, ) {} // ... }
Si este servicio está presente, mostraremos un mensaje que indica que la directiva no le es aplicable:
class EventFiltersDirective { @Input() set eventFilters(filters: Filters) { if (this.eventFiltersService === null) { console.warn(ALREADY_APPLIED_MESSAGE); return; } this.mainService.register(this.elementRef.nativeElement, filters); }
Aplicación práctica
Todo el código descrito se puede encontrar en Stackblitz:
https://stackblitz.com/edit/angular-event-filter
Como ejemplos de uso, se muestra una select
imaginaria (un componente dentro de una ventana modal) y un menú contextual en el rol de su menú desplegable. En el caso del menú contextual, si marca alguna implementación, verá que el comportamiento es siempre el siguiente: cuando pasa el cursor sobre un elemento, se enfoca, cuando presiona las flechas en el teclado, el foco se mueve a través de los elementos, pero si mueve el mouse, el foco vuelve al elemento ubicado debajo del puntero del mouse. Parece que este comportamiento es fácil de implementar, sin embargo, las reacciones innecesarias al evento mousemove
pueden desencadenar docenas de ciclos de comprobación de cambios inútiles. Al establecer como filtro una verificación para el foco del elemento target
del evento, podemos cortar estos disparos innecesarios, dejando solo aquellos que realmente llevan el foco.

Además, este componente de select
tiene filtrado en suscripciones @HostListener
. Cuando presiona la Esc
dentro de la ventana emergente, debería cerrarse. Esto debería suceder solo si este clic no era necesario en algún componente anidado y no se procesó en él. En select
presionar Esc
cierra el menú desplegable y se devuelve el foco al campo en sí, pero si ya está cerrado, no debe evitar que el evento aparezca y que la ventana modal se cierre más tarde. Por lo tanto, el procesamiento puede ser descrito por un decorador:
@HostListener('keydown.esc.filtered.stop')
, al @HostListener('keydown.esc.filtered.stop')
: () => this.opened
.
Dado que select
es un componente con varios elementos enfocables, el seguimiento de su enfoque general es posible a través de eventos emergentes de focusout
. Ocurrirán con todos los cambios en el foco, incluidos aquellos que no abandonan los límites del componente. Este evento tiene un campo de relatedTarget
que relatedTarget
dónde se mueve el foco. Después de analizarlo, podemos entender si llamar a un análogo del evento de blur
para nuestro componente:
class SelectComponent {
El filtro, al mismo tiempo, se ve así:
const focusOutFilter = ({relatedTarget}: FocusEvent) => !this.elementRef.nativeElement.contains(relatedTarget);
Conclusión
Desafortunadamente, el procesamiento incorporado de las pulsaciones de teclas compuestas en Angular aún comenzará en NgZone
, lo que significa que verificará los cambios. Si quisiéramos, no podríamos haber recurrido al procesamiento incorporado, pero la ganancia de rendimiento sería pequeña, y los huecos en la "cocina" interna de Angular podrían dañarse durante la actualización. Por lo tanto, debemos abandonar el evento compuesto o usar un filtro similar al operador de límite y simplemente no llamar al controlador donde no es relevante.
Entrar en el procesamiento de eventos internos de Angular es una tarea aventurera, ya que la implementación interna puede cambiar en el futuro. Esto nos obliga a seguir las actualizaciones, en particular, la tarea en GitHub, dada en la segunda sección del artículo. Pero ahora podemos filtrar convenientemente la ejecución de controladores y comenzar a verificar los cambios; ahora tenemos la oportunidad de aplicar convenientemente los métodos preventDefault
y stopPropagation
típicos del procesamiento de eventos justo al declarar una suscripción. Desde el futuro, sería más conveniente declarar filtros para @HostListener
justo al lado de ellos usando decoradores. En el próximo artículo, planeo hablar sobre varios decoradores que creamos en casa e intentar implementar esta solución.