Angular: оптимизация обработки событий
Прошла буквально пара недель, как я впервые начал писать на Angular, и сразу же столкнулся с рядом специфических проблем. Из-за малого опыта работы с этим фреймворком, я не уверен, что примененные способы оптимизации — это не стандартная практика. Некоторые признаки указывают, что разработчики предполагают подобные подходы, но делать выводы пришлось по профайлеру, а информацию искать по частям. При этом надо сказать, что решение нашлось очень быстро, когда причины проблем прояснились.
В статье я разберу как оптимизировать обработку часто вызываемых событий: mousemove, scroll, dragover и прочих. Конкретно я столкнулся с проблемами при реализации drag-and-drop интерфейса, поэтому и разбирать буду на примере с перетаскиванием элементов.
Хочу изложить свой ход мыслей на примере нескольких попыток оптимизации, и немного опишу базовые принципы работы Angular — Демонстрационное приложение с попытками оптимизации.
Решаемая задача
В приложении необходимо было сделать интерфейс, управляемый перетаскиванием элементов между ячейками таблицы.
Количество ячеек и количество элементов, которые можно перетаскивать, достигают нескольких тысяч.
Первый вариант решения
Первым делом я направился искать готовые решения, реализующие drag-and-drop, выбор пал на ng2-dnd, так у данной библиотеки понятное и простое API, и присутствует некоторая популярность в виде звездочек на гитхабе.
Получилось быстро накидать решение, которое работало почти правильно, но даже при относительно небольшом количестве элементов появились проблемы:
- вычисления потребляли все доступные мощности;
- результат отображался с большой задержкой.
Здесь можно посмотреть результат данного подхода.
Примечание: ниже приведен код компонента, с примером решения задачи с минимальной затратой времени на реализацию. По ходу статьи будет приведено еще несколько примеров кода. Все компоненты имеют общую часть, которая формирует таблицу. Данный код вынесен из компонентов, так как к оптимизации обработки событий он не имеет никакого отношения. Подробнее со всем кодом проекта можно ознакомиться в репозитории.
@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;
}
}
Доработки
Доводить до ума подобную реализацию смысла никакого не было, так как работать в таком режиме практически невозможно.
Возникло первое предположение, что уменьшение элементов, которые обрабатываются библиотекой, может существенно улучшить ситуацию. От большого количества draggable элементов избавиться невозможно в рамках задачи, а вот droppable ячейки можно убрать и отслеживать события, которые получает таблица, по событиям можно установить элемент ячейки и ее данные.
Данный подход предполагает взаимодействие с HTML элементами и нативными событиями, что не хорошо в контексте фреймворка, но я посчитал это приемлемым в целях оптимизации.
@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();
}
}
По субъективным ощущениям и по профайлеру можно судить, что стало лучше, но в целом ситуация не поменялась. В профайлере видно, что фреймворк запускает большое количество обработчиков событий, для поиска изменений в данных, и на тот момент мне не совсем была понятна природа этих вызовов.
Предположил, что библиотека заставляет Angular подписаться на все эти события и обрабатывать их таким образом.
Второе решение
По профайлеру было видно, что корень проблемы не в моих обработчиках, а вызов enableProdMode (), хоть и сильно сокращает время поиска и применения изменений, но профайлер показывает, что на выполнение скриптов расходуется основное количество ресурсов. После некоторого количества попыток микрооптимизаций, я все же решил отказаться от библиотеки ng2-dnd, и реализовать все самостоятельно в целях улучшения контроля.
@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();
}
}
Ситуация в плане производительности значительно улучшилась, и в продакшн режиме скорость обработки перетаскивания стала близкой к приемлемой.
По профайлеру по-прежнему было видно, что много вычислительных ресурсов тратится на выполнение скриптов, причем эти вычисления не имеют никакого отношения к моему коду.
Тут я уже начал понимать, что ответственен за это Zone.js, который лежит в основе Angular. На это явно указывали методы, которые можно наблюдать в профайлере. В файле polyfills.ts, я увидел, что есть возможность отключить стандартный обработчик фреймворка для некоторых событий. И так как чаще всего при перетаскивании вызывается событие dragover, включение его в черный список, давало практические идеальный результат.
/**
* 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: `
{{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 = 'Декомпозированные ячейки';
}
Шаг 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';
}
// ...
}
Заключение
В итоге получаем чистый и понятный код, с точным контролем за изменениями.
По профайлеру видно, что на выполнение скриптов практически не расходуются ресурсы. А также этот подход никак не меняет стандартное поведение фреймворка или компонента, за исключением конкретных случаев, на которые в коде есть явные указания.