Всем привет, меня зовут Владимир. Я занимаюсь фронтенд разработкой в Tinkoff.ru.
В Ангуляре для передачи данных внутри приложения или для инкапсуляции бизнес-логики мы привыкли использовать сервисы. Для управления асинхронными потоками отлично подходит RxJS.
Ангуляр в сочетании с RxJS позволяет писать в декларативном стиле, коротко и ясно. Но иногда мы сталкиваемся со сторонними библиотеками или API, которые используют коллбэки, промисы, тем самым подталкивают нас отступить от привычного стиля и писать императивно.
Цель статьи — показать на примере подобных API, как с помощью RxJS их можно без проблем обернуть в Observable-сервисы. Это поможет достичь удобства использования в Ангуляре. Начнем с Geolocation API.
Geolocation API
Geolocation API позволяет пользователю предоставлять свое местоположение веб-приложению, если пользователь согласится на это. Из соображений конфиденциальности у пользователя будет запрошено разрешение на предоставление информации о местоположении.
Ниже показан пример базового использования нативного Geolocation API.
Сначала необходимо убедиться, что Geolocation поддерживается браузером:
if('geolocation' in navigator) {
/* geolocation is available */
} else {
/* geolocation IS NOT available */
}
Затем на помощь приходит метод getCurrentPosition(), который единожды передает позицию пользователя. Метод watchPosition(), в свою очередь, позволит отслеживать позицию пользователя в течение определенного времени. Эти методы принимают коллбэки, которые обработают результат в случае получения данных или при возникновении ошибки.
...
success(position) {
doSomething(position.coords.latitude, position.coords.longitude);
}
error() {
alert('Sorry, no position available.');
}
watch() {
this.watchID = navigator.geolocation.watchPosition(this.success, this.error);
}
stopWatch() {
navigator.geolocation.clearWatch(this.watchID);
}
...
Если необходимо отследить позицию пользователя в течение какого-то времени, по окончании важно не забыть вызвать метод clearWatch() с id наблюдателя за местоположением. Оставленный наблюдатель увеличивает потребление энергии устройством. Но такой базовый вариант неудобен для использования в Ангуляре. Давайте разберемся, как это исправить с помощью RxJS Observable.
Сначала создадим токен для получения объекта Geolocation, чтобы не использовать глобальный объект напрямую. В дальнейшем это облегчит тестирование и сделает возможным запуск на стороне сервера. Для этой цели воспользуемся токеном NAVIGATOR пакета @ng-web-apis/common.
export const GEOLOCATION = new InjectionToken<Geolocation>(
'An abstraction over window.navigator.geolocation object',
{
factory: () => inject(NAVIGATOR).geolocation,
},
);
Также понадобится токен, с помощью которого проверим поддержку браузером Geolocation API:
export const GEOLOCATION_SUPPORT = new InjectionToken<boolean>(
'Is Geolocation API supported?',
{
factory: () => !!inject(GEOLOCATION),
},
);
И еще один токен для передачи PositionOptions:
import {InjectionToken} from '@angular/core';
export const POSITION_OPTIONS = new InjectionToken<PositionOptions>(
'Token for an additional position options',
{factory: () => ({})},
);
Наконец пришло время создать сервис! Observable поможет добиться простоты его использования.
Постоянно работая с Observable, часто забывают, что это обычный класс, от которого можно наследоваться. Конструктор его класса принимает в качестве аргумента функцию, которая вызывается в момент подписки. Эта функция предоставляет объект Subscriber, который в свою очередь передает значения подписчикам, уведомляет об успешном завершении или прерывает поток с ошибкой с помощью методов next(), complete() и error(). Посмотрим, как это выглядит:
@Injectable({
providedIn: 'root',
})
export class GeolocationService extends Observable<Position> {
constructor(
@Inject(GEOLOCATION) geolocationRef: Geolocation) {
super(subscriber => {
geolocationRef.watchPosition(
position => subscriber.next(position),
positionError => subscriber.error(positionError),
);
});
}
}
Следующим шагом добавим проверку поддержки браузера и опции в конструктор сервиса:
@Injectable({
providedIn: 'root',
})
export class GeolocationService extends Observable<Position> {
constructor(
@Inject(GEOLOCATION) geolocationRef: Geolocation,
@Inject(GEOLOCATION_SUPPORT) geolocationSupported: boolean,
@Inject(POSITION_OPTIONS) positionOptions: PositionOptions,
) {
super(subscriber => {
if (!geolocationSupported) {
subscriber.error('Geolocation is not supported in your browser');
}
geolocationRef.watchPosition(
position => subscriber.next(position),
positionError => subscriber.error(positionError),
positionOptions,
);
})
}
}
Финишная прямая! Поскольку сервис наследуется от класса Observable, у него есть метод pipe(). Теперь появилась возможность применять цепочку RxJS-операторов непосредственно к нашему сервису. Чтобы при множественных подписках функция внутри конструктора выполнялась только раз, а ее результат распространялся на всех подписчиков, используем RxJS-оператор shareReplay().
Почему не share()? К сожалению, в нативном Geolocation API одновременный вызов getCurrentPosition() и watchPosition() приводит к неожиданному поведению и возможной ошибке Timeout expired. Оператор shareReplay() решит проблему, повторив последние значения наблюдателя геопозиции для новых подписчиков, что позволит одновременно отслеживать текущую геопозицию в одном месте и получать ее единоразово в другом. Передаем в него опции {bufferSize: 1, refCount: true}, чтобы работал механизм подсчета количества подписчиков. Для автоматического вызова clearWatch()-метода, когда отписывается последний подписчик, используем RxJS-оператор finalize():
@Injectable({
providedIn: 'root',
})
export class GeolocationService extends Observable<Position> {
constructor(
@Inject(GEOLOCATION) geolocationRef: Geolocation,
@Inject(GEOLOCATION_SUPPORT) geolocationSupported: boolean,
@Inject(POSITION_OPTIONS)
positionOptions: PositionOptions,
) {
let watchPositionId = 0;
super(subscriber => {
if (!geolocationSupported) {
subscriber.error('Geolocation is not supported in your browser');
}
watchPositionId = geolocationRef.watchPosition(
position => subscriber.next(position),
positionError => subscriber.error(positionError),
positionOptions,
);
});
return this.pipe(
finalize(() => geolocationRef.clearWatch(watchPositionId)),
shareReplay({bufferSize: 1, refCount: true}),
);
}
}
Сервис готов! Использовать его в компонентах просто и удобно, взгляните на пример ниже:
...
constructor(@Inject(GeolocationService) private readonly position$: Observable<Position>) {}
...
position$.pipe(take(1)).subscribe(position => doSomethingWithPosition(position));
...
Можно также передавать позицию напрямую в шаблон, используя async pipe. Это отлично сочетается с @angular/google-maps или любым другим кастомным компонентом карты.
<app-map
[position]="position$ | async"
></app-map>
Больше не стоит беспокоиться об удалении наблюдателя и можно пользоваться возможностями RxJS при работе с геолокацией.
Мы рассмотрели создание наблюдаемого сервиса на примере Geolocation API. Полный код данного решения смотрите здесь. Он готов к использованию и опубликован в npm.
Не будем останавливаться на этом и на примере ResizeObserver API посмотрим, как наблюдаемые сервисы можно с легкостью обернуть в директивы.
ResizeObserver API
API Resize Observer предоставляет эффективный механизм, с помощью которого приложение может отслеживать элемент на предмет изменения его размера.
Как и Geolocation API, нативный ResizeObserver API неудобен для использования в Ангуляре. Но мы можем обернуть его в Observable-сервис. Не буду снова подробно описывать, как это сделать. Это похоже на сервис, который написан выше, взгляните на код:
@Injectable()
export class ResizeObserverService extends Observable<
ReadonlyArray<ResizeObserverEntry>
> {
constructor(
@Inject(ElementRef) {nativeElement}: ElementRef<Element>,
@Inject(NgZone) ngZone: NgZone,
@Inject(RESIZE_OBSERVER_SUPPORT) support: boolean,
@Inject(RESIZE_OPTION_BOX) box: ResizeObserverOptions['box'],
) {
let observer: ResizeObserver;
super(subscriber => {
if (!support) {
subscriber.error('ResizeObserver is not supported in your browser');
}
observer = new ResizeObserver(entries => {
ngZone.run(() => {
subscriber.next(entries);
});
});
observer.observe(nativeElement, {box});
});
return this.pipe(
finalize(() => observer.disconnect()),
share(),
);
}
}
В новой версии Ангуляра зона сама реагирует на ResizeObserver. Мы обернули вызов next() в ngZone() только для поддержки старых версий Ангуляра.
Единственное значительное отличие от Geolocation-сервиса заключается в том, что в конструкторе внедрен ElementRef. Это нужно, чтобы передать ссылку на наблюдаемый элемент в метод observe(). Теперь появилась возможность использовать сервис напрямую из нашего компонента. Механизм Dependency Injection позаботится о том, чтобы передать ссылку на нативный элемент в сервис.
Чтобы сервис стал удобнее в использовании, обернем его в директиву. Сделать сделать очень просто. Необходимо внедрить сервис в конструкторе директивы и направить его значения в Output(), как показано ниже:
@Directive({
selector: '[waResizeObserver]',
providers: [
ResizeObserverService,
{
provide: RESIZE_OPTION_BOX,
deps: [[new Attribute('waResizeBox')]],
useFactory: boxFactory,
},
],
})
export class ResizeObserverDirective {
@Output()
readonly waResizeObserver: Observable<ResizeObserverEntry[]>;
constructor(
@Inject(ResizeObserverService)
entries$: Observable<ResizeObserverEntry[]>
) {
this.waResizeObserver = entries$;
}
}
Здесь стоит обратить внимание на то, как RESIZE_OPTION_BOX добавлены в провайдеры директивы. Использование такой конструкции позволит указывать опции ReziseObserver прямо из шаблона или получать дефолтное значение с помощью фабрики.
Директива готова. Посмотрим, как просто ее использовать в компонентах:
<div
waResizeBox="content-box"
(waResizeObserver)="onResize($event)"
>
Resizable box
</div>
...
onResize(entry: ResizeObserverEntry[]) {
// do something with entry
}
...
Это решение также оформлено в крошечную open-source-библиотеку, которая доступна на npm. Посмотреть весь код можно на «Гитхабе».
Заключение
Рассмотренные методы позволяют использовать полный арсенал RxJS и Dependency Injection даже с библиотеками и API, не заточенными под Ангуляр. Также они помогут оградить компоненты от излишней логики. Если нужно работать уже с существующими потоками или промисами, альтернативный способ — создание токена с фабрикой, как это сделано в @ng-web-apis/midi. Подробнее об этом можно почитать здесь.
Надеюсь, идеи этой статьи помогут в создании простых и удобных сервисов.
Примеры, рассмотренные в статье, являются частью большого проекта под названием Web APIs for Angular. Наша цель — создание легковесных качественных оберток для использования нативного API в Angular-приложениях. Так что, если вам нужен, к примеру, Payment Request API или Intersection Observer, — посмотрите все наши релизы.
Justerest
Пробовали заменять наследование композицией? Меньше токенов придётся плодить. И вообще, я не суеверный, но есть такой антипаттерн — Базовый класс-утилита (BaseBean): Наследование функциональности из класса-утилиты вместо делегирования к нему.
Waterplea
Поскольку везде, где этот сервис будет использоваться его можно типизировать, как Observable, тут как раз зависимость «is a», а не «has a», поэтому принципы ООП наследование не нарушает и под BaseBean не подпадает.