Хабрахабр

Angular: оптимизация обработки событий

Из-за малого опыта работы с этим фреймворком, я не уверен, что примененные способы оптимизации — это не стандартная практика. Прошла буквально пара недель, как я впервые начал писать на Angular, и сразу же столкнулся с рядом специфических проблем. При этом надо сказать, что решение нашлось очень быстро, когда причины проблем прояснились. Некоторые признаки указывают, что разработчики предполагают подобные подходы, но делать выводы пришлось по профайлеру, а информацию искать по частям.

Конкретно я столкнулся с проблемами при реализации drag-and-drop интерфейса, поэтому и разбирать буду на примере с перетаскиванием элементов.
Хочу изложить свой ход мыслей на примере нескольких попыток оптимизации, и немного опишу базовые принципы работы Angular — Демонстрационное приложение с попытками оптимизации. В статье я разберу как оптимизировать обработку часто вызываемых событий: mousemove, scroll, dragover и прочих.

Решаемая задача

В приложении необходимо было сделать интерфейс, управляемый перетаскиванием элементов между ячейками таблицы.

Количество ячеек и количество элементов, которые можно перетаскивать, достигают нескольких тысяч.

Первый вариант решения

Первым делом я направился искать готовые решения, реализующие drag-and-drop, выбор пал на ng2-dnd, так у данной библиотеки понятное и простое API, и присутствует некоторая популярность в виде звездочек на гитхабе.

Получилось быстро накидать решение, которое работало почти правильно, но даже при относительно небольшом количестве элементов появились проблемы:

  • вычисления потребляли все доступные мощности;
  • результат отображался с большой задержкой.

Здесь можно посмотреть результат данного подхода.

По ходу статьи будет приведено еще несколько примеров кода. Примечание: ниже приведен код компонента, с примером решения задачи с минимальной затратой времени на реализацию. Данный код вынесен из компонентов, так как к оптимизации обработки событий он не имеет никакого отношения. Все компоненты имеют общую часть, которая формирует таблицу. Подробнее со всем кодом проекта можно ознакомиться в репозитории.

Код

репозиторий, пример

@Component(}</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; }
}

Доработки

Доводить до ума подобную реализацию смысла никакого не было, так как работать в таком режиме практически невозможно.

От большого количества draggable элементов избавиться невозможно в рамках задачи, а вот droppable ячейки можно убрать и отслеживать события, которые получает таблица, по событиям можно установить элемент ячейки и ее данные. Возникло первое предположение, что уменьшение элементов, которые обрабатываются библиотекой, может существенно улучшить ситуацию.

Данный подход предполагает взаимодействие с HTML элементами и нативными событиями, что не хорошо в контексте фреймворка, но я посчитал это приемлемым в целях оптимизации.

Код

репозиторий, пример

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

Профайлер

По субъективным ощущениям и по профайлеру можно судить, что стало лучше, но в целом ситуация не поменялась. В профайлере видно, что фреймворк запускает большое количество обработчиков событий, для поиска изменений в данных, и на тот момент мне не совсем была понятна природа этих вызовов.

Предположил, что библиотека заставляет Angular подписаться на все эти события и обрабатывать их таким образом.

Второе решение

По профайлеру было видно, что корень проблемы не в моих обработчиках, а вызов enableProdMode(), хоть и сильно сокращает время поиска и применения изменений, но профайлер показывает, что на выполнение скриптов расходуется основное количество ресурсов. После некоторого количества попыток микрооптимизаций, я все же решил отказаться от библиотеки ng2-dnd, и реализовать все самостоятельно в целях улучшения контроля.

Код

репозиторий, пример

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

Профайлер

Ситуация в плане производительности значительно улучшилась, и в продакшн режиме скорость обработки перетаскивания стала близкой к приемлемой.

По профайлеру по-прежнему было видно, что много вычислительных ресурсов тратится на выполнение скриптов, причем эти вычисления не имеют никакого отношения к моему коду.

На это явно указывали методы, которые можно наблюдать в профайлере. Тут я уже начал понимать, что ответственен за это Zone.js, который лежит в основе Angular. И так как чаще всего при перетаскивании вызывается событие dragover, включение его в черный список, давало практические идеальный результат. В файле polyfills.ts, я увидел, что есть возможность отключить стандартный обработчик фреймворка для некоторых событий.

/** * 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

На этом можно было остановиться, но после небольшого поиска в интернете было найдено решение, которое не меняло бы стандартное поведение.

Третий вариант решения

В проекте у меня каждая ячейка была отдельным компонентом, в предыдущих примерах я не стал этого делать, чтобы не усложнять код.

Шаг 1

Когда решение было найдено, я сперва вернулся к первоначальной логике, где каждый компонент ячейки был ответственен только за свое содержание, а таблица в таком варианте стала выполнять только роль контейнера.

Такая декомпозиция позволила сильно ограничить количество данных, в которых будет происходить поиск изменений, и значительно упростила код, одновременно давая больше контроля.

Код после рефакторинга

репозиторий, пример

@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 = 'Декомпозированные ячейки';
}

Шаг 2

Из комментария в файле polyfills.js следует, что Zone.js по умолчанию берет на себя контроль за всеми событиями DOM и различными задачами например обработку setTimeout.

Это позволяет Angular своевременно запускать механизм поиска изменений, а пользователям фреймворка не задумываться над контекстом выполнения кода.

Данный подход позволяет, точечно контролировать обработку событий в конкретных местах. На Stack Overflow было найдено решение, как с помощью переопределения стандартного EventManager, можно заставить события с определенным параметром выполняться вне контекста фреймворка.

Из плюсов можно отметить, что явно указывая, где события будут выполняться вне контекста фреймворка, не будет неожиданностей для разработчиков, не знакомых с данным кодом, в отличие от подхода с включением событий в черный список.

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

Шаг 3

Еще один момент заключается в том, что внесение изменений в DOM, провоцируют браузер немедленно отобразить их.

Для того чтобы узнать, когда браузер будет готов к рендеру следующего фрейма, существует requestAnimationFrame. Рендр одного фрейма занимает некоторое время, рендр следующего может быть запущен только после завершения предыдущего.

В нашем случае нет потребности вносить изменения чаще, чем браузер сможет их отобразить, поэтому для синхронизации я написал небольшой сервис.

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

Шаг 4

Теперь остается только подсказать фреймворку, где произошли изменения в нужный момент.

Я лишь скажу, что явно управлять поиском изменений можно с помощью ChangeDetectorRef. Подробнее с механизмом обнаружения изменений можно ознакомиться в данной статье. Через DI он подключается к нужному компоненту, и как только становится известно об изменениях, которые вносились при выполнении кода вне контекста Angular, необходимо запустить поиск изменений в конкретном компоненте.

Итоговый вариант

Вносим в код компонента буквально пару изменений: события dragenter, dragleave, dragover заменяем на аналогичные с .out-zone в конце названия, и в обработчиках этих событий явно указываем фреймворку на наличие изменений в данных.

репозиторий, пример

-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'; } // ... }

Заключение

В итоге получаем чистый и понятный код, с точным контролем за изменениями.

А также этот подход никак не меняет стандартное поведение фреймворка или компонента, за исключением конкретных случаев, на которые в коде есть явные указания. По профайлеру видно, что на выполнение скриптов практически не расходуются ресурсы.

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть