Angular: event handling optimization

  • Tutorial


Just a couple of weeks passed, when I first started writing in Angular , and immediately ran into a number of specific problems. Due to the small experience with this framework, I am not sure that the applied optimization methods are not standard practice. Some signs indicate that the developers suggest such approaches, but it was necessary to draw conclusions by the profiler, and to search for information in parts. Moreover, it must be said that a solution was found very quickly when the causes of the problems became clear.

In this article, I will discuss how to optimize the processing of frequently called events: mousemove, scroll, dragover and others. Specifically, I ran into problems when implementing the drag-and-drop interface, so I’ll also analyze it with the example of dragging and dropping elements.

I want to present my train of thought on the example of several attempts at optimization, and I will briefly describe the basic principles of Angular - A demo application with optimization attempts .

Problem to be Solved


In the application, it was necessary to make an interface controlled by dragging and dropping elements between table cells.

The number of cells and the number of elements that can be dragged reaches several thousand.

The first solution


First of all, I went to look for ready-made solutions that implement drag-and-drop, the choice fell on ng2-dnd , so this library has a clear and simple API, and there is some popularity in the form of stars on the github.

It turned out to quickly throw a solution that worked almost correctly, but even with a relatively small number of elements, problems appeared:
  • calculations consumed all available power;
  • the result was displayed with a long delay.

Here you can see the result of this approach.

Note: the code of the component is given below, with an example of solving the problem with a minimum investment of time. As the article progresses, several more code examples will be given. All components have a common part that forms a table. This code is taken out of the components, since it has nothing to do with the optimization of event processing. More details about the entire project code can be found in the repository .

The code
repository , an example
@Component({
  selector: 'app-version-1',
  template: `
    

{{title}}

{{item}}{{cell.entered}}
`, }) export class Version1Component extends VersionBase { public static readonly title = 'Наивная реализация'; // Курсор с данными был наведен на ячейку public dragEnter({ dragData }, cell: Cell) { cell.entered = dragData.item; } // Курсор с данными покинул ячейку public dragLeave({ dragData }, cell: Cell) { delete cell.entered; } // В ячейку положили данные public drop({ dragData }, cell: Cell) { const index = dragData.cell.indexOf(dragData.item); dragData.cell.splice(index, 1); cell.push(dragData.item); delete cell.entered; } }


Improvements


There was no sense in bringing such an implementation to mind, since working in this mode is almost impossible.

The first assumption arose that reducing the elements that are processed by the library could significantly improve the situation. It is impossible to get rid of a large number of draggable elements within the framework of the task, but droppable cells can be removed and the events that the table receives can be tracked, events can be used to set the cell element and its data.

This approach involves interacting with HTML elements and native events, which is not good in the context of the framework, but I found this acceptable for optimization purposes.

The code
repository , an example

@Component({
  selector: 'app-version-2',
  template: `
    

{{title}}

{{item}}{{cell.entered}}
`, }) export class Version2Component extends VersionBase { public static readonly title = 'Один droppable элемент'; // Ячейка над которой находится курсор с данными private enteredCell: Cell; // Поиск элемента на котором сработало событие private getTargetElement(target: EventTarget): Element { return (target instanceof Element) ? target : (target instanceof Text) ? target.parentElement : null; } // Поиск данных ячейки по элементу private getCell(element: Element): Cell { if (!element) { return null; } const td = element.closest('td'); const tr = element.closest('tr'); const body = element.closest('tbody'); const row = body ? Array.from(body.children).indexOf(tr) : -1; const col = tr ? Array.from(tr.children).indexOf(td) : -1; return (row >= 0 && col >= 0) ? this.table[row][col] : null; } // Сброс состояния активной ячейки private clearEnteredCell() { if (this.enteredCell) { delete this.enteredCell.entered; delete this.enteredCell; } } // Курсор с данными был наведен на элемент таблицы public dragEnter({ dragData, mouseEvent }: { dragData: any, mouseEvent: DragEvent }) { this.clearEnteredCell(); const element = this.getTargetElement(mouseEvent.target); const cell = this.getCell(element); if (cell) { cell.entered = dragData.item; this.enteredCell = cell; } } // Курсор с данными покинул элемент таблицы public dragLeave({ dragData, mouseEvent }: { dragData: any, mouseEvent: DragEvent }) { const element = this.getTargetElement(mouseEvent.target) if (!element || !element.closest('td')) { this.clearEnteredCell(); } } // На элемент таблицы положили данные public drop({ dragData, mouseEvent }: { dragData: any, mouseEvent: DragEvent }) { if (this.enteredCell) { const index = dragData.cell.indexOf(dragData.item); dragData.cell.splice(index, 1); this.enteredCell.push(dragData.item); } this.clearEnteredCell(); } // Перетаскивание завершено public dragEnd() { this.clearEnteredCell(); } }

Profiler


According to subjective feelings and profiler, you can judge what has become better, but in general the situation has not changed. You can see in the profiler that the framework runs a large number of event handlers to search for changes in the data, and at that time I did not quite understand the nature of these calls.

I suggested that the library forces Angular to subscribe to all these events and handle them that way.

Second solution


From the profiler, it was clear that the root of the problem was not in my handlers, but the call to enableProdMode (), although it greatly reduces the time to search and apply changes, but the profiler shows that most of the resources are spent on scripts. After a number of attempts at microoptimizations, I still decided to abandon the ng2-dnd library, and implement everything myself in order to improve control.

The code
repository , an example

@Component({
  selector: 'app-version-3',
  template: `
    

{{title}}

{{item}}{{cell.entered}}
`, }) export class Version3Component extends VersionBase { public static readonly title = 'Нативные события'; // Ячейка над которой находится курсор с данными private enteredCell: Cell; // Перетаскиваемые данные private dragData: { cell: Cell, item: string }; // Поиск элемента, над которым сработало событие private getTargetElement(target: EventTarget): Element { return (target instanceof Element) ? target : (target instanceof Text) ? target.parentElement : null; } // Поиск данных ячейки по элементу private getCell(element: Element): Cell { if (!element) { return null; } const td = element.closest('td'); const tr = element.closest('tr'); const body = element.closest('tbody'); const row = body ? Array.from(body.children).indexOf(tr) : -1; const col = tr ? Array.from(tr.children).indexOf(td) : -1; return (row >= 0 && col >= 0) ? this.table[row][col] : null; } // Сброс состояния активной ячейки private clearEnteredCell() { if (this.enteredCell) { delete this.enteredCell.entered; delete this.enteredCell; } } // Начало перетаскивания public dragStart(event: DragEvent, dragData) { this.dragData = dragData; event.dataTransfer.effectAllowed = 'all'; event.dataTransfer.setData('Text', dragData.item); } // Курсор с данными был наведен на элемент таблицы public dragEnter(event: DragEvent) { this.clearEnteredCell(); const element = this.getTargetElement(event.target); const cell = this.getCell(element); if (cell) { this.enteredCell = cell; this.enteredCell.entered = this.dragData.item; } } // Курсор с данными покинул элемент таблицы public dragLeave(event: DragEvent) { const element = this.getTargetElement(event.target); if (!element || !element.closest('td')) { this.clearEnteredCell(); } } // Курсор с данными находится над элементом таблицы public dragOver(event: DragEvent) { const element = this.getTargetElement(event.target); const cell = this.getCell(element); if (cell) { event.preventDefault(); event.dataTransfer.dropEffect = 'move'; return false; } } // На элемент таблицы положили данные public drop(event: DragEvent) { const element = this.getTargetElement(event.target); event.stopPropagation(); if (this.dragData && this.enteredCell) { const index = this.dragData.cell.indexOf(this.dragData.item); this.dragData.cell.splice(index, 1); this.enteredCell.push(this.dragData.item); } this.dragEnd(); return false; } // Перетаскивание завершено public dragEnd() { delete this.dragData; this.clearEnteredCell(); } }

Profiler


The performance situation has improved significantly, and in production mode the drag and drop processing speed has become close to acceptable.

According to the profiler, it was still visible that a lot of computing resources were spent on running scripts, and these calculations had nothing to do with my code.

Then I began to realize that I am responsible for this Zone.js, which is the basis of Angular. This was clearly indicated by the methods that can be observed in the profiler. In the polyfills.ts file, I saw that it was possible to disable the standard framework handler for some events. And since the dragover event is most often triggered by dragging and dropping it into the blacklist, it gives a practical, perfect result.

/**
 * By default, zone.js will patch all possible macroTask and DomEvents
 * user can disable parts of macroTask/DomEvents patch by setting following flags
 */
 // (window as any).__Zone_disable_requestAnimationFrame = true; // disable patch requestAnimationFrame
 // (window as any).__Zone_disable_on_property = true; // disable patch onProperty such as onclick
(window as any).__zone_symbol__BLACK_LISTED_EVENTS = ['dragover']; // disable patch specified eventNames

One could stop at this, but after a short search on the Internet a solution was found that would not change the standard behavior.

Third solution


In the project, each cell in me was a separate component, in the previous examples I did not do this, so as not to complicate the code.

Step 1


When a solution was found, I first returned to the original logic, where each component of the cell was responsible only for its content, and the table in this version began to fulfill only the role of a container.

Such a decomposition allowed to severely limit the amount of data in which the search for changes will take place, and greatly simplified the code, while giving more control.

Code after refactoring
repository , an example

@Component({
  selector: 'app-version-4-cell',
  template: `
    {{item}}{{cell.entered}}
  `,
})
export class Version4CellComponent {
  @Input() public cell: Cell;
  private enteredElements: any = [];
  constructor(
    private element: ElementRef,
    private dndStorage: DndStorageService,
  ) {}
  // Начало перетаскивания
  public dragStart(event: DragEvent, item: string) {
    this.dndStorage.set(this.cell, item);
    event.dataTransfer.effectAllowed = 'all';
    event.dataTransfer.setData('Text', item);
  }
  // Курсор с данными был наведен на элемент таблицы
  @HostListener('dragenter', ['$event'])
  private dragEnter(event: DragEvent) {
    this.enteredElements.push(event.target);
    if (this.cell !== this.dndStorage.cell) {
      this.cell.entered = this.dndStorage.item;
    }
  }
  // Курсор с данными покинул элемент таблицы
  @HostListener('dragleave', ['$event'])
  private dragLeave(event: DragEvent) {
    this.enteredElements = this.enteredElements.filter(x => x != event.target);
    if (!this.enteredElements.length) {
      delete this.cell.entered;
    }
  }
  // Курсор с данными находится над элементом таблицы
  @HostListener('dragover', ['$event'])
  private dragOver(event: DragEvent) {
    event.preventDefault();
    event.dataTransfer.dropEffect = this.cell.entered ? 'move' : 'none';
    return false;
  }
  // На элемент таблицы положили данные
  @HostListener('drop', ['$event'])
  private drop(event: DragEvent) {
    event.stopPropagation();
    this.cell.push(this.dndStorage.item);
    this.dndStorage.dropped();
    delete this.cell.entered;
    return false;
  }
  // Перетаскивание завершено
  public dragEnd(event: DragEvent) {
    if (this.dndStorage.isDropped) {
      const index = this.cell.indexOf(this.dndStorage.item);
      this.cell.splice(index, 1);
    }
    this.dndStorage.reset();
  }
}
@Component({
  selector: 'app-version-4',
  template: `
    

{{title}}

`, }) export class Version4Component extends VersionBase { public static readonly title = 'Декомпозированные ячейки'; }

Step 2


From the comment in the polyfills.js file, it follows that Zone.js, by default, takes control of all DOM events and various tasks such as handling setTimeout.

This allows Angular to launch the change search engine in a timely manner, and users of the framework do not have to think about the context of code execution.

At Stack Overflow, a solution was found how, by overriding the standard EventManager , you can force events with a specific parameter to be executed outside the context of the framework. This approach allows you to precisely control the processing of events in specific places.

Of the pluses, it can be noted that explicitly indicating where events will be executed outside the framework context, there will be no surprises for developers who are not familiar with this code, in contrast to the approach to including events in the blacklist.

import { Injectable, Inject, NgZone } from '@angular/core';
import { EVENT_MANAGER_PLUGINS, EventManager } from '@angular/platform-browser';
@Injectable()
export class OutZoneEventManager extends EventManager {
  constructor(
    @Inject(EVENT_MANAGER_PLUGINS) plugins: any[],
    private zone: NgZone
  ) {
    super(plugins, zone);
  }
  addEventListener(element: HTMLElement, eventName: string, handler: Function): Function {
    // Поиск флага в названии события
    if(eventName.endsWith('out-zone')) {
      eventName = eventName.split('.')[0];
      // Обработчик события будет выполняться вне контекста Angular
      return this.zone.runOutsideAngular(() => {
        return super.addEventListener(element, eventName, handler);
      });
    }
    // Поведение по умолчанию
    return super.addEventListener(element, eventName, handler);
  }
}

Step 3


Another point is that making changes to the DOM provokes the browser to immediately display them.

The rendering of one frame takes some time; the rendering of the next one can only be started after the previous one is completed. In order to find out when the browser is ready to render the next frame, requestAnimationFrame exists .

In our case, there is no need to make changes more often than the browser can display them, so for synchronization I wrote a small service.

import { Observable } from 'rxjs/Observable';
import { animationFrame } from 'rxjs/scheduler/animationFrame.js';
import { Injectable } from '@angular/core';
@Injectable()
export class BeforeRenderService {
  private tasks: Array<() => void> = [];
  private running: boolean = false;
  constructor() {}
  public addTask(task: () => void) {
    this.tasks.push(task);
    this.run();
  }
  private run() {
    if (this.running) { return; }
    this.running = true;
    animationFrame.schedule(() => {
      this.tasks.forEach(x => x());
      this.tasks.length = 0;
      this.running = false;
    });
  }
}

Step 4


Now it remains only to tell the framework where the changes occurred at the right time.

More details on the change detection mechanism can be found in this article . I can only say that you can explicitly control the search for changes using ChangeDetectorRef . Through DI, it connects to the required component, and as soon as it becomes known about the changes that were made when the code was executed outside the Angular context, you need to start the search for changes in the specific component.

Final option


We make a couple of changes to the component code: we replace dragenter, dragleave, dragover events with the ones with .out-zone at the end of the name, and in the handlers of these events we explicitly indicate the framework for any changes in the data.

repository , an example

-export class Version4CellComponent {
+export class Version5CellComponent {
   @Input() public cell: Cell;
   constructor(
     private element: ElementRef,
     private dndStorage: DndStorageService,
+    private changeDetector: ChangeDetectorRef,
+    private beforeRender: BeforeRenderService,
   ) {}
   // ...
   // Курсор с данными был наведен на элемент таблицы
-  @HostListener('dragenter', ['$event'])
+  @HostListener('dragenter.out-zone', ['$event'])
   private dragEnter(event: DragEvent) {
     this.enteredElements.push(event.target);
     if (this.cell !== this.dndStorage.cell) {
       this.cell.entered = this.dndStorage.item;
+      this.beforeRender.addTask(() => this.changeDetector.detectChanges());
     }
   }
   // Курсор с данными покинул элемент таблицы
-  @HostListener('dragleave', ['$event'])
+  @HostListener('dragleave.out-zone', ['$event'])
   private dragLeave(event: DragEvent) {
     this.enteredElements = this.enteredElements.filter(x => x != event.target);
     if (!this.enteredElements.length) {
       delete this.cell.entered;
+      this.beforeRender.addTask(() => this.changeDetector.detectChanges());
     }
   }
   // Курсор с данными находится над элементом таблицы
-  @HostListener('dragover', ['$event'])
+  @HostListener('dragover.out-zone', ['$event'])
   private dragOver(event: DragEvent) {
     event.preventDefault();
     event.dataTransfer.dropEffect = this.cell.entered ? 'move' : 'none';
   }
   // ...
 }

Conclusion


As a result, we get a clean and understandable code, with precise change control.



According to the profiler, it is almost impossible to use resources to execute scripts. And also this approach does not change the standard behavior of the framework or component in any way, except for specific cases for which there are explicit indications in the code.

Also popular now: