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