company_banner

Observable сервисы в Angular

    Всем привет, меня зовут Владимир. Я занимаюсь фронтенд разработкой в 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, — посмотрите все наши релизы.

    Tinkoff
    it’s Tinkoff — просто о сложном

    Comments 19

      +1
      Пробовали заменять наследование композицией? Меньше токенов придётся плодить. И вообще, я не суеверный, но есть такой антипаттерн — Базовый класс-утилита (BaseBean): Наследование функциональности из класса-утилиты вместо делегирования к нему.
        +2
        Поскольку везде, где этот сервис будет использоваться его можно типизировать, как Observable, тут как раз зависимость «is a», а не «has a», поэтому принципы ООП наследование не нарушает и под BaseBean не подпадает.
        +2
        Я не хочу разводить холивары, посыл статьи отличный — ограждаться от стороннего АПИ. В прокидывании Observable через InjectionToken тоже ничего плохого. Но если бы я захотел пользоваться такой утилиткой, то я бы ожидал примерно следующий GeolocationService.

        @Injectable({ providedIn: 'root' })
        export class GeolocationService {
        	private geolocation?: Geolocation = this.document.defaultView?.navigator?.geolocation;
        
        	constructor(@Inject(DOCUMENT) private document: Document) {}
        
        	isSupported(): boolean {
        		return !!this.geolocation;
        	}
        
        	getCurrentPosition(options?: PositionOptions): Observable<Position> {
        		const geolocation = this.getGeolocationOrThrowError();
        		return new Observable<Position>((subscriber) =>
        			geolocation.getCurrentPosition(
        				(position) => {
        					subscriber.next(position);
        					subscriber.complete();
        				},
        				(positionError) => subscriber.error(positionError),
        				options,
        			),
        		);
        	}
        
        	private getGeolocationOrThrowError(): Geolocation {
        		assert(this.geolocation, 'Geolocation is not supported in your browser');
        		return this.geolocation;
        	}
        
        	watchPosition(options?: PositionOptions): Observable<Position> {
        		const geolocation = this.getGeolocationOrThrowError();
        		return new Observable<Position>((subscriber) => {
        			const watchPositionId = geolocation.watchPosition(
        				(position) => subscriber.next(position),
        				(positionError) => subscriber.error(positionError),
        				options,
        			);
        			return () => geolocation.clearWatch(watchPositionId);
        		});
        	}
        }
        


        На его основе я бы смог расшарить observable дальше как мне нужно (да, может быть сделать один глобальный position$).

        Но через применение наследования вы смешали абстракции. Первая абстракция — адаптер над АПИ. Вторая абстаракция — расшаренный observable. (Я так считаю)
          0

          Сорри за оффтоп, просто мысли вслух.
          Интересно, что в последнее время так много разговоров о том, что наследование — это зло и намного выгодней заменять его композицией и/или абстракцией. Есть подозрение, что скоро его настигнет судьба оператора goto… Но если подумать, наследование — это единственное, что качественно отличает ООП от других парадигм. Остальные "киты" так или иначе реализуемы в рамках не-ООП языков. Не хочу сказать, что ООП ожидает та же судьба, что и наследование, оно так и останется отличным инструментом структурирования кода. Но сколько новых холиваров это породит — сложно представить :)

            0
            через применение наследования вы смешали абстракции

            Если расшаренный observable добавляет лишню ответственность (с этим можно подискутировать отдельно, но я тут про другое), то мы его просто уберем из конструктора GeolocationService. Переложим ответственность на консумеров. А наследование-то при этом остается. То есть в данном случае наследование как таковое ничего не смешало.


            Признаюсь: наследование от observable, используемое в посте автора, для меня тоже выглядит чужеродно, непривычно. Но немного поразмыслив, я так и не нашел логического обоснования своей неприязни. Возможно, просто вкусовщина.

              +2

              А, подумал еще немного :). Там наверху запостили про избыточные токены. Насколько я вижу, у топикстартера они тут используются для конфигурации.


              Так вот, конфигурировать через DI сложнее, чем через параметры метода. Допустим, конфигурацию нам надо получить в рантайме с нашего сервера. Может, ключ API вообще разный у каждого залогиненного юзера. В случае топикстартера, это будет обертка типа


              fetchConfigFromApi().pipe(switchMap(config => 
                new GeolocationService(
                  this.geolocation, // OOPS - where should we get it from?
                  this.geolocationSupported, //  leaked implementation details
                  config
                )
              ))

              В случае, когда GeolocationService не является observable, а возвращает его из публичного метода, получается чище:


              fetchConfigFromApi().pipe(switchMap(config => 
                this.geolocationService.getGeolocation(config)
              ))
                0

                Конфигурация через DI хороша там, где оно не меняется. Через параметры вызова там, где меняется часто. Можно и совмещать эти два подхода. Что-то типа persistent config с возможностью переопределить в отдельных случаях. Пример такого подхода можно увидеть у нас в ng-dompurify — либы для использования DOMPurify в качестве санитайзера Angular:
                https://habr.com/ru/company/tinkoff/blog/459396/

                  0

                  Согласен! Допустим, весь Angular Material как раз через DI и конфигурируется. Ибо там конфигурация статическая. Всякие там MAT_DIALOG_DEFAULT_OPTIONS, MAT_MENU_SCROLL_STRATEGY.

            +1

            А зачем у вас @Inject(GeolocationService) при внедрении сервиса? Так вам приходится дублировать тип для внедряемой переменной. Почему не просто


            constructor(private readonly position$: GeolocationService)
              0
              Мы традиционно пишем Inject всегда, так как хуже от него не будет. В Ivy, наверное, это делать уже не нужно, но изначально пошло вроде вот почему. В старых версиях Angular чтобы это дело работало без Inject в JIT при локальной разработке надо было делать emitDecoratorMetadata: true. При этом он записывал типы параметров и клал их реально в код. Это ломало Angular Universal, в котором на бэке не было всех этих классов и при SSR оно падало с каким-нибудь reference KeyboardEvent is undefined. Надо будет эту тему освежить, как раз сейчас занимаюсь созданием пакета под Angular Universal.
              –4
              Не надо так делать. Не надо передавать методы в качестве коллбеков.

              navigator.geolocation.watchPosition(this.success, this.error);

              Дальше читать не стал. Как эти откровения попали в топ? Извините, наболело, болезнь кодревьюера.
                +1

                Это пример того, как работает НАТИВНЫЙ API.

                  –2
                  Пример того, как в точности будет работать НАТИВНЫЙ API можно найти по ссылке на MDN которую дает автор. А то что написано у автора, говорит о том что он не видит разницы между функцией и методом, и API так работать не будет, в общем случае. Что, трудно было забайндить или обернуть в стрелку? Это же основы, блин, зачем тебе Ангулар, если этого не знаешь. Такие люди тоннами сейчас на интервью идут и статьи пишут, грустно видеть.
                    0

                    А что конкретно в примере автора не будет работать как есть, без байнда/стрелки?

                      0
                      Вы имеете в виду, почему бы не передавать методы как коллбеки? Или вы имеете в виду, что консоле.лог (или что там, алерт) будет работать, поэтому пример ок? Я просто не уловил сути вопроса.
                        0

                        Суть вопроса в том, что, насколько я вижу, код в примере полностью рабочий.

                          –1
                          Ну и ок, о чем тогда спрашиваете? Отличный код, все работает. Пишите так.
                            0

                            Зачем же так резко? Не Вам этот код поддерживать. А чтоб уменьшить шансы того, что подобный код придется поддерживать, можно было бы и потрудиться дать развернутый ответ. Часто из комментов получаешь больше важной информации, чем из самой статьи.


                            Постараюсь ответить на коммент выше. Проблема в том, что при передаче метода в качестве аргумента теряется его контекст. То есть это будет работать, пока в передаваемом методе нет использования this. Если вдруг Вы захотите в методе success использовать что-то вроде this.message('success'), то получите ошибку. И без соответствующих тестов ловить ее придется в рантайме. Именно поэтому часто можно увидеть костыли типа this.handleClick = this.handleClick.bind(this); (строка из документации к реакту). Больше информации здесь https://learn.javascript.ru/bind


                            Сказанное выше справедливо для ES объектов. На счет TS — не уверен, может в TS эта проблема решена (хотя я в этом очень сомневаюсь). Проверить возможности нету, пишу с электрочайника. Если кто-то знает — просветите, плз.


                            PS: то, что код "полностью рабочий", не делает его хорошим.

                              +1

                              В ТС, кончено, всё точно так же. Код этот написан так, просто чтобы кратко показать, что оно работает на коллбэках. Автору статьи, уверен, и в голову не пришло, что кто-то станет читать статью про Ангуляр и обзёрваблы не зная, как работает this в таких ситуациях. Это всё равно, как докопаться, что там в коде функция doSomething в итоге глобальная, а значит либо оно совершенно бесполезное, либо адовый сайдэффект.

              Only users with full accounts can post comments. Log in, please.