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