Как писать хорошие библиотеки под Angular

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

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

txi1dzb6tmfqbfgxayyqznvsqeq.png


The Angular way

Я уже давно пишу на Angular, перенимая опыт у отличных инженеров, с которыми работаю, и черпая знания из обширной базы, доступной в сети. Некоторое время назад я заметил, что, хотя браузеры предоставляют огромные возможности, мало что из этого включено в Angular «из коробки». Так и задумано: ведь это просто платформа для создания своих продуктов и нужно заточить ее под свои нужды. Поэтому мы создали opensource-инициативу Web APIs for Angular. Ее цель — создание легковесных, качественных и идиоматических оберток для использования нативных API в Angular. Я бы хотел обсудить принципы написания хорошего код на примере библиотеки @ng-web-apis/intersection-observer.

По моему мнению, эти три концепции играют основную роль:


  1. Angular декларативен по природе, в то время как нативный и традиционный JavaScript-код зачастую императивный.
  2. У Angular крутая система внедрения зависимостей, которую можно активно использовать себе во благо.
  3. 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'));

cdi7cfnocm3shy4qekcwqyygmg0.png
Император одобряет нативный API

Здесь не так много кода, но мы успели нарушить все три принципа, названные выше. В Angular подобную логику выносят в директиву с декларативной настройкой:

I'm being observed


Вы можете узнать больше о декларативной природе директив Angular в этой статье на примере Payment Request API. Я очень советую прочитать ее, так как для подробного разбора этого аспекта тут просто слишком мало кода.

Нам понадобится 2 директивы: одна для создания наблюдателя, другая — чтобы отметить наблюдаемый элемент. Так мы сможем отслеживать несколько элементов одним наблюдателем. Внутри второй директивы мы поручим всю работу сервису. Таким образом мы сможем следить и за хостом-компонентом, где директиву использовать не получится. Это также позволит абстрагироваться от императивных вызовов observe/unobserve.

Первая директива может наследоваться непосредственно от IntersectionObserver и хранить у себя Map для сопоставления элементов и обратных вызовов. Ведь если мы отслеживаем несколько элементов, нет смысла оповещать их все, если пересечение сработало только на одном:

@Directive({
   selector: '[waIntersectionObserver]',
})
export class IntersectionObserverDirective extends IntersectionObserver
   implements OnDestroy {
   private readonly callbacks = new Map();

   constructor(
       @Optional() @Inject(INTERSECTION_ROOT) root: ElementRef | 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 {}

Тогда наш шаблон станет выглядеть так:

...
I'm being observed
...


Подробнее прочитать про DI и про то, как обуздать его мощь, можно в статье о нашей декларативной Web Audio API библиотеке под Angular.

Токены — полезный инструмент. Они добавляют обособленности в код. К примеру, этот токен может предоставляться каким-нибудь хост-компонентом, когда нам нужно отслеживать пересечения дочерних элементов с его границами.

Сервис дочерней директивы получает родительскую через DI и превращает работу IntersectionObserver в RxJS Observable, что мы обсудим далее.


Observables

В то время как нативные API полагаются на коллбэки, мы в Angular используем RxJs и его реактивную парадигму. Одна особенность Observable, про которую часто забывают, — это просто класс и от него можно наследоваться. Давайте сделаем сервис-абстракцию над IntersectionObserver, который превратит его в Observable. У нас уже есть подготовленная директива, осталось в ней зарегистрироваться:

@Injectable()
export class IntersectionObserveeService extends Observable {
   constructor(
       @Inject(ElementRef) {nativeElement}: ElementRef,
       @Inject(IntersectionObserverDirective)
       observer: IntersectionObserverDirective,
   ) {
       super(subscriber => {
           observer.observe(nativeElement, entries => {
               subscriber.next(entries);
           });

           return () => {
               observer.unobserve(nativeElement);
           };
       });
   }
}

n7cla_aus6crip7oh78ai1ki3uc.png

Теперь у нас есть 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,
   ) {}
}

Теперь мы можем либо использовать директиву в шаблоне, либо запрашивать сервис и добавлять его в связки RxJs-операторов, таких как map, filter, switchMap, чтобы получить желаемую логику.


Заключение

Мы следовали всем трем озвученным принципам, чтобы создать декларативную библиотеку для использования IntersectionObserver в виде Observable. С ней можно работать всеми удобными способами благодаря DI и токенам. Она весит 1 КБ в .gzip и доступна на Github и npm.

Активное применение наследования, конечно, решение на любителя. Но мне кажется, тут смотрится вполне аккуратно. Работу полифиллов оно не нарушает, в чем можно убедиться, открыв демо в Internet Explorer.

Надеюсь, эта статья была для вас полезна и поможет создавать качественные и красивые приложения. Мне эти принципы дают еще и удовольствие от работы, и мы продолжим переносить нативные API в Angular. Если вам хочется попробовать в своих приложениях что-то более экзотическое, например создать двухмерную игру на Canvas или виртуальный инструмент для игры на MIDI-клавиатуре, — посмотрите все наши релизы.

Месяц назад я выступал на GDG DevParty Russia, рассказывая про использование нативных браузерных API в Angular. Если вам понравилась эта статья и хотелось бы увидеть больше примеров, приглашаю посмотреть запись:


© Habrahabr.ru