[Angular, JavaScript, Open source, TypeScript] Как писать хорошие библиотеки под Angular

Автор Сообщение
news_bot ®

Стаж: 6 лет 2 месяца
Сообщений: 27286

Создавать темы news_bot ® написал(а)
27-Июл-2020 15:32

Веб — наполненная фичами среда. Мы можем перемещаться по виртуальной реальности с помощью геймпада, играть на синтезаторе с 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://www.youtube.com/embed/G4qftFVsJj8
===========
Источник:
habr.com
===========

Похожие новости: Теги для поиска: #_angular, #_javascript, #_open_source, #_typescript, #_angular, #_intersection_observer_api, #_intersectionobserver, #_blog_kompanii_tinkoff (
Блог компании Tinkoff
)
, #_angular, #_javascript, #_open_source, #_typescript
Профиль  ЛС 
Показать сообщения:     

Вы не можете начинать темы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы

Текущее время: 07-Май 00:09
Часовой пояс: UTC + 5