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