
В моём случае, так как я много работаю с 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. Еще есть время ворваться в него, и сделать максимально точный прогноз по злободневным величинам.

barba
Есть такой приём, называется event delegation. Он использовался еще во времена jQuery. Позволяет избежать лишних обработчиков событий, а также сложностей с тем, чтобы эти обработчики навешивать и убирать. Один обработчик сразу на все несколько тысяч элементов.
Xuxicheta
да и в ангуляре тоже можно, вот пример stackblitz.com/edit/angular-ivy-ctk18j
Видимо речь о том, что в Ангуляре нельзя из event.target получить экземпляр компонента, в котором произошло событие. В моем примере для этого используется data-аттрибут, который сохраняет индекс компонента, и по этому индексу забирается инстанс из QueryList. И нельзя сказать что это «универсальный способ».
barba
Хммм… Фреймворки точно делают чтобы было проще писать приложения?
Xuxicheta
вопрос в сложности приложения.
Фреймворк позволяет прибегать к однообразным паттернам и удобно разделять функционал.
Эта задача, если ее представить в независимом, рафинированном виде при помощи фреймворка решается сильно громоздко, но в составе сложного приложения такой код будет уже предпочтительней js-лапше на эвентах.
Ну и нельзя отрицать что конкретно этот кейс не очень хорошо ложится на ангуляр и ts.
Если бы, допустим, забить на типизацию, можно было бы сделать ссылку на инстанс полем хтмл-элемента и получать из таргета напрямую.
А еще я могу оформить все это дело в виде либы, и в живом коде (там, где логика) все это будет спрятано под короткую запись в шаблоне.
barba
Я скорее к тому, что если фреймворк для решения довольно простой задачи вынуждает использовать намного более сложный путь с кучей оверинжениринга (а навешивание и удаление хэндлеров событий при помощи intersection observer вместо того, чтобы сделать делегирование выглядит крайним овериженирингом), то что-то идет не так.
Xuxicheta
а как делегировать mouseenter? it doesn't bubble.
Можно было конечно через mouseover сделать, не факт что оно работает с tippy.
Но честно говоря не вижу разницы. Директива пишется один раз и потом можно забыть как оно устроено.
Druu
Это не задача — это решение. И если это решение плохо реализуется во фреймворке — значит, просто, это решение считается плохим.