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

   // ...
 }

Заключение

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

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

Что еще почитать по теме

T-Rex 30 марта 2021

Что такое SMTP-протокол и как он устроен?

SMTP (Simple Mail Transfer Protocol) — протокол передачи почты. Он был представлен еще в 1982 году, но не теряет актуальности до сих пор. В статье разбираемся, какие задачи решает протокол и как он ра…
T-Rex 30 марта 2021
Владимир Туров 1 сентября 2020

Дело совершенно секретного iPod

Это был обычный серый день в конце 2005 года. Я сидел на рабочем месте и писал код для следующей версии iPod. Вдруг без стука ворвался директор ПО для iPod, начальник моего начальника, и закрыл дверь.
Владимир Туров 1 сентября 2020
T-Rex 21 августа 2020

TrendForce: цены на SSD упадут

Эксперты DRAMeXchange предсказывают значительное падение цен на оперативную память и твердотельные накопители в ближайшее время. Причина — сокращение спроса на чипы для NAND и DRAM.
T-Rex 21 августа 2020

Новое в блоге

Михаил Фомин 24 июня 2022

Docker Swarm VS Kubernetes — как бизнес выбирает оркестраторы

Рассказываем, для каких задач бизнесу больше подойдет Docker Swarm, а когда следует выбрать Kubernetes.
Михаил Фомин 24 июня 2022
Ульяна Малышева 30 сентября 2022

«Нулевой» локальный диск. Как мы запустили облако только с сетевыми дисками и приручили Ceph

Чем хороши сетевые диски и почему именно Ceph, рассказал директор по развитию ядра облачной платформы Иван Романько.
Ульяна Малышева 30 сентября 2022
Валентин Тимофеев 30 сентября 2022

Как проходит онбординг сотрудников ИТО? Что нужно, чтобы выйти на смену в дата-центр

Рассказываем, как обучаем новых сотрудников, какие задачи и испытания проходят инженеры прежде, чем выйти на свою первую смену.
Валентин Тимофеев 30 сентября 2022
T-Rex 28 сентября 2022

Книги по SQL: что почитать новичкам и специалистам

Собрали 6 книг, которые помогут на старте изучения SQL и при углублении в тему.
T-Rex 28 сентября 2022