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

xkutrleng4kfbj9d-dlbvywnqga.png

Прошла буквально пара недель, как я впервые начал писать на 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(); } }

Профайлер
peaumog66pvyzylmygsh4npykpq.png


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

Предположил, что библиотека заставляет 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(); } }

Профайлер
uy0dsnpnfmb6y5tilk-8iqptp9y.png

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

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

Тут я уже начал понимать, что ответственен за это 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';
   }

   // ...
 }

Заключение


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

4ecvpt46bzvpop50zym98-qsrg4.png

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

© Habrahabr.ru