1. Introdução
Angular fornece uma maneira declarativa conveniente de assinar eventos em um modelo usando a sintaxe (eventName)="onEventName($event)"
. Juntamente com a política de verificação de alterações ChangeDetectionStrategy.OnPush
, essa abordagem inicia automaticamente o ciclo de verificação de alterações apenas para a entrada do usuário que nos interessa. Em outras palavras, se ouvirmos o evento (input)
no elemento <input>
, a verificação de alteração não será acionada se o usuário simplesmente clicar no campo de entrada. Melhora muito
desempenho comparado à política padrão ( ChangeDetectionStrategy.Default
). Nas diretivas, também podemos assinar eventos no elemento host por meio do @HostListener('eventName')
.
Na minha prática, geralmente há casos em que o processamento de um evento específico é necessário apenas se uma condição for atendida. isto é o manipulador se parece com isso:
class ComponentWithEventHandler { // ... onEvent(event: Event) { if (!this.condition) { return; } // Handling event ... } }
Mesmo que a condição não seja atendida e nenhuma ação tenha ocorrido, o ciclo de verificação de alterações ainda será iniciado. No caso de eventos frequentes, como scroll
ou mousemove
scroll
, isso pode afetar adversamente o desempenho do aplicativo.
Na biblioteca da interface do usuário do componente em que estou trabalhando, assinar o mousemove
nos menus suspensos acionou uma recontagem de alterações na árvore inteira de componentes para cada movimento do mouse. Era necessário monitorar o mouse para implementar o comportamento correto do menu, mas claramente valia a pena otimizar. Mais sobre isso abaixo.
Esses momentos são especialmente importantes para os elementos universais da interface do usuário. Pode haver muitos deles na página, e os aplicativos podem ser muito complexos e exigentes quanto ao desempenho.
Você pode corrigir a situação assinando eventos ignorando ngZone
, por exemplo, usando Observable.fromEvent
e iniciando a verificação manual de alterações, chamando changeDetectorRef.markForCheck()
. No entanto, isso adiciona uma tonelada de trabalho extra e torna impossível o uso das convenientes ferramentas angulares incorporadas.
Não é segredo que o Angular permite que você assine os chamados pseudo-eventos, especificando exatamente em quais eventos estamos interessados. Podemos escrever (keydown.enter)="onEnter($event)"
e o manipulador (e com ele o ciclo de verificação de alterações) será chamado apenas quando a tecla Enter
for pressionada. As demais pressionadas serão ignoradas. Neste artigo, veremos como você pode usar a mesma abordagem que Angular para otimizar a manipulação de eventos. E, como bônus, adicione .stop
e .stop
, que cancelarão o comportamento padrão e impedirão que o evento .stop
automaticamente.
EventManagerPlugin
Angular usa a classe EventManager
para manipular eventos. Ele possui um conjunto de plug-ins que estendem o EventManagerPlugin
abstrato e delega o processamento da assinatura de eventos ao plug-in que suporta esse evento (por nome). Existem vários plugins dentro do Angular, incluindo o tratamento de eventos HammerJS e um plug-in responsável por eventos compostos como keydown.enter
. Esta é uma implementação interna do Angular, e essa abordagem está sujeita a alterações. No entanto, três anos se passaram desde a criação do problema no processamento desta solução e nenhum progresso foi feito nessa direção:
https://github.com/angular/angular/issues/3929
O que é interessante nisso para nós? Apesar de essas classes serem internas e não poderem ser herdadas delas, o token responsável pela implementação de dependências para plug-ins é público. Isso significa que podemos escrever nossos próprios plug-ins e estender o mecanismo interno de manipulação de eventos com eles.
Se você olhar para o código-fonte do EventManagerPlugin
, perceberá que não poderemos herdar dele, na maioria das vezes é abstrato e é fácil implementar nossa própria classe que atende aos seus requisitos:
https://github.com/angular/angular/blob/master/packages/platform-browser/src/dom/events/event_manager.ts#L92
Grosso modo, o plug-in deve ser capaz de determinar se está funcionando com este evento e deve ser capaz de adicionar um manipulador de eventos e manipuladores globais (no body
, window
e document
). Estaremos interessados nos modificadores .filter
, .prevent
e .stop
. Para vinculá-los ao nosso plug-in, implementamos os métodos necessários:
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) ); } }
Portanto, o EventManager
entenderá que os eventos nos quais existem certos modificadores devem ser passados para o nosso plug-in para processamento. Então, precisamos implementar a adição de manipuladores de eventos. Não estamos interessados em manipuladores globais; no caso deles, a necessidade de tais ferramentas é muito menos comum e a implementação seria mais complicada. Portanto, simplesmente removemos nossos modificadores do nome do evento e o devolvemos ao EventManager
para que ele EventManager
plug-in EventManager
correto para processamento:
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); } }
No caso de um evento em um elemento regular, precisamos escrever nossa própria lógica. Para fazer isso, encerramos o manipulador em um fechamento e passamos o evento sem nossos modificadores de volta ao EventManager
, chamando-o fora do ngZone
, para evitar o início do ciclo de verificação de alterações:
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 { ... } */ }
Nesta fase, temos: o nome do evento, o próprio evento e o elemento em que está sendo ouvido. O manipulador que chega aqui não é o manipulador de origem atribuído a esse evento, mas o final da cadeia de fechamento criada pelo Angular para seus próprios propósitos.
Uma solução seria adicionar um atributo ao elemento, responsável por chamar o manipulador ou não. Às vezes, para tomar uma decisão, é necessário analisar o próprio evento: se a ação padrão foi cancelada, qual elemento é a origem do evento, etc. Um atributo não é suficiente para isso, precisamos encontrar uma maneira de definir uma função de filtro que receba um evento e retorne true
ou false
. Em seguida, poderíamos descrever nosso manipulador da seguinte maneira:
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)); } };
Solução
A solução pode ser um serviço único que armazena a correspondência de elementos em pares de eventos / filtros e entidades auxiliares para definir essas correspondências. Obviamente, em um elemento, pode haver vários manipuladores para o mesmo evento, mas, como regra, pode ser o @HostListener
e um manipulador instalados nesse componente no modelo um nível mais alto. Prevemos essa situação, enquanto outros casos nos interessam pouco devido à sua especificidade.
O serviço principal é bastante simples e consiste em um mapa e alguns métodos para configurar, receber e limpar 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; } }
Assim, podemos implementar esse serviço no plug-in e receber um filtro passando o elemento e o nome do evento. Para uso em conjunto com o @HostListener
adicionamos outro pequeno serviço que @HostListener
com o componente e limparemos os filtros correspondentes quando ele for removido:
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 adicionar filtros aos elementos, você pode fazer uma diretiva semelhante:
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); } }
Se houver um serviço para filtrar eventos dentro do componente, não permitiremos que filtros sejam pendurados nele pela diretiva. No final, isso quase sempre pode ser feito simplesmente envolvendo o componente com o elemento ao qual nossa diretiva será atribuída. Para entender que um serviço já está presente nesse elemento, opcionalmente o implementaremos na diretiva:
class EventFiltersDirective { // ... constructor( @Optional() @Self() @Inject(FiltersService) private readonly filtersService: FiltersService | null, ) {} // ... }
Se este serviço estiver presente, exibiremos uma mensagem informando que a diretiva não é aplicável a ele:
class EventFiltersDirective { @Input() set eventFilters(filters: Filters) { if (this.eventFiltersService === null) { console.warn(ALREADY_APPLIED_MESSAGE); return; } this.mainService.register(this.elementRef.nativeElement, filters); }
Aplicação prática
Todo o código descrito pode ser encontrado no Stackblitz:
https://stackblitz.com/edit/angular-event-filter
Como exemplos de uso, uma select
imaginária é mostrada lá - um componente dentro de uma janela modal - e um menu de contexto no papel de seu menu suspenso. No caso do menu de contexto, se você verificar alguma implementação, verá que o comportamento é sempre o seguinte: quando você passa o mouse sobre um item, ele se concentra; quando você pressiona as setas do teclado, o foco se move pelos itens, mas se você move o mouse, o foco retorna ao elemento localizado sob o ponteiro do mouse. Parece que esse comportamento é fácil de implementar, no entanto, reações desnecessárias ao evento mousemove
podem disparar dezenas de ciclos inúteis de verificação de alterações. Ao definir como filtro uma verificação do foco do elemento target
do evento, podemos interromper esses disparos desnecessários, deixando apenas aqueles que realmente carregam o foco.

Além disso, esse componente de select
possui filtragem nas assinaturas @HostListener
. Quando você pressiona a Esc
dentro do pop-up, ela deve fechar. Isso deve acontecer apenas se esse clique não for necessário em algum componente aninhado e não tiver sido processado nele. Na select
pressionar Esc
faz com que o menu suspenso feche e o foco retorne ao próprio campo, mas se já estiver fechado, não deverá impedir que o evento seja exibido e a janela modal seja fechada mais tarde. Assim, o processamento pode ser descrito por um decorador:
@HostListener('keydown.esc.filtered.stop')
, ao @HostListener('keydown.esc.filtered.stop')
: () => this.opened
.
Como o select
é um componente com vários elementos que podem ser focalizados, é possível rastrear seu foco geral por focusout
eventos pop-up do focusout
. Eles ocorrerão com todas as alterações de foco, incluindo aquelas que não saem dos limites do componente. Este evento possui um campo relatedTarget
que relatedTarget
onde o foco se move. Após analisá-lo, podemos entender se é necessário chamar um análogo do evento de blur
para nosso componente:
class SelectComponent {
O filtro, ao mesmo tempo, fica assim:
const focusOutFilter = ({relatedTarget}: FocusEvent) => !this.elementRef.nativeElement.contains(relatedTarget);
Conclusão
Infelizmente, o processamento NgZone
de pressionamentos de tecla compostos no Angular ainda será iniciado no NgZone
, o que significa que ele procurará por alterações. Se quiséssemos, não poderíamos ter recorrido ao processamento interno, mas o ganho de desempenho seria pequeno e os recessos na “cozinha” interna da Angular poderiam ser danificados durante a atualização. Portanto, devemos abandonar o evento composto ou usar um filtro semelhante ao operador de limite e simplesmente não chamar o manipulador onde ele não é relevante.
A entrada no processamento interno de eventos da Angular é uma tarefa aventureira, pois a implementação interna pode mudar no futuro. Isso nos obriga a seguir as atualizações, em particular, a tarefa no GitHub, apresentada na segunda seção do artigo. Mas agora podemos filtrar convenientemente a execução de manipuladores e iniciar a verificação de alterações; agora temos a oportunidade de aplicar convenientemente os métodos preventDefault
e stopPropagation
típicos do processamento de eventos ao declarar uma assinatura. No futuro, seria mais conveniente declarar filtros para @HostListener
ao lado deles usando decoradores. No próximo artigo, pretendo falar sobre vários decoradores que criamos em casa e tentar implementar essa solução.