Angular: оптимизация обработки событий - Академия Selectel

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

Максим Овчарик Максим Овчарик Старший фронтед разработчик 12 апреля 2018

Оптимизация обработки часто вызываемых событий при реализации drag-and-drop интерфейса при помощи Angular на примере задачи с перетаскиванием элементов. Прошла буквально пара недель, как я впервые начал писать на 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';
   }

   // ...
 }

Заключение

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

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