📜 ⬆️ ⬇️

Angular: event handling optimization



It was literally a couple of weeks, as I first started writing on Angular , and immediately ran into a number of specific problems. Because of 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 similar approaches, but they had to draw conclusions on the profiler and look for information in parts. At the same time, it must be said that the solution was found very quickly, when the causes of the problems became clear.

In the article I will explain how to optimize the handling of frequently triggered events: mousemove, scroll, dragover, and others. Specifically, I ran into problems with the implementation of the drag-and-drop interface, so I’ll go through 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 the work of Angular - Demo application with optimization attempts .
')

Solvable problem


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

The number of cells and the number of items that can be dragged reach 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 asterisks on the githabe.

It turned out to quickly distribute a solution that worked almost correctly, but even with a relatively small number of elements, problems appeared:

Here you can see the result of this approach.

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

Code
repository example
@Component({ selector: 'app-version-1', template: ` <h1>{{title}}</h1> <table> <tbody> <tr *ngFor="let row of table"> <td *ngFor="let cell of row"> <div class="cell-content" dnd-droppable (onDropSuccess)="drop($event, cell)" (onDragEnter)="dragEnter($event, cell)" (onDragLeave)="dragLeave($event, cell)" > <span class="item" *ngFor="let item of cell" dnd-draggable [dragData]="{cell: cell, item: item}" >{{item}}</span> <span class="entered" *ngIf="cell.entered">{{cell.entered}}</span> </div> </td> </tr> </tbody> </table> `, }) 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 point in bringing such an implementation to mind, since it is almost impossible to work in this mode.

The first assumption arose that reducing the elements that are processed by the library can 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 events traced by the table can be removed; by the events, the cell element and its data can be set.

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

Code
repository example

 @Component({ selector: 'app-version-2', template: ` <h1>{{title}}</h1> <table> <tbody dnd-droppable (onDropSuccess)="drop($event)" (onDragEnter)="dragEnter($event)" (onDragLeave)="dragLeave($event)" > <tr *ngFor="let row of table"> <td *ngFor="let cell of row"> <div class="cell-content"> <span class="item" *ngFor="let item of cell" dnd-draggable [dragData]="{cell: cell, item: item}" (onDragEnd)="dragEnd($event)" >{{item}}</span> <span class="entered" *ngIf="cell.entered">{{cell.entered}}</span> </div> </td> </tr> </tbody> </table> `, }) 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 the subjective feelings and the profiler one can judge what has become better, but in general the situation has not changed. The profiler shows 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.

Assumed that the library forces Angular to subscribe to all these events and process them in this way.

Second solution


It was clear from the profiler that the root of the problem is not in my handlers, and the call enableProdMode (), although it greatly reduces the time for searching and applying changes, but the profiler shows that the main amount of resources is spent on the execution of scripts. After a number of attempts at micro-optimizations, I still decided to abandon the ng2-dnd library, and implement everything myself in order to improve control.

Code
repository example

 @Component({ selector: 'app-version-3', template: ` <h1>{{title}}</h1> <table> <tbody (dragenter)="dragEnter($event)" (dragleave)="dragLeave($event)" (dragover)="dragOver($event)" (drop)="drop($event)" > <tr *ngFor="let row of table"> <td *ngFor="let cell of row"> <div class="cell-content"> <span class="item" *ngFor="let item of cell" draggable="true" (dragstart)="dragStart($event, {cell: cell, item: item})" (dragend)="dragEnd()" >{{item}}</span> <span class="entered" *ngIf="cell.entered">{{cell.entered}}</span> </div> </td> </tr> </tbody> </table> `, }) 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 situation in terms of performance has improved significantly, and in production mode, the drag processing speed has become close to acceptable.

As for the profiler, it was still clear that a lot of computing resources are being spent on executing scripts, and these calculations have nothing to do with my code.

Then I began to realize that I am responsible for this Zone.js, which lies at the heart of Angular. This is clearly indicated by the methods that can be observed in the profiler. In the file polyfills.ts, I saw that there is an opportunity to disable the standard framework handler for some events. And since the dragover event is most often caused by dragging and dropping it onto the blacklist, it gave a practically 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 

It was possible to stop at this, but after a small search on the Internet, a solution was found that would not change the standard behavior.

The third solution


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

Step 1


When the 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 perform only the role of a container.

Such decomposition allowed us to limit the amount of data in which the search for changes will occur, and significantly simplified the code, while giving more control.

Code after refactoring
repository example

 @Component({ selector: 'app-version-4-cell', template: ` <span class="item" *ngFor="let item of cell" draggable="true" (dragstart)="dragStart($event, item)" (dragend)="dragEnd($event)" >{{item}}</span> <span class="entered" *ngIf="cell.entered">{{cell.entered}}</span> `, }) 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: ` <h1>{{title}}</h1> <table> <tbody> <tr *ngFor="let row of table"> <td *ngFor="let cell of row"> <app-version-4-cell class="cell-content" [cell]="cell"></app-version-4-cell> </td> </tr> </tbody> </table> `, }) export class Version4Component extends VersionBase { public static readonly title = ' '; } 

Step 2


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

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

On Stack Overflow, a solution was found, like using a standard EventManager override , you can force events with a specific parameter to run outside the framework context. This approach allows point to control the processing of events in specific locations.

From the advantages it can be noted that explicitly indicating where events will run outside the context of the framework, there will be no surprises for developers who are not familiar with this code, unlike 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 will cause the browser to immediately display them.

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

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

 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 prompt the framework where the changes occurred at the right moment.

More information about the mechanism for detecting changes can be found in this article . I can only say that you can explicitly manage the search for changes using the ChangeDetectorRef . Through DI, it connects to the required component, and as soon as it becomes aware of the changes that were made while executing code outside the Angular context, it is necessary to start a search for changes in a specific component.

Final option


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

repository 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 clear code, with precise control for changes.



According to profiler, it is clear that resources are practically not spent on script execution. And also this approach does not change the standard behavior of the framework or component, except for specific cases that are explicitly indicated in the code.

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


All Articles