company_banner

Отложенное применение функционала директив в Angular

Original author: Netanel Basal
  • Translation
Недавно мне надо было решить задачу по смене старого механизма для вывода всплывающих подсказок, реализованного средствами нашей библиотеки компонентов, на новый. Я, как всегда, решил не заниматься изобретением велосипеда. Для того чтобы приступить к решению этой задачи, я занялся поисками опенсорсной библиотеки, написанной на чистом JavaScript, которую можно было бы поместить в директиву Angular и в таком виде использовать.



В моём случае, так как я много работаю с popper.js, я нашёл библиотеку tippy.js, написанную тем же разработчиком. Для меня такая библиотека выглядела как идеальное решение задачи. Библиотека tippy.js обладает обширным набором возможностей. С её помощью можно создавать и всплывающие подсказки (элементы tooltip), и многие другие элементы. Эти элементы можно настраивать с помощью тем, они быстры, строго типизированы, обеспечивают доступность контента и отличаются многими другими полезными возможностями.

Я начал работу с создания директивы-обёртки для tippy.js:

@Directive({ selector: '[tooltip]' })
export class TooltipDirective {
  private instance: Instance;
  private _content: string;

  get content() {
    return this._content;
  }

  @Input('tooltip') set content(content: string) {
    this._content = content;
    if (this.instance) this.instance.setContent(content);
  }

  constructor(private host: ElementRef<Element>, private zone: NgZone) {}

  ngAfterViewInit() {
    this.zone.runOutsideAngular(() => {
      this.instance = tippy(this.host.nativeElement, {
        content: this.content,
      });
    });
}

Всплывающую подсказку создают, вызывая функцию tippy и передавая ей элементы host и content. Кроме того, мы вызываем tippy за пределами Angular Zone, так как нам не нужно, чтобы события, регистрируемые tippy, приводили бы к запуску цикла обнаружения изменений.

Теперь воспользуемся всплывающей подсказкой в большом списке из 700 элементов:

@Component({
  selector: 'my-app',
  template: `
    <ul>
      <li *ngFor="let item of data" [tooltip]="item.label">
         {{ item.label }}
      </li>
    </ul>
  `
})
export class AppComponent {
  data = Array.from({ length: 700 }, (_, i) => ({
    id: i,
    label: `Value ${i}`,
  }));
}

Всё работает так, как ожидается. Каждый элемент выводит всплывающую подсказку. Но мы можем решить эту задачу лучше. В нашем случае создано 700 экземпляров tippy. А для каждого элемента средствами tippy.js было добавлено 4 прослушивателя событий. Это означает, что мы зарегистрировали 2800 прослушивателей (700*4).

Для того чтобы увидеть это своими глазами, можно воспользоваться методом getEventListeners в консоли инструментов разработчика Chrome. Конструкция вида getEventListeners(element) возвращает сведения о прослушивателях событий, зарегистрированных для заданного элемента.


Сводные данные обо всех прослушивателях событий

Если оставить код в таком виде, это может подействовать на потребление памяти приложением и на время его первого рендеринга. Особенно это касается вывода страницы на мобильных устройствах. Поразмыслим над этим. Нужно ли создавать экземпляры tippy для элементов, которые не выводятся в области просмотра? Нет, не нужно.

Воспользуемся API IntersectionObserver для того чтобы отложить включение поддержки всплывающих подсказок до момента появления элемента на экране. Если вы не знакомы с API IntersectionObserver — взгляните на документацию

Создадим для IntersectionObserver обёртку, представленную наблюдаемым объектом:

const hasSupport = 'IntersectionObserver' in window;

export function inView(
  element: Element,
  options: IntersectionObserverInit = {
    root: null,
    threshold: 0.5,
  }
) {
  return new Observable((subscriber) => {
    if (!hasSupport) {
      subscriber.next(true);
      subscriber.complete();
    }

    const observer = new IntersectionObserver(([entry]) => {
      subscriber.next(entry.isIntersecting);
    }, options);

    observer.observe(element);

    return () => observer.disconnect();
  });
}

Мы создали наблюдаемый объект, который сообщает подписчикам о моменте пересечения элемента с заданной областью. Кроме того, тут мы проверяем поддержку IntersectionObserver браузером. Если браузер не поддерживает IntersectionObserver — мы просто выдаём true и завершаем работу. Пользователи IE сами виноваты в своих страданиях.

Теперь наблюдаемый объект inView мы можем использовать в директиве, реализующей функционал всплывающей подсказки:

@Directive({ selector: '[tooltip]' })
export class TooltipDirective {
  ...

  ngAfterViewInit() {
    // Не забудьте отписаться
    inView(this.host.nativeElement).subscribe((inView) => {
      if (inView && !this.instance) {
        this.zone.runOutsideAngular(() => {
          this.instance = tippy(this.host.nativeElement, {
            content: this.content,
          });
        });
      } else if (this.instance) {
        this.instance.destroy();
        this.instance = null;
      }
    });
  }
}

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


Сводные данные обо всех прослушивателях событий после доработки проекта

Отлично. Теперь мы создаём всплывающие подсказки только для видимых элементов.

Поищем ответы на пару вопросов, связанных с новым решением.

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

А как насчёт делегирования событий? Для этого нужно самостоятельно реализовывать дополнительные механизмы, в Angular нет универсального способа решения этой задачи.

Итоги


Здесь мы поговорили о том, как откладывать применение функционала директив. Это позволяет приложению быстрее загружаться и потреблять меньше памяти. Пример со всплывающей подсказкой — это лишь один из многих случаев, в которых может применяться подобная техника. Уверен, вы найдёте немало способов её использования в собственных проектах.

А как вы решили бы задачу по выводу большого списка элементов, каждый из которых нужно оснастить всплывающей подсказкой?

Напоминаем, что у нас продолжается конкурс прогнозов, в котором можно выиграть новенький iPhone. Еще есть время ворваться в него, и сделать максимально точный прогноз по злободневным величинам.

RUVDS.com
RUVDS – хостинг VDS/VPS серверов

Comments 12

    +1
    Есть такой приём, называется event delegation. Он использовался еще во времена jQuery. Позволяет избежать лишних обработчиков событий, а также сложностей с тем, чтобы эти обработчики навешивать и убирать. Один обработчик сразу на все несколько тысяч элементов.
      +1
      да и в ангуляре тоже можно, вот пример stackblitz.com/edit/angular-ivy-ctk18j

      А как насчёт делегирования событий? Для этого нужно самостоятельно реализовывать дополнительные механизмы, в Angular нет универсального способа решения этой задачи.


      Видимо речь о том, что в Ангуляре нельзя из event.target получить экземпляр компонента, в котором произошло событие. В моем примере для этого используется data-аттрибут, который сохраняет индекс компонента, и по этому индексу забирается инстанс из QueryList. И нельзя сказать что это «универсальный способ».
        +1
        Хммм… Фреймворки точно делают чтобы было проще писать приложения?
          +1
          вопрос в сложности приложения.
          Фреймворк позволяет прибегать к однообразным паттернам и удобно разделять функционал.

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

          Ну и нельзя отрицать что конкретно этот кейс не очень хорошо ложится на ангуляр и ts.
          Если бы, допустим, забить на типизацию, можно было бы сделать ссылку на инстанс полем хтмл-элемента и получать из таргета напрямую.

          А еще я могу оформить все это дело в виде либы с директивой, и в живом коде (там, где логика) все это будет спрятано под короткую запись в шаблоне.
            0
            Я скорее к тому, что если фреймворк для решения довольно простой задачи вынуждает использовать намного более сложный путь с кучей оверинжениринга (а навешивание и удаление хэндлеров событий при помощи intersection observer вместо того, чтобы сделать делегирование выглядит крайним овериженирингом), то что-то идет не так.
              0
              а как делегировать mouseenter? it doesn't bubble.
              Можно было конечно через mouseover сделать, не факт что оно работает с tippy.
              Но честно говоря не вижу разницы. Директива пишется один раз и потом можно забыть как оно устроено.
                0
                Я скорее к тому, что если фреймворк для решения довольно простой задачи

                Это не задача — это решение. И если это решение плохо реализуется во фреймворке — значит, просто, это решение считается плохим.

        0

        Решаю похожую задачу, есть подозрение, что вешать на каждый элемент свой Intersector — не очень хорошая затея.


        Вероятно, его нужно вытащить в единый сервис, а не применять на каждом элементе функцию inView()
        Кроме того, не пробрасывая root-контейнер, тоже можно как-то повлиять на производительность, не зря ведь в options к созданию обсервера его можно задавать.


        Есть у кого точная информация на этот счёт? Можно ли использовать решение в лоб, как описано в статье?

          0
          Тоже ниже вопрос по производительности поднял. Списался и c автором оригинальной статьи, в итоге добавил в свою библиотеку поддержку одного обзёрвера на много элементов, так что можно подключать и юзать :)
          github.com/ng-web-apis/intersection-observer
          +1
          Пользуясь случаем чуток прорекламирую наш опенсорс. Мы пилим легковесные обёртки над нативным API под Angular и у нас есть как раз Intersection Observer, обёрнутый в Observable сервис:
          github.com/ng-web-apis/intersection-observer

          Отлично подойдёт для подобного кейса. Интересно ещё, насколько выгоднее иметь 700 обзёрверов против 700 ивент листенеров. Можно было бы эту тему оптимизировать, ведь параметры одинаковые — можно в одном обзёрвере отслеживать все 700 элементов и эмитить наружу какие элементы сейчас видимы. Ну и сделать из этого сервис, доступный всем элементам.
            0
            Скажите пожалуйста, вы рассматривали создание экземпляра tippy при наведение на элемент на котором висит директива? Ведь тогда даже для видимых элементов не нужно было бы создавать экземпляры, но могут быть проблемы с задержкой при инициализации(тултип появляется не сразу).
              0
              А где отписка от inView(this.host.nativeElement).subscribe (в ngOnDestry)?

              Only users with full accounts can post comments. Log in, please.