Веб — наполненная фичами среда. Мы можем перемещаться по виртуальной реальности с помощью геймпада, играть на синтезаторе с MIDI-клавиатуры, покупать товары одним касанием пальца. Все эти впечатляющие возможности предоставляют нативные API, которые, как и их функциональность, крайне многообразны.
Angular — превосходная платформа с одними из лучших инструментов во фронтэнд-среде. И, конечно, есть определенный способ делать «по-ангуляровски». Что лично мне особенно нравится в этом фреймворке — это то чувство удовлетворенности, которое испытываешь, когда все сделано как надо: аккуратный код, четкая архитектура. Давайте разберемся, что делает код правильно написанным под Angular.

The Angular way
Я уже давно пишу на Angular, перенимая опыт у отличных инженеров, с которыми работаю, и черпая знания из обширной базы, доступной в сети. Некоторое время назад я заметил, что, хотя браузеры предоставляют огромные возможности, мало что из этого включено в Angular «из коробки». Так и задумано: ведь это просто платформа для создания своих продуктов и нужно заточить ее под свои нужды. Поэтому мы создали opensource-инициативу Web APIs for Angular. Ее цель — создание легковесных, качественных и идиоматических оберток для использования нативных API в Angular. Я бы хотел обсудить принципы написания хорошего код на примере библиотеки @ng-web-apis/intersection-observer.
По моему мнению, эти три концепции играют основную роль:
- Angular декларативен по природе, в то время как нативный и традиционный JavaScript-код зачастую императивный.
- У Angular крутая система внедрения зависимостей, которую можно активно использовать себе во благо.
- Angular строит логику на Observable, тогда как многие API базируются на коллбэках.
Давайте пройдемся по этим пунктам подробнее.
Декларативный vs императивный
Вот типичный кусок кода, который у вас будет, если вы захотите использовать IntersectionObserver:
const callback = entries => { ... }; const options = { root: document.querySelector('#scrollArea'), rootMargin: '10px', threshold: 1 }; const observer = new IntersectionObserver(callback, options); observer.observe(document.querySelector('#target'));

Император одобряет нативный API
Здесь не так много кода, но мы успели нарушить все три принципа, названные выше. В Angular подобную логику выносят в директиву с декларативной настройкой:
<div waIntersectionObserver waIntersectionThreshold="1" waIntersectionRootMargin="10px" (waIntersectionObservee)="onIntersection($event)" > I'm being observed </div>
Вы можете узнать больше о декларативной природе директив Angular в этой статье на примере Payment Request API. Я очень советую прочитать ее, так как для подробного разбора этого аспекта тут просто слишком мало кода.
Нам понадобится 2 директивы: одна для создания наблюдателя, другая — чтобы отметить наблюдаемый элемент. Так мы сможем отслеживать несколько элементов одним наблюдателем. Внутри второй директивы мы поручим всю работу сервису. Таким образом мы сможем следить и за хостом-компонентом, где директиву использовать не получится. Это также позволит абстрагироваться от императивных вызовов observe/unobserve.
Первая директива может наследоваться непосредственно от IntersectionObserver и хранить у себя Map для сопоставления элементов и обратных вызовов. Ведь если мы отслеживаем несколько элементов, нет смысла оповещать их все, если пересечение сработало только на одном:
@Directive({ selector: '[waIntersectionObserver]', }) export class IntersectionObserverDirective extends IntersectionObserver implements OnDestroy { private readonly callbacks = new Map<Element, IntersectionObserverCallback>(); constructor( @Optional() @Inject(INTERSECTION_ROOT) root: ElementRef<Element> | null, @Attribute('waIntersectionRootMargin') rootMargin: string | null, @Attribute('waIntersectionThreshold') threshold: string | null, ) { super( entries => { this.callbacks.forEach((callback, element) => { const filtered = entries.filter(({target}) => target === element); return filtered.length && callback(filtered, this); }); }, { root: root && root.nativeElement, rootMargin: rootMargin || ROOT_MARGIN_DEFAULT, threshold: threshold ? threshold.split(',').map(parseFloat) : THRESHOLD_DEFAULT, }, ); } observe(target: Element, callback: IntersectionObserverCallback = () => {}) { super.observe(target); this.callbacks.set(target, callback); } unobserve(target: Element) { super.unobserve(target); this.callbacks.delete(target); } ngOnDestroy() { this.disconnect(); } }
Сервис для второй директивы и необходимость передать корневой элемент для отслеживания пересечений приводят нас ко второму принципу — внедрению зависимостей.
Dependency Injection
Мы часто используем DI для передачи встроенных в Angular сущностей или сервисов, которые создаем сами. Но с его помощью можно делать куда больше. Я говорю про провайдеры, фабрики, токены и тому подобное. Например, нашей директиве необходимо получить корневой элемент, с которым мы будем отслеживать пересечения. Предоставим его с помощью токена и простой директивы:
@Directive({ selector: '[waIntersectionRoot]', providers: [ { provide: INTERSECTION_ROOT, useExisting: ElementRef, }, ], }) export class IntersectionRootDirective {}
Тогда наш шаблон станет выглядеть так:
<div waIntersectionRoot> ... <div waIntersectionObserver waIntersectionThreshold="1" waIntersectionRootMargin="10px" (waIntersectionObservee)="onIntersection($event)" > I'm being observed </div> ... </div>
Подробнее прочитать про DI и про то, как обуздать его мощь, можно в статье о нашей декларативной Web Audio API библиотеке под Angular.
Токены — полезный инструмент. Они добавляют обособленности в код. К примеру, этот токен может предоставляться каким-нибудь хост-компонентом, когда нам нужно отслеживать пересечения дочерних элементов с его границами.
Сервис дочерней директивы получает родительскую через DI и превращает работу IntersectionObserver в RxJS Observable, что мы обсудим далее.
Observables
В то время как нативные API полагаются на коллбэки, мы в Angular используем RxJs и его реактивную парадигму. Одна особенность Observable, про которую часто забывают, — это просто класс и от него можно наследоваться. Давайте сделаем сервис-абстракцию над IntersectionObserver, который превратит его в Observable. У нас уже есть подготовленная директива, осталось в ней зарегистрироваться:
@Injectable() export class IntersectionObserveeService extends Observable<IntersectionObserverEntry[]> { constructor( @Inject(ElementRef) {nativeElement}: ElementRef<Element>, @Inject(IntersectionObserverDirective) observer: IntersectionObserverDirective, ) { super(subscriber => { observer.observe(nativeElement, entries => { subscriber.next(entries); }); return () => { observer.unobserve(nativeElement); }; }); } }

Теперь у нас есть Observable, инкапсулирующий логику IntersectionObserver. Мы даже можем использовать эти классы вне Angular, передавая параметры в new-вызовы.
Мы применили похожий подход для создания
Observable-сервиса в Geolocation API и Resize Observer API, где подробно разобрали его.
Директива просто передаст этот сервис в качестве Output. Ведь класс EventEmitter, который мы привыкли использовать тоже наследуется от Observable и, соответственно, совместим с нашим сервисом:
@Directive({ selector: '[waIntersectionObservee]', outputs: ['waIntersectionObservee'], providers: [IntersectionObserveeService], }) export class IntersectionObserveeDirective { constructor( @Inject(IntersectionObserveeService) readonly waIntersectionObservee: Observable<IntersectionObserverEntry[]>, ) {} }
Теперь мы можем либо использовать директиву в шаблоне, либо запрашивать сервис и добавлять его в связки RxJs-операторов, таких как map, filter, switchMap, чтобы получить желаемую логику.
Заключение
Мы следовали всем трем озвученным принципам, чтобы создать декларативную библиотеку для использования IntersectionObserver в виде Observable. С ней можно работать всеми удобными способами благодаря DI и токенам. Она весит 1 КБ в .gzip и доступна на Github и npm.
Активное применение наследования, конечно, решение на любителя. Но мне кажется, тут смотрится вполне аккуратно. Работу полифиллов оно не нарушает, в чем можно убедиться, открыв демо в Internet Explorer.
Надеюсь, эта статья была для вас полезна и поможет создавать качественные и красивые приложения. Мне эти принципы дают еще и удовольствие от работы, и мы продолжим переносить нативные API в Angular. Если вам хочется попробовать в своих приложениях что-то более экзотическое, например создать двухмерную игру на Canvas или виртуальный инструмент для игры на MIDI-клавиатуре, — посмотрите все наши релизы.
Месяц назад я выступал на GDG DevParty Russia, рассказывая про использование нативных браузерных API в Angular. Если вам понравилась эта статья и хотелось бы увидеть больше примеров, приглашаю посмотреть запись:
ссылка на оригинал статьи https://habr.com/ru/company/tinkoff/blog/512608/
Добавить комментарий